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
+163
View File
@@ -0,0 +1,163 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
type Settings = {
fit: "contain" | "cover";
scale: number;
offsetX: number;
offsetY: number;
rotation: number;
brightness: number;
contrast: number;
saturation: number;
borderRadius: number;
};
type Slide = { id: string; fileName: string; originalName: string; mimeType: string; settings: Settings };
type Project = { id: string; name: string; canvas: { width: number; height: number; background: string }; slides: Slide[]; createdAt: string; updatedAt: string };
async function readJson<T>(response: Response): Promise<T> {
const data = await response.json();
if (!response.ok) throw new Error(data.error || `请求失败:${response.status}`);
return data;
}
function imageUrl(projectId: string, fileName: string) { return `/api/image-projects/${projectId}/images/${fileName}`; }
export default function ImageEditor({ projectId }: { projectId: string }) {
const [project, setProject] = useState<Project | null>(null);
const [selectedId, setSelectedId] = useState("");
const [busy, setBusy] = useState("");
const [message, setMessage] = useState("正在载入项目…");
const fileInput = useRef<HTMLInputElement>(null);
const drag = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>(null);
const load = useCallback(async () => {
try {
const data = await readJson<{ project: Project }>(await fetch(`/api/image-projects/${projectId}`));
setProject(data.project);
setSelectedId((current) => current || data.project.slides[0]?.id || "");
setMessage("项目已载入");
} catch (error) { setMessage((error as Error).message); }
}, [projectId]);
useEffect(() => { void load(); }, [load]);
const selected = project?.slides.find((slide) => slide.id === selectedId) || project?.slides[0];
function updateSettings(patch: Partial<Settings>) {
if (!project || !selected) return;
setProject({ ...project, slides: project.slides.map((slide) => slide.id === selected.id ? { ...slide, settings: { ...slide.settings, ...patch } } : slide) });
}
async function save() {
if (!project) return;
setBusy("save");
try {
const data = await readJson<{ project: Project }>(await fetch(`/api/image-projects/${project.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(project) }));
setProject(data.project);
setMessage("所有修改已保存");
} catch (error) { setMessage((error as Error).message); }
finally { setBusy(""); }
}
async function addImages(files: FileList | null) {
if (!project || !files?.length) return;
setBusy("upload");
try {
const form = new FormData();
Array.from(files).forEach((file) => form.append("images", file));
const data = await readJson<{ project: Project }>(await fetch(`/api/image-projects/${project.id}/images`, { method: "POST", body: form }));
setProject(data.project);
setSelectedId(data.project.slides.at(-1)?.id || selectedId);
setMessage(`已添加 ${files.length} 张图片`);
} catch (error) { setMessage((error as Error).message); }
finally { setBusy(""); if (fileInput.current) fileInput.current.value = ""; }
}
function setCanvas(width: number, height: number) {
if (project) setProject({ ...project, canvas: { ...project.canvas, width, height } });
}
function onPointerDown(event: ReactPointerEvent<HTMLDivElement>) {
if (!selected) return;
event.currentTarget.setPointerCapture(event.pointerId);
drag.current = { x: event.clientX, y: event.clientY, offsetX: selected.settings.offsetX, offsetY: selected.settings.offsetY };
}
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>) {
if (!drag.current || !selected) return;
const rect = event.currentTarget.getBoundingClientRect();
updateSettings({ offsetX: Math.max(-100, Math.min(100, drag.current.offsetX + (event.clientX - drag.current.x) / rect.width * 100)), offsetY: Math.max(-100, Math.min(100, drag.current.offsetY + (event.clientY - drag.current.y) / rect.height * 100)) });
}
function onPointerUp(event: ReactPointerEvent<HTMLDivElement>) {
drag.current = null;
event.currentTarget.releasePointerCapture(event.pointerId);
}
if (!project) return <div className="editor-loading"><span className="editor-spinner" /><p>{message}</p><a href="/">返回首页</a></div>;
if (!selected) return <div className="editor-loading"><p>项目中没有图片</p><a href="/">返回首页</a></div>;
const style = {
transform: `translate(${selected.settings.offsetX}%, ${selected.settings.offsetY}%) scale(${selected.settings.scale / 100}) rotate(${selected.settings.rotation}deg)`,
filter: `brightness(${selected.settings.brightness}%) contrast(${selected.settings.contrast}%) saturate(${selected.settings.saturation}%)`,
objectFit: selected.settings.fit,
borderRadius: `${selected.settings.borderRadius}px`,
} as CSSProperties;
const ratioStyle = { "--canvas-ratio": `${project.canvas.width} / ${project.canvas.height}`, background: project.canvas.background } as CSSProperties;
return <main className="image-editor-shell">
<header className="editor-topbar">
<div className="editor-brand"><a href="/" className="editor-back">←</a><span className="editor-logo">小红书</span><i /><input value={project.name} onChange={(event) => setProject({ ...project, name: event.target.value })} /></div>
<div className="editor-status"><span className="status-dot" />{message}</div>
<div className="editor-actions"><button className="ghost-control" onClick={() => fileInput.current?.click()}>+ 添加图片</button><button className="save-control" onClick={save} disabled={Boolean(busy)}>{busy === "save" ? "保存中…" : "保存项目"}</button></div>
<input ref={fileInput} hidden type="file" accept="image/png,image/jpeg,image/webp" multiple onChange={(event) => void addImages(event.target.files)} />
</header>
<aside className="editor-filmstrip">
<div className="filmstrip-title"><span>页面</span><small>{project.slides.length}</small></div>
<div className="filmstrip-list">{project.slides.map((slide, index) => <button key={slide.id} className={`filmstrip-item ${selected.id === slide.id ? "active" : ""}`} onClick={() => setSelectedId(slide.id)}><span>{index + 1}</span><img src={imageUrl(project.id, slide.fileName)} alt={slide.originalName} /></button>)}</div>
<button className="filmstrip-add" onClick={() => fileInput.current?.click()}>+</button>
</aside>
<section className="editor-workspace">
<div className="canvas-toolbar"><button>↖ 选择</button><button>✋ 移动</button><i /><span>{project.canvas.width} × {project.canvas.height}</span><button onClick={() => updateSettings({ scale: 100, offsetX: 0, offsetY: 0, rotation: 0 })}>重置视图</button></div>
<div className="canvas-viewport">
<div className="canvas-page" style={ratioStyle} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp}>
<img draggable={false} src={imageUrl(project.id, selected.fileName)} alt={selected.originalName} style={style} />
<div className="canvas-selection"><i className="handle nw"/><i className="handle ne"/><i className="handle sw"/><i className="handle se"/></div>
</div>
</div>
<div className="editor-pagination"><button disabled={project.slides[0].id === selected.id} onClick={() => setSelectedId(project.slides[Math.max(0, project.slides.findIndex((item) => item.id === selected.id) - 1)].id)}>‹</button><span>{project.slides.findIndex((item) => item.id === selected.id) + 1} / {project.slides.length}</span><button disabled={project.slides.at(-1)?.id === selected.id} onClick={() => setSelectedId(project.slides[Math.min(project.slides.length - 1, project.slides.findIndex((item) => item.id === selected.id) + 1)].id)}>›</button></div>
</section>
<aside className="editor-inspector">
<div className="inspector-tabs"><button className="active">画面</button><button>文字</button><button>素材</button></div>
<InspectorSection title="画布">
<div className="ratio-buttons"><button className={project.canvas.width === project.canvas.height ? "active" : ""} onClick={() => setCanvas(1080, 1080)}>1:1</button><button className={project.canvas.width / project.canvas.height === .75 ? "active" : ""} onClick={() => setCanvas(1080, 1440)}>3:4</button><button className={project.canvas.width / project.canvas.height === .8 ? "active" : ""} onClick={() => setCanvas(1080, 1350)}>4:5</button></div>
<label className="color-field"><span>背景</span><input type="color" value={project.canvas.background} onChange={(event) => setProject({ ...project, canvas: { ...project.canvas, background: event.target.value } })} /><code>{project.canvas.background.toUpperCase()}</code></label>
</InspectorSection>
<InspectorSection title="图片适配">
<div className="segmented-control"><button className={selected.settings.fit === "cover" ? "active" : ""} onClick={() => updateSettings({ fit: "cover" })}>填充</button><button className={selected.settings.fit === "contain" ? "active" : ""} onClick={() => updateSettings({ fit: "contain" })}>完整</button></div>
<Range label="缩放" value={selected.settings.scale} min={20} max={300} suffix="%" onChange={(scale) => updateSettings({ scale })} />
<Range label="旋转" value={selected.settings.rotation} min={-180} max={180} suffix="°" onChange={(rotation) => updateSettings({ rotation })} />
<Range label="圆角" value={selected.settings.borderRadius} min={0} max={200} suffix="" onChange={(borderRadius) => updateSettings({ borderRadius })} />
</InspectorSection>
<InspectorSection title="画面调整">
<Range label="亮度" value={selected.settings.brightness} min={0} max={200} suffix="%" onChange={(brightness) => updateSettings({ brightness })} />
<Range label="对比度" value={selected.settings.contrast} min={0} max={200} suffix="%" onChange={(contrast) => updateSettings({ contrast })} />
<Range label="饱和度" value={selected.settings.saturation} min={0} max={200} suffix="%" onChange={(saturation) => updateSettings({ saturation })} />
</InspectorSection>
</aside>
</main>;
}
function InspectorSection({ title, children }: { title: string; children: React.ReactNode }) {
return <section className="inspector-section"><h3>{title}<button>⌃</button></h3>{children}</section>;
}
function Range({ label, value, min, max, suffix, onChange }: { label: string; value: number; min: number; max: number; suffix: string; onChange: (value: number) => void }) {
return <label className="range-field"><div><span>{label}</span><code>{Math.round(value)}{suffix}</code></div><input type="range" min={min} max={max} value={value} onChange={(event) => onChange(Number(event.target.value))} /></label>;
}