"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(response: Response): Promise { 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(null); const [selectedId, setSelectedId] = useState(""); const [busy, setBusy] = useState(""); const [message, setMessage] = useState("正在载入项目…"); const fileInput = useRef(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) { 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) { 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) { 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) { drag.current = null; event.currentTarget.releasePointerCapture(event.pointerId); } if (!project) return

{message}

返回首页
; if (!selected) return

项目中没有图片

返回首页
; 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
←小红书 setProject({ ...project, name: event.target.value })} />
{message}
void addImages(event.target.files)} />
{project.canvas.width} × {project.canvas.height}
{selected.originalName}
{project.slides.findIndex((item) => item.id === selected.id) + 1} / {project.slides.length}
; } function InspectorSection({ title, children }: { title: string; children: React.ReactNode }) { return

{title}

{children}
; } function Range({ label, value, min, max, suffix, onChange }: { label: string; value: number; min: number; max: number; suffix: string; onChange: (value: number) => void }) { return ; }