一杯茶时间用 Next.js 编写的酷炫九宫格照片工具网站 😍😍😍

随着社交媒体的深度渗透,朋友圈、微博、Instagram 等平台已成为用户展示生活、分享瞬间的核心场景。其中,"九宫格"排版形式凭借其规整的视觉美感和内容叙事性,成为年轻用户(尤其是女性群体)高频使用的图片发布方式。

在接下来的内容中,我们将使用 NextJs 结合 sharp 来实现图片裁剪的功能。

编写基本页面

首先我们将编写一个图片裁剪的功能,以支持用户来生成不同规格的图片,例如 3x3、2x2 等格式。

如下代码所示:

tsx 复制代码
"use client";

import { useState, useRef } from "react";

const Home = () => {
  const [rows, setRows] = useState(3);
  const [columns, setColumns] = useState(3);
  const [image, setImage] = useState<string | null>(null);
  const [splitImages, setSplitImages] = useState<string[]>([]);
  const [isProcessing, setIsProcessing] = useState(false);
  const [imageLoaded, setImageLoaded] = useState(false);
  const imageRef = useRef<HTMLImageElement>(null);
  const fileInputRef = useRef<HTMLInputElement>(null);

  const handleFileUpload = (file: File) => {
    if (!file.type.startsWith("image/")) {
      alert("请上传图片文件");

      return;
    }

    // 先重置状态
    setImage(null);
    setSplitImages([]);
    setImageLoaded(false);

    const reader = new FileReader();

    reader.onload = (e) => {
      if (e.target?.result) {
        setImage(e.target.result as string);
      }
    };

    reader.readAsDataURL(file);
  };

  // 修改图片加载完成的处理函数
  const handleImageLoad = () => {
    console.log("Image loaded"); // 添加日志以便调试
    setImageLoaded(true);
  };

  // 渲染切割辅助线
  const renderGuideLines = () => {
    if (!image || !imageRef.current || !imageLoaded) return null;

    const commonLineStyles = "bg-blue-500/50 absolute pointer-events-none";
    const imgRect = imageRef.current.getBoundingClientRect();
    const containerRect =
      imageRef.current.parentElement?.getBoundingClientRect();

    if (!containerRect) return null;

    const imgStyle = {
      left: `${imgRect.left - containerRect.left}px`,
      top: `${imgRect.top - containerRect.top}px`,
      width: `${imgRect.width}px`,
      height: `${imgRect.height}px`,
    };

    return (
      <div className="absolute pointer-events-none" style={imgStyle}>
        {/* 垂直线 */}
        {Array.from({ length: Math.max(0, columns - 1) }).map((_, i) => (
          <div
            key={`v-${i}`}
            className={`${commonLineStyles} top-0 bottom-0 w-[1px] md:w-[2px] backdrop-blur-sm`}
            style={{
              left: `${((i + 1) * 100) / columns}%`,
              transform: "translateX(-50%)",
            }}
          />
        ))}
        {/* 水平线 */}
        {Array.from({ length: Math.max(0, rows - 1) }).map((_, i) => (
          <div
            key={`h-${i}`}
            className={`${commonLineStyles} left-0 right-0 h-[1px] md:h-[2px] backdrop-blur-sm`}
            style={{
              top: `${((i + 1) * 100) / rows}%`,
              transform: "translateY(-50%)",
            }}
          />
        ))}
      </div>
    );
  };

  // 处理图片切割
  const handleSplitImage = async () => {
    if (!image) return;

    setIsProcessing(true);

    try {
      const response = await fetch(image);
      const blob = await response.blob();
      const file = new File([blob], "image.jpg", { type: blob.type });

      const formData = new FormData();
      formData.append("image", file);
      formData.append("rows", rows.toString());
      formData.append("columns", columns.toString());

      const res = await fetch("/api/split-image", {
        method: "POST",
        body: formData,
      });

      const data = await res.json();

      if (data.error) {
        throw new Error(data.error);
      }

      setSplitImages(data.pieces);
    } catch (error) {
      console.error("Failed to split image:", error);
      alert("图片切割失败,请重试");
    } finally {
      setIsProcessing(false);
    }
  };

  // 添加下载单个图片的函数
  const handleDownloadSingle = async (imageUrl: string, index: number) => {
    try {
      const response = await fetch(imageUrl);
      const blob = await response.blob();
      const url = window.URL.createObjectURL(blob);
      const link = document.createElement("a");
      link.href = url;
      link.download = `piece_${index + 1}.png`;
      document.body.appendChild(link);
      link.click();
      document.body.removeChild(link);
      window.URL.revokeObjectURL(url);
    } catch (error) {
      console.error("下载失败:", error);
      alert("下载失败,请重试");
    }
  };

  // 添加打包下载所有图片的函数
  const handleDownloadAll = async () => {
    try {
      // 如果没有 JSZip,需要先动态导入
      const JSZip = (await import("jszip")).default;
      const zip = new JSZip();

      // 添加所有图片到 zip
      const promises = splitImages.map(async (imageUrl, index) => {
        const response = await fetch(imageUrl);
        const blob = await response.blob();
        zip.file(`piece_${index + 1}.png`, blob);
      });

      await Promise.all(promises);

      // 生成并下载 zip 文件
      const content = await zip.generateAsync({ type: "blob" });
      const url = window.URL.createObjectURL(content);
      const link = document.createElement("a");
      link.href = url;
      link.download = "split_images.zip";
      document.body.appendChild(link);
      link.click();
      document.body.removeChild(link);
      window.URL.revokeObjectURL(url);
    } catch (error) {
      console.error("打包下载失败:", error);
      alert("打包下载失败,请重试");
    }
  };

  // 修改预览区域的渲染函数
  const renderPreview = () => {
    if (!image) {
      return <p className="text-gray-400">切割后的图片预览</p>;
    }

    if (isProcessing) {
      return <p className="text-gray-400">正在处理中...</p>;
    }

    if (splitImages.length > 0) {
      return (
        <div className="relative w-full h-full flex items-center justify-center">
          <div
            className="grid gap-[3px] bg-[#242c3e]"
            style={{
              gridTemplateColumns: `repeat(${columns}, 1fr)`,
              gridTemplateRows: `repeat(${rows}, 1fr)`,
              width: imageRef.current?.width || "100%",
              height: imageRef.current?.height || "100%",
              maxWidth: "100%",
              maxHeight: "100%",
            }}
          >
            {splitImages.map((src, index) => (
              <div key={index} className="relative group">
                <img
                  src={src}
                  alt={`切片 ${index + 1}`}
                  className="w-full h-full object-cover"
                />
                <button
                  onClick={() => handleDownloadSingle(src, index)}
                  className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity"
                >
                  <svg
                    className="w-6 h-6 text-white"
                    fill="none"
                    stroke="currentColor"
                    viewBox="0 0 24 24"
                  >
                    <path
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      strokeWidth={2}
                      d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
                    />
                  </svg>
                </button>
              </div>
            ))}
          </div>
        </div>
      );
    }

    return <p className="text-gray-400">点击切割按钮开始处理</p>;
  };

  return (
    <>
      <div className="fixed inset-0 bg-[#0B1120] -z-10" />
      <main className="min-h-screen w-full py-16 md:py-20">
        <div className="container mx-auto px-4 sm:px-6 max-w-7xl">
          {/* 标题区域 */}
          <div className="text-center mb-12 md:mb-16">
            <h1 className="text-3xl md:text-4xl lg:text-5xl font-bold mb-4 animate-fade-in">
              <span className="bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 text-transparent bg-clip-text bg-[size:400%] animate-gradient">
                图片切割工具
              </span>
            </h1>
            <p className="text-gray-400 text-base md:text-lg max-w-2xl mx-auto animate-fade-in-up">
              上传一张图片,快速将其切割成网格布局,支持自定义行列数。
            </p>
          </div>

          {/* 图片区域 - 调整高度和响应式布局 */}
          <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 md:gap-8 mb-12 md:mb-16">
            {/* 上传区域 - 调整高度 */}
            <div className="relative h-[400px] md:h-[500px] lg:h-[600px] bg-[#1a2234] rounded-xl overflow-hidden">
              <input
                ref={fileInputRef}
                type="file"
                accept="image/*"
                className="hidden"
                onChange={(e) => {
                  const file = e.target.files?.[0];
                  if (file) handleFileUpload(file);
                }}
                id="imageUpload"
              />
              <label
                htmlFor="imageUpload"
                className="absolute inset-0 flex flex-col items-center justify-center cursor-pointer"
              >
                {image ? (
                  <div className="relative w-full h-full flex items-center justify-center">
                    <img
                      ref={imageRef}
                      src={image}
                      alt="上传的图片"
                      className="max-w-full max-h-full object-contain"
                      onLoad={handleImageLoad}
                      key={image}
                    />
                    {renderGuideLines()}
                  </div>
                ) : (
                  <>
                    <div className="p-4 rounded-full bg-[#242c3e] mb-4">
                      <svg
                        className="w-8 h-8 text-gray-400"
                        fill="none"
                        stroke="currentColor"
                        viewBox="0 0 24 24"
                      >
                        <path
                          strokeLinecap="round"
                          strokeLinejoin="round"
                          strokeWidth={1.5}
                          d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
                        />
                      </svg>
                    </div>
                    <p className="text-gray-400">点击或拖拽图片到这里上传</p>
                  </>
                )}
              </label>
            </div>

            {/* 预览区域 - 调整高度 */}
            <div className="h-[400px] md:h-[500px] lg:h-[600px] bg-[#1a2234] rounded-xl flex items-center justify-center">
              {renderPreview()}
            </div>
          </div>

          {/* 控制器 - 添加上下边距的容器 */}
          <div className="py-4 md:py-6">
            <div className="flex flex-col sm:flex-row items-center justify-center gap-4 md:gap-6">
              <div className="flex flex-col sm:flex-row items-center gap-4 w-full sm:w-auto">
                <div className="bg-[#1a2234] rounded-xl px-5 py-2.5 flex items-center gap-4 border border-[#242c3e] w-full sm:w-auto">
                  <span className="text-gray-400 font-medium min-w-[40px]">
                    行数
                  </span>
                  <div className="flex items-center gap-2 bg-[#242c3e] rounded-lg p-1 flex-1 sm:flex-none justify-center">
                    <button
                      className="w-8 h-8 flex items-center justify-center text-white rounded-lg hover:bg-blue-500 transition-colors"
                      onClick={() => setRows(Math.max(1, rows - 1))}
                    >
                      -
                    </button>
                    <span className="text-white min-w-[32px] text-center font-medium">
                      {rows}
                    </span>
                    <button
                      className="w-8 h-8 flex items-center justify-center text-white rounded-lg hover:bg-blue-500 transition-colors"
                      onClick={() => setRows(rows + 1)}
                    >
                      +
                    </button>
                  </div>
                </div>

                <div className="bg-[#1a2234] rounded-xl px-5 py-2.5 flex items-center gap-4 border border-[#242c3e] w-full sm:w-auto">
                  <span className="text-gray-400 font-medium min-w-[40px]">
                    列数
                  </span>
                  <div className="flex items-center gap-2 bg-[#242c3e] rounded-lg p-1 flex-1 sm:flex-none justify-center">
                    <button
                      className="w-8 h-8 flex items-center justify-center text-white rounded-lg hover:bg-blue-500 transition-colors"
                      onClick={() => setColumns(Math.max(1, columns - 1))}
                    >
                      -
                    </button>
                    <span className="text-white min-w-[32px] text-center font-medium">
                      {columns}
                    </span>
                    <button
                      className="w-8 h-8 flex items-center justify-center text-white rounded-lg hover:bg-blue-500 transition-colors"
                      onClick={() => setColumns(columns + 1)}
                    >
                      +
                    </button>
                  </div>
                </div>

                <button
                  className="px-5 py-2.5 bg-[#1a2234] text-gray-400 rounded-xl hover:bg-[#242c3e] hover:text-white transition-all font-medium border border-[#242c3e] w-full sm:w-auto"
                  onClick={() => {
                    setRows(3);
                    setColumns(3);
                  }}
                >
                  重置
                </button>
              </div>

              <div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
                <button
                  className="px-6 py-3 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-xl hover:from-blue-600 hover:to-blue-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 font-medium shadow-lg shadow-blue-500/20 w-full sm:w-auto"
                  disabled={!image || isProcessing}
                  onClick={handleSplitImage}
                >
                  {isProcessing ? "处理中..." : "切割图片"}
                </button>
                <button
                  className="px-6 py-3 bg-gradient-to-r from-red-500 to-red-600 text-white rounded-xl hover:from-red-600 hover:to-red-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed font-medium shadow-lg shadow-red-500/20 w-full sm:w-auto"
                  onClick={() => {
                    setImage(null);
                    setSplitImages([]);
                    setImageLoaded(false);

                    if (fileInputRef.current) {
                      fileInputRef.current.value = "";
                    }
                  }}
                  disabled={!image}
                >
                  清除
                </button>
              </div>

              {/* 下载按钮 */}
              {splitImages.length > 0 && (
                <button
                  onClick={handleDownloadAll}
                  className="px-6 py-3 bg-gradient-to-r from-green-500 to-green-600 text-white rounded-xl hover:from-green-600 hover:to-green-700 transition-all font-medium shadow-lg shadow-green-500/20 flex items-center justify-center gap-2 w-full sm:w-auto"
                >
                  <svg
                    className="w-5 h-5"
                    fill="none"
                    stroke="currentColor"
                    viewBox="0 0 24 24"
                  >
                    <path
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      strokeWidth={2}
                      d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
                    />
                  </svg>
                  打包下载
                </button>
              )}
            </div>
          </div>

          {/* 底部留白 */}
          <div className="h-16 md:h-20"></div>
        </div>
      </main>
    </>
  );
};

export default Home;

在上面的代码中,当用户上传图片兵当图片加载完成之后,renderGuideLines 方法会绘制图片的切割网格,显示垂直和水平的分隔线,便于用户预览切割后的效果。

它会根据用户设置的行数(rows)和列数(columns),这些切割线将被动态渲染。用户设置完行列数后,点击 "切割图片" 按钮,会触发 handleSplitImage 函数,将图片传递到后端进行切割。图片会被发送到后端接口 /api/split-image,并返回切割后的图片数据(即每个小图的 URL)。

最终 ui 效果如下图所示:

设计 API

前面的内容中,我们已经编写了前端的 ui,接下来我们要设计我们的 api 接口以支持前端页面调用。

首先我们要先知道一个概念,sharp 是一个高性能的 Node.js 图像处理库,支持各种常见的图像操作,如裁剪、调整大小、旋转、转换格式等。它基于 libvips(一个高效的图像处理库),与其他一些图像处理库相比,它的处理速度更快,内存消耗也更低。

而 Next.js 是一个服务器端渲染(SSR)的框架,可以通过 API 路由来处理用户上传的文件。在 API 路由中使用 sharp 可以确保图像在服务器端得到处理,而不是在客户端进行,这样可以减轻客户端的负担,并且保证图像在服务器上处理完成后再发送到客户端,从而提高页面加载速度。

如下代码所示:

ts 复制代码
import sharp from "sharp";
import { NextResponse } from "next/server";

export async function POST(request: Request) {
  try {
    const data = await request.formData();
    const file = data.get("image") as File;
    const rows = Number(data.get("rows"));
    const columns = Number(data.get("columns"));

    if (!file || !rows || !columns) {
      return NextResponse.json(
        { error: "Missing required parameters" },
        { status: 400 }
      );
    }

    if (!file.type.startsWith("image/")) {
      return NextResponse.json({ error: "Invalid file type" }, { status: 400 });
    }

    const buffer = Buffer.from(await file.arrayBuffer());

    if (!buffer || buffer.length === 0) {
      return NextResponse.json(
        { error: "Invalid image buffer" },
        { status: 400 }
      );
    }

    const image = sharp(buffer);
    const metadata = await image.metadata();

    if (!metadata.width || !metadata.height) {
      return NextResponse.json(
        { error: "Invalid image metadata" },
        { status: 400 }
      );
    }

    console.log("Processing image:", {
      width: metadata.width,
      height: metadata.height,
      format: metadata.format,
      rows,
      columns,
    });

    const pieces: string[] = [];
    const width = metadata.width;
    const height = metadata.height;
    const pieceWidth = Math.floor(width / columns);
    const pieceHeight = Math.floor(height / rows);

    if (pieceWidth <= 0 || pieceHeight <= 0) {
      return NextResponse.json(
        { error: "Invalid piece dimensions" },
        { status: 400 }
      );
    }

    for (let i = 0; i < rows; i++) {
      for (let j = 0; j < columns; j++) {
        const left = j * pieceWidth;
        const top = i * pieceHeight;
        const currentWidth = j === columns - 1 ? width - left : pieceWidth;
        const currentHeight = i === rows - 1 ? height - top : pieceHeight;

        try {
          const piece = await image
            .clone()
            .extract({
              left,
              top,
              width: currentWidth,
              height: currentHeight,
            })
            .toBuffer();

          pieces.push(
            `data:image/${metadata.format};base64,${piece.toString("base64")}`
          );
        } catch (err) {
          console.error("Error processing piece:", { i, j, err });
          throw err;
        }
      }
    }

    return NextResponse.json({ pieces });
  } catch (error) {
    console.error("Error processing image:", error);

    return NextResponse.json(
      {
        error: "Failed to process image",
        details: error instanceof Error ? error.message : "Unknown error",
      },
      { status: 500 }
    );
  }
}

export const config = {
  api: {
    bodyParser: {
      sizeLimit: "10mb",
    },
  },
};

在上面的这些代码中,它的具体流程如下:

  1. 接收请求:首先通过 POST 方法接收包含图片和切割参数(行数和列数)的表单数据。request.formData() 用来解析表单数据并提取文件和参数。

  2. 参数验证:检查文件类型是否为图片,确保上传数据完整且有效。如果缺少必需的参数或上传的不是图片,返回相应的错误信息。

  3. 图片处理:通过 sharp 库将图片数据转换为可操作的 Buffer,然后获取图片的元数据(如宽度、高度、格式)。如果图片的元数据无效或获取不到,返回错误。

  4. 计算切割尺寸:根据用户输入的行数和列数计算每个小块的宽高,并检查计算出来的尺寸是否有效。如果计算出的尺寸不合适,返回错误。

  5. 图片切割:使用 sharp 的 extract 方法对图片进行切割。每次提取一个小块后,将其转换为 base64 编码的字符串,并保存到 pieces 数组中。

  6. 返回结果:成功处理后,将切割后的图片数据作为 JSON 响应返回,每个图片切块以 base64 编码形式存储。若遇到错误,捕获并返回详细的错误信息。

  7. 配置:通过 config 配置,设置请求体的最大大小限制为 10MB,防止上传过大的文件导致请求失败。

这里的代码完成之后,我们还要设计一下 next.config.mjs 文件,如下:

js 复制代码
/** @type {import('next').NextConfig} */
const nextConfig = {
  webpack: (config) => {
    config.externals = [...config.externals, "sharp"];
    return config;
  },
};

export default nextConfig;

当我们点击切割图片的时候,最终生成的效果如下图所示:

总结

完整项目地址

如果你想参与进来开发或者想进群学习,可以添加我微信 yunmz777,后面还会有很多需求,等这个项目完成之后还会有很多新的并且很有趣的开源项目等着你。

如果该项目对你有帮助或者对这个项目感兴趣,欢迎 Star⭐️⭐️⭐️

最后再来提一些这两个开源项目,它们都是我们目前正在维护的开源项目:

🐒🐒🐒

相关推荐
子兮曰11 小时前
OpenClaw入门:从零开始搭建你的私有化AI助手
前端·架构·github
Victor35611 小时前
https://editor.csdn.net/md/?articleId=139321571&spm=1011.2415.3001.9698
后端
吴仰晖11 小时前
使用github copliot chat的源码学习之Chromium Compositor
前端
1024小神11 小时前
github发布pages的几种状态记录
前端
Victor35611 小时前
Hibernate(89)如何在压力测试中使用Hibernate?
后端
灰子学技术12 小时前
go response.Body.close()导致连接异常处理
开发语言·后端·golang
不像程序员的程序媛13 小时前
Nginx日志切分
服务器·前端·nginx
Daniel李华13 小时前
echarts使用案例
android·javascript·echarts
北原_春希13 小时前
如何在Vue3项目中引入并使用Echarts图表
前端·javascript·echarts
JY-HPS13 小时前
echarts天气折线图
javascript·vue.js·echarts