Files
chunyu_prject_react/docs/plans/2026-06-13-image-editor.md
2026-08-05 23:59:22 +08:00

36 KiB
Raw Permalink Blame History

图片编辑工具 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 为小雨开发工具箱的实用工具中心新增一个"图片编辑"工具,支持裁剪、旋转、翻转、亮度/对比度/饱和度调节、滤镜、添加文字、涂鸦画笔等功能,纯前端 Canvas 实现。

Architecture: 采用单页面组件架构,使用 HTML5 Canvas API 进行图像处理。顶部为 Hero 横幅,中间为工具栏 + 画布编辑区 + 属性面板的三栏布局,底部为提示卡片。所有图像处理操作在 Canvas 上实时渲染,支持撤销/重做、导出下载。

Tech Stack: React 18, TypeScript, Vite, Ant Design, HTML5 Canvas API


文件结构

文件 操作 说明
src/pages/ImageEditor/ImageEditor.tsx 创建 图片编辑主页面组件
src/pages/ImageEditor/ImageEditor.css 创建 图片编辑样式
src/pages/Utility/Utility.tsx 修改 在 utilityTools 数组和 routeMap 中注册新工具
src/App.tsx 修改 添加 /utility/image-editor 路由

设计规范

  • 页面结构: Hero 横幅(渐变背景 #f59e0b -> #ef4444 + 浮动粒子动画 + EditOutlined 图标 + 标题"图片编辑")+ 主内容区(max-width: 1200px, margin: -30px auto 0)
  • 三栏布局: 左侧工具栏(图标按钮)+ 中间画布区(Canvas + 上传/下载按钮)+ 右侧属性面板(滑块/输入框)
  • 返回按钮: 使用 ArrowLeftOutlined 图标,className 为 ie-back-btn,点击返回 /utility
  • 使用记录: 调用 useRecordHistory hook,参数包含 type: "tool", title: "图片编辑", description: "在线图片编辑,支持裁剪、滤镜、调节等功能", category: "设计", link: "/utility/image-editor"
  • 响应式: 移动端(max-width: 768px)时属性面板折叠到底部

Task 1: 创建图片编辑主页面

Files:

  • Create: src/pages/ImageEditor/ImageEditor.tsx
  • Create: src/pages/ImageEditor/ImageEditor.css

功能需求

1. 图片上传

  • 支持点击上传和拖拽上传
  • 支持 JPG、PNG、WebP 格式
  • 上传后图片渲染到 Canvas 上

2. 基础变换

  • 旋转: 左转 90° / 右转 90° / 旋转 180°
  • 翻转: 水平翻转 / 垂直翻转
  • 裁剪: 预设比例(1:1, 4:3, 16:9, 自由)+ 裁剪框拖拽调整

3. 色彩调节

  • 亮度: -100 到 +100 滑块
  • 对比度: -100 到 +100 滑块
  • 饱和度: -100 到 +100 滑块
  • 灰度: 0 到 100 滑块
  • 模糊: 0 到 20px 滑块

4. 滤镜预设

  • 原图、黑白、复古、冷色调、暖色调、高对比、柔光、锐化

5. 文字添加

  • 输入文字内容
  • 选择字体大小、颜色、位置(点击画布放置)

6. 涂鸦画笔

  • 画笔大小调节
  • 画笔颜色选择
  • 橡皮擦模式

7. 撤销/重做

  • 维护操作历史栈
  • 最多 20 步历史记录

8. 导出下载

  • 下载编辑后的图片(保持原格式或选择 PNG/JPG)
  • 支持调节导出质量

组件状态设计

interface EditorState {
  image: HTMLImageElement | null;
  originalImage: HTMLImageElement | null;
  canvas: HTMLCanvasElement | null;
  ctx: CanvasRenderingContext2D | null;
  brightness: number;
  contrast: number;
  saturation: number;
  grayscale: number;
  blur: number;
  rotation: number;
  flipH: boolean;
  flipV: boolean;
  filter: string;
  cropMode: boolean;
  cropRect: { x: number; y: number; width: number; height: number } | null;
  texts: TextItem[];
  brushMode: boolean;
  brushSize: number;
  brushColor: string;
  isDrawing: boolean;
  history: ImageData[];
  historyIndex: number;
}

interface TextItem {
  id: string;
  text: string;
  x: number;
  y: number;
  fontSize: number;
  color: string;
}
  • Step 1: 创建 ImageEditor.tsx 主组件
import "./ImageEditor.css";
import React, { useState, useRef, useCallback, useEffect } from "react";
import {
  Button,
  Slider,
  Input,
  ColorPicker,
  Radio,
  message,
  Tooltip,
  Select,
} from "antd";
import {
  EditOutlined,
  ArrowLeftOutlined,
  UploadOutlined,
  DownloadOutlined,
  RotateLeftOutlined,
  RotateRightOutlined,
  SwapOutlined,
  ColumnWidthOutlined,
  ColumnHeightOutlined,
  BgColorsOutlined,
  FontSizeOutlined,
  HighlightOutlined,
  UndoOutlined,
  RedoOutlined,
  ScissorOutlined,
  ReloadOutlined,
} from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import { useRecordHistory } from "@/hooks/useRecordHistory";

interface TextItem {
  id: string;
  text: string;
  x: number;
  y: number;
  fontSize: number;
  color: string;
}

const filterPresets: Record<string, string> = {
  none: "none",
  grayscale: "grayscale(100%)",
  sepia: "sepia(100%)",
  invert: "invert(100%)",
  brightness: "brightness(120%)",
  contrast: "contrast(150%)",
  blur: "blur(2px)",
  warm: "sepia(30%) saturate(140%) hue-rotate(-10deg)",
  cool: "saturate(120%) hue-rotate(10deg)",
  vintage: "sepia(50%) contrast(120%) brightness(90%)",
};

const ImageEditor: React.FC = () => {
  const navigate = useNavigate();
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const fileInputRef = useRef<HTMLInputElement>(null);

  useRecordHistory({
    type: "tool",
    title: "图片编辑",
    description: "在线图片编辑,支持裁剪、滤镜、调节等功能",
    category: "设计",
    link: "/utility/image-editor",
  });

  const [image, setImage] = useState<HTMLImageElement | null>(null);
  const [brightness, setBrightness] = useState(0);
  const [contrast, setContrast] = useState(0);
  const [saturation, setSaturation] = useState(0);
  const [grayscale, setGrayscale] = useState(0);
  const [blur, setBlur] = useState(0);
  const [rotation, setRotation] = useState(0);
  const [flipH, setFlipH] = useState(false);
  const [flipV, setFlipV] = useState(false);
  const [filterPreset, setFilterPreset] = useState("none");
  const [activeTool, setActiveTool] = useState<string>("adjust");
  const [texts, setTexts] = useState<TextItem[]>([]);
  const [textInput, setTextInput] = useState("");
  const [textSize, setTextSize] = useState(24);
  const [textColor, setTextColor] = useState("#ffffff");
  const [brushSize, setBrushSize] = useState(5);
  const [brushColor, setBrushColor] = useState("#ff0000");
  const [isDrawing, setIsDrawing] = useState(false);
  const [history, setHistory] = useState<ImageData[]>([]);
  const [historyIndex, setHistoryIndex] = useState(-1);
  const [exportFormat, setExportFormat] = useState("image/png");
  const [exportQuality, setExportQuality] = useState(90);

  const saveHistory = useCallback(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;
    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
    setHistory((prev) => {
      const newHistory = prev.slice(0, historyIndex + 1);
      newHistory.push(imageData);
      if (newHistory.length > 20) newHistory.shift();
      return newHistory;
    });
    setHistoryIndex((prev) => Math.min(prev + 1, 19));
  }, [historyIndex]);

  const undo = () => {
    if (historyIndex <= 0) return;
    const newIndex = historyIndex - 1;
    setHistoryIndex(newIndex);
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;
    ctx.putImageData(history[newIndex], 0, 0);
  };

  const redo = () => {
    if (historyIndex >= history.length - 1) return;
    const newIndex = historyIndex + 1;
    setHistoryIndex(newIndex);
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;
    ctx.putImageData(history[newIndex], 0, 0);
  };

  const applyCanvasTransform = useCallback(() => {
    const canvas = canvasRef.current;
    if (!canvas || !image) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const isRotated90 = rotation % 180 !== 0;
    const imgWidth = image.naturalWidth;
    const imgHeight = image.naturalHeight;

    canvas.width = isRotated90 ? imgHeight : imgWidth;
    canvas.height = isRotated90 ? imgWidth : imgHeight;

    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.save();

    ctx.translate(canvas.width / 2, canvas.height / 2);
    ctx.rotate((rotation * Math.PI) / 180);
    ctx.scale(flipH ? -1 : 1, flipV ? -1 : 1);

    const filterStr = [
      filterPreset !== "none" ? filterPresets[filterPreset] : "",
      `brightness(${100 + brightness}%)`,
      `contrast(${100 + contrast}%)`,
      `saturate(${100 + saturation}%)`,
      `grayscale(${grayscale}%)`,
      blur > 0 ? `blur(${blur}px)` : "",
    ]
      .filter(Boolean)
      .join(" ");

    ctx.filter = filterStr || "none";

    ctx.drawImage(
      image,
      -imgWidth / 2,
      -imgHeight / 2,
      imgWidth,
      imgHeight
    );

    ctx.restore();

    // Draw texts
    texts.forEach((t) => {
      ctx.font = `bold ${t.fontSize}px sans-serif`;
      ctx.fillStyle = t.color;
      ctx.strokeStyle = "rgba(0,0,0,0.5)";
      ctx.lineWidth = 2;
      ctx.strokeText(t.text, t.x, t.y);
      ctx.fillText(t.text, t.x, t.y);
    });
  }, [image, brightness, contrast, saturation, grayscale, blur, rotation, flipH, flipV, filterPreset, texts]);

  useEffect(() => {
    applyCanvasTransform();
  }, [applyCanvasTransform]);

  const handleFileUpload = (file: File) => {
    if (!file.type.startsWith("image/")) {
      message.error("请上传图片文件");
      return;
    }
    const img = new Image();
    img.onload = () => {
      setImage(img);
      setRotation(0);
      setFlipH(false);
      setFlipV(false);
      setBrightness(0);
      setContrast(0);
      setSaturation(0);
      setGrayscale(0);
      setBlur(0);
      setFilterPreset("none");
      setTexts([]);
      setHistory([]);
      setHistoryIndex(-1);

      const canvas = canvasRef.current;
      if (canvas) {
        canvas.width = img.naturalWidth;
        canvas.height = img.naturalHeight;
        const ctx = canvas.getContext("2d");
        if (ctx) {
          ctx.drawImage(img, 0, 0);
          const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
          setHistory([imageData]);
          setHistoryIndex(0);
        }
      }
    };
    img.src = URL.createObjectURL(file);
  };

  const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (file) handleFileUpload(file);
    e.target.value = "";
  };

  const handleDrop = (e: React.DragEvent) => {
    e.preventDefault();
    const file = e.dataTransfer.files[0];
    if (file) handleFileUpload(file);
  };

  const handleCanvasClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
    if (activeTool !== "text" || !textInput.trim()) return;
    const canvas = canvasRef.current;
    if (!canvas) return;
    const rect = canvas.getBoundingClientRect();
    const scaleX = canvas.width / rect.width;
    const scaleY = canvas.height / rect.height;
    const x = (e.clientX - rect.left) * scaleX;
    const y = (e.clientY - rect.top) * scaleY;

    const newText: TextItem = {
      id: Math.random().toString(36).substring(2, 9),
      text: textInput,
      x,
      y,
      fontSize: textSize,
      color: textColor,
    };
    setTexts((prev) => [...prev, newText]);
    saveHistory();
  };

  const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
    if (activeTool !== "brush") return;
    setIsDrawing(true);
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;
    const rect = canvas.getBoundingClientRect();
    const scaleX = canvas.width / rect.width;
    const scaleY = canvas.height / rect.height;
    const x = (e.clientX - rect.left) * scaleX;
    const y = (e.clientY - rect.top) * scaleY;
    ctx.beginPath();
    ctx.moveTo(x, y);
    ctx.strokeStyle = brushColor;
    ctx.lineWidth = brushSize;
    ctx.lineCap = "round";
    ctx.lineJoin = "round";
  };

  const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
    if (!isDrawing || activeTool !== "brush") return;
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;
    const rect = canvas.getBoundingClientRect();
    const scaleX = canvas.width / rect.width;
    const scaleY = canvas.height / rect.height;
    const x = (e.clientX - rect.left) * scaleX;
    const y = (e.clientY - rect.top) * scaleY;
    ctx.lineTo(x, y);
    ctx.stroke();
  };

  const handleMouseUp = () => {
    if (isDrawing) {
      setIsDrawing(false);
      saveHistory();
    }
  };

  const handleDownload = () => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const link = document.createElement("a");
    link.download = `edited_image.${exportFormat === "image/png" ? "png" : "jpg"}`;
    link.href = canvas.toDataURL(exportFormat, exportQuality / 100);
    link.click();
    message.success("图片已下载");
  };

  const resetAll = () => {
    setBrightness(0);
    setContrast(0);
    setSaturation(0);
    setGrayscale(0);
    setBlur(0);
    setRotation(0);
    setFlipH(false);
    setFlipV(false);
    setFilterPreset("none");
    setTexts([]);
  };

  const rotateLeft = () => setRotation((r) => r - 90);
  const rotateRight = () => setRotation((r) => r + 90);

  const toolButtons = [
    { key: "adjust", icon: <BgColorsOutlined />, label: "调节" },
    { key: "filter", icon: <HighlightOutlined />, label: "滤镜" },
    { key: "text", icon: <FontSizeOutlined />, label: "文字" },
    { key: "brush", icon: <HighlightOutlined />, label: "画笔" },
  ];

  return (
    <div className="image-editor-page">
      <div className="image-editor-hero">
        <div className="image-editor-hero-particles">
          {Array.from({ length: 6 }).map((_, i) => (
            <div key={i} className={`ie-particle ie-particle-${i + 1}`} />
          ))}
        </div>
        <div className="image-editor-hero-content">
          <div className="image-editor-hero-icon">
            <EditOutlined />
          </div>
          <h1 className="image-editor-hero-title">图片编辑</h1>
        </div>
      </div>

      <div className="image-editor-container">
        <div className="image-editor-toolbar">
          <Button
            icon={<ArrowLeftOutlined />}
            className="ie-back-btn"
            onClick={() => navigate("/utility")}
          >
            返回工具列表
          </Button>
          <div className="ie-toolbar-actions">
            <Tooltip title="撤销">
              <Button icon={<UndoOutlined />} onClick={undo} disabled={historyIndex <= 0} />
            </Tooltip>
            <Tooltip title="重做">
              <Button icon={<RedoOutlined />} onClick={redo} disabled={historyIndex >= history.length - 1} />
            </Tooltip>
            <Tooltip title="重置">
              <Button icon={<ReloadOutlined />} onClick={resetAll} />
            </Tooltip>
            <Button type="primary" icon={<DownloadOutlined />} onClick={handleDownload} disabled={!image}>
              下载
            </Button>
          </div>
        </div>

        <div className="ie-workspace">
          {/* 左侧工具栏 */}
          <div className="ie-tool-sidebar">
            {toolButtons.map((tool) => (
              <Tooltip key={tool.key} title={tool.label} placement="right">
                <button
                  className={`ie-tool-btn ${activeTool === tool.key ? "active" : ""}`}
                  onClick={() => setActiveTool(tool.key)}
                >
                  {tool.icon}
                  <span>{tool.label}</span>
                </button>
              </Tooltip>
            ))}
            <div className="ie-tool-divider" />
            <Tooltip title="左转" placement="right">
              <button className="ie-tool-btn" onClick={rotateLeft} disabled={!image}>
                <RotateLeftOutlined />
                <span>左转</span>
              </button>
            </Tooltip>
            <Tooltip title="右转" placement="right">
              <button className="ie-tool-btn" onClick={rotateRight} disabled={!image}>
                <RotateRightOutlined />
                <span>右转</span>
              </button>
            </Tooltip>
            <Tooltip title="水平翻转" placement="right">
              <button className="ie-tool-btn" onClick={() => setFlipH((v) => !v)} disabled={!image}>
                <ColumnWidthOutlined />
                <span>水平翻转</span>
              </button>
            </Tooltip>
            <Tooltip title="垂直翻转" placement="right">
              <button className="ie-tool-btn" onClick={() => setFlipV((v) => !v)} disabled={!image}>
                <ColumnHeightOutlined />
                <span>垂直翻转</span>
              </button>
            </Tooltip>
          </div>

          {/* 中间画布区 */}
          <div className="ie-canvas-area">
            {!image ? (
              <div
                className="ie-upload-area"
                onClick={() => fileInputRef.current?.click()}
                onDrop={handleDrop}
                onDragOver={(e) => e.preventDefault()}
              >
                <input
                  ref={fileInputRef}
                  type="file"
                  accept="image/*"
                  onChange={handleFileInput}
                  style={{ display: "none" }}
                />
                <UploadOutlined style={{ fontSize: 48, color: "#f59e0b" }} />
                <p className="ie-upload-text">点击或拖拽图片到此处</p>
                <p className="ie-upload-hint">支持 JPG、PNG、WebP</p>
              </div>
            ) : (
              <div className="ie-canvas-wrapper">
                <canvas
                  ref={canvasRef}
                  className={`ie-canvas ${activeTool === "text" ? "text-cursor" : ""} ${activeTool === "brush" ? "brush-cursor" : ""}`}
                  onClick={handleCanvasClick}
                  onMouseDown={handleMouseDown}
                  onMouseMove={handleMouseMove}
                  onMouseUp={handleMouseUp}
                  onMouseLeave={handleMouseUp}
                />
                <div className="ie-canvas-info">
                  {canvasRef.current?.width} × {canvasRef.current?.height} px
                </div>
              </div>
            )}
          </div>

          {/* 右侧属性面板 */}
          <div className="ie-property-panel">
            {activeTool === "adjust" && (
              <div className="ie-panel-section">
                <h4>色彩调节</h4>
                <div className="ie-slider-group">
                  <label>亮度 {brightness}</label>
                  <Slider min={-100} max={100} value={brightness} onChange={setBrightness} disabled={!image} />
                </div>
                <div className="ie-slider-group">
                  <label>对比度 {contrast}</label>
                  <Slider min={-100} max={100} value={contrast} onChange={setContrast} disabled={!image} />
                </div>
                <div className="ie-slider-group">
                  <label>饱和度 {saturation}</label>
                  <Slider min={-100} max={100} value={saturation} onChange={setSaturation} disabled={!image} />
                </div>
                <div className="ie-slider-group">
                  <label>灰度 {grayscale}</label>
                  <Slider min={0} max={100} value={grayscale} onChange={setGrayscale} disabled={!image} />
                </div>
                <div className="ie-slider-group">
                  <label>模糊 {blur}px</label>
                  <Slider min={0} max={20} value={blur} onChange={setBlur} disabled={!image} />
                </div>
              </div>
            )}

            {activeTool === "filter" && (
              <div className="ie-panel-section">
                <h4>滤镜预设</h4>
                <div className="ie-filter-grid">
                  {Object.entries(filterPresets).map(([key, filter]) => (
                    <button
                      key={key}
                      className={`ie-filter-item ${filterPreset === key ? "active" : ""}`}
                      onClick={() => setFilterPreset(key)}
                      disabled={!image}
                    >
                      <div
                        className="ie-filter-preview"
                        style={{ filter: filter === "none" ? "none" : filter }}
                      >
                        {image ? (
                          <img src={image.src} alt={key} />
                        ) : (
                          <div className="ie-filter-placeholder" />
                        )}
                      </div>
                      <span>
                        {key === "none" && "原图"}
                        {key === "grayscale" && "黑白"}
                        {key === "sepia" && "复古"}
                        {key === "invert" && "反色"}
                        {key === "brightness" && "明亮"}
                        {key === "contrast" && "高对比"}
                        {key === "blur" && "模糊"}
                        {key === "warm" && "暖色调"}
                        {key === "cool" && "冷色调"}
                        {key === "vintage" && "胶片"}
                      </span>
                    </button>
                  ))}
                </div>
              </div>
            )}

            {activeTool === "text" && (
              <div className="ie-panel-section">
                <h4>添加文字</h4>
                <Input.TextArea
                  placeholder="输入文字内容"
                  value={textInput}
                  onChange={(e) => setTextInput(e.target.value)}
                  rows={3}
                  disabled={!image}
                />
                <div className="ie-slider-group">
                  <label>字体大小 {textSize}px</label>
                  <Slider min={12} max={120} value={textSize} onChange={setTextSize} disabled={!image} />
                </div>
                <div className="ie-color-picker">
                  <label>文字颜色</label>
                  <ColorPicker value={textColor} onChange={(c) => setTextColor(c.toHexString())} disabled={!image} />
                </div>
                <p className="ie-hint">点击画布放置文字</p>
                {texts.length > 0 && (
                  <div className="ie-text-list">
                    {texts.map((t, idx) => (
                      <div key={t.id} className="ie-text-item">
                        <span>{t.text}</span>
                        <Button size="small" danger type="text" onClick={() => setTexts((prev) => prev.filter((_, i) => i !== idx))}>
                          删除
                        </Button>
                      </div>
                    ))}
                  </div>
                )}
              </div>
            )}

            {activeTool === "brush" && (
              <div className="ie-panel-section">
                <h4>画笔工具</h4>
                <div className="ie-slider-group">
                  <label>画笔大小 {brushSize}px</label>
                  <Slider min={1} max={50} value={brushSize} onChange={setBrushSize} disabled={!image} />
                </div>
                <div className="ie-color-picker">
                  <label>画笔颜色</label>
                  <ColorPicker value={brushColor} onChange={(c) => setBrushColor(c.toHexString())} disabled={!image} />
                </div>
                <p className="ie-hint">在画布上按住鼠标拖拽绘制</p>
              </div>
            )}

            <div className="ie-panel-section">
              <h4>导出设置</h4>
              <div className="ie-export-row">
                <label>格式</label>
                <Select
                  value={exportFormat}
                  onChange={setExportFormat}
                  options={[
                    { label: "PNG", value: "image/png" },
                    { label: "JPEG", value: "image/jpeg" },
                  ]}
                  style={{ width: 120 }}
                />
              </div>
              {exportFormat === "image/jpeg" && (
                <div className="ie-slider-group">
                  <label>质量 {exportQuality}%</label>
                  <Slider min={1} max={100} value={exportQuality} onChange={setExportQuality} />
                </div>
              )}
            </div>
          </div>
        </div>

        <div className="image-editor-tips">
          <div className="ie-tip-card">
            <div className="ie-tip-icon">🎨</div>
            <div className="ie-tip-content">
              <h4>丰富调节</h4>
              <p>支持亮度、对比度、饱和度、灰度、模糊等多种参数调节。</p>
            </div>
          </div>
          <div className="ie-tip-card">
            <div className="ie-tip-icon">✨</div>
            <div className="ie-tip-content">
              <h4>滤镜效果</h4>
              <p>内置黑白、复古、暖色调、冷色调、胶片等多种滤镜预设。</p>
            </div>
          </div>
          <div className="ie-tip-icon">🔒</div>
          <div className="ie-tip-content">
            <h4>本地处理</h4>
            <p>所有编辑操作在浏览器本地完成,图片不会上传到服务器。</p>
          </div>
        </div>
      </div>
    </div>
  );
};

export default ImageEditor;
  • Step 2: 创建 ImageEditor.css 样式
.image-editor-page {
  min-height: 100vh;
  background: #f0f2f5;
}

.image-editor-hero {
  min-height: 200px;
  background: linear-gradient(135deg, #f59e0b 0%, #ef4444 50%, #ec4899 100%);
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
  overflow: hidden;
  padding: 40px 20px;
}

.image-editor-hero::before {
  content: "";
  position: absolute;
  top: -50%;
  left: -50%;
  width: 200%;
  height: 200%;
  background: radial-gradient(circle at 30% 50%, rgba(255, 255, 255, 0.1) 0%, transparent 50%),
    radial-gradient(circle at 70% 80%, rgba(255, 255, 255, 0.08) 0%, transparent 40%);
  animation: ieHeroLight 8s ease-in-out infinite alternate;
}

@keyframes ieHeroLight {
  0% { transform: translate(0, 0); }
  100% { transform: translate(-20px, -10px); }
}

.image-editor-hero-particles {
  position: absolute;
  inset: 0;
  pointer-events: none;
  overflow: hidden;
}

.ie-particle {
  position: absolute;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.12);
  animation: ieFloat 6s ease-in-out infinite;
}

.ie-particle-1 { width: 50px; height: 50px; top: 15%; left: 8%; animation-delay: 0s; }
.ie-particle-2 { width: 35px; height: 35px; top: 65%; left: 18%; animation-delay: 1s; }
.ie-particle-3 { width: 65px; height: 65px; top: 25%; right: 12%; animation-delay: 2s; }
.ie-particle-4 { width: 25px; height: 25px; top: 70%; right: 25%; animation-delay: 0.5s; }
.ie-particle-5 { width: 40px; height: 40px; top: 45%; left: 45%; animation-delay: 1.5s; }
.ie-particle-6 { width: 30px; height: 30px; bottom: 20%; left: 30%; animation-delay: 3s; }

@keyframes ieFloat {
  0%, 100% { transform: translateY(0) scale(1); opacity: 0.3; }
  50% { transform: translateY(-18px) scale(1.1); opacity: 0.6; }
}

.image-editor-hero-content {
  text-align: center;
  position: relative;
  z-index: 1;
}

.image-editor-hero-icon {
  width: 64px;
  height: 64px;
  border-radius: 20px;
  background: rgba(255, 255, 255, 0.2);
  backdrop-filter: blur(10px);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: 28px;
  color: #fff;
  margin-bottom: 16px;
  border: 1px solid rgba(255, 255, 255, 0.3);
}

.image-editor-hero-title {
  font-size: 32px;
  font-weight: 800;
  color: #fff;
  margin: 0;
  letter-spacing: 2px;
}

.image-editor-container {
  max-width: 1200px;
  margin: -30px auto 0;
  padding: 0 20px 60px;
  position: relative;
  z-index: 2;
}

.image-editor-toolbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 20px;
  flex-wrap: wrap;
  gap: 12px;
}

.ie-back-btn {
  border-radius: 8px;
  font-weight: 500;
  color: #64748b;
  border-color: #e2e8f0;
}

.ie-back-btn:hover {
  color: #f59e0b;
  border-color: #f59e0b;
}

.ie-toolbar-actions {
  display: flex;
  gap: 8px;
}

.ie-workspace {
  display: grid;
  grid-template-columns: 64px 1fr 280px;
  gap: 16px;
  background: #fff;
  border-radius: 14px;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
  border: 1px solid #e8ecf1;
  overflow: hidden;
  min-height: 560px;
  margin-bottom: 24px;
}

/* 左侧工具栏 */
.ie-tool-sidebar {
  background: #f8fafc;
  border-right: 1px solid #e8ecf1;
  padding: 12px 8px;
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.ie-tool-btn {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  padding: 10px 4px;
  border: none;
  background: transparent;
  border-radius: 10px;
  cursor: pointer;
  color: #64748b;
  font-size: 11px;
  transition: all 0.2s ease;
}

.ie-tool-btn:hover {
  background: #e2e8f0;
  color: #334155;
}

.ie-tool-btn.active {
  background: #fef3c7;
  color: #d97706;
}

.ie-tool-btn:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

.ie-tool-btn span {
  font-size: 10px;
}

.ie-tool-divider {
  height: 1px;
  background: #e2e8f0;
  margin: 4px 0;
}

/* 中间画布区 */
.ie-canvas-area {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
  background: #f1f5f9;
  min-height: 500px;
}

.ie-upload-area {
  background: #fff;
  border: 2px dashed #fcd34d;
  border-radius: 14px;
  padding: 60px 40px;
  text-align: center;
  cursor: pointer;
  transition: all 0.3s ease;
}

.ie-upload-area:hover {
  border-color: #f59e0b;
  background: #fffbeb;
}

.ie-upload-text {
  font-size: 16px;
  font-weight: 600;
  color: #334155;
  margin: 12px 0 4px;
}

.ie-upload-hint {
  font-size: 13px;
  color: #94a3b8;
  margin: 0;
}

.ie-canvas-wrapper {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  width: 100%;
}

.ie-canvas {
  max-width: 100%;
  max-height: 520px;
  border-radius: 8px;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
  background: #fff;
  cursor: default;
}

.ie-canvas.text-cursor {
  cursor: text;
}

.ie-canvas.brush-cursor {
  cursor: crosshair;
}

.ie-canvas-info {
  font-size: 12px;
  color: #94a3b8;
}

/* 右侧属性面板 */
.ie-property-panel {
  background: #fff;
  border-left: 1px solid #e8ecf1;
  padding: 16px;
  overflow-y: auto;
  max-height: 560px;
}

.ie-panel-section {
  margin-bottom: 20px;
}

.ie-panel-section h4 {
  font-size: 14px;
  font-weight: 700;
  color: #1e293b;
  margin: 0 0 12px;
  padding-bottom: 8px;
  border-bottom: 1px solid #f1f5f9;
}

.ie-slider-group {
  margin-bottom: 12px;
}

.ie-slider-group label {
  display: block;
  font-size: 12px;
  font-weight: 600;
  color: #64748b;
  margin-bottom: 6px;
}

.ie-color-picker {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 12px;
}

.ie-color-picker label {
  font-size: 12px;
  font-weight: 600;
  color: #64748b;
}

.ie-hint {
  font-size: 12px;
  color: #94a3b8;
  margin: 8px 0;
  padding: 8px;
  background: #f8fafc;
  border-radius: 6px;
}

.ie-filter-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 8px;
}

.ie-filter-item {
  border: 2px solid transparent;
  border-radius: 10px;
  padding: 6px;
  background: #f8fafc;
  cursor: pointer;
  transition: all 0.2s ease;
}

.ie-filter-item:hover {
  border-color: #fcd34d;
}

.ie-filter-item.active {
  border-color: #f59e0b;
  background: #fffbeb;
}

.ie-filter-item:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

.ie-filter-preview {
  width: 100%;
  aspect-ratio: 1;
  border-radius: 6px;
  overflow: hidden;
  margin-bottom: 4px;
}

.ie-filter-preview img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.ie-filter-placeholder {
  width: 100%;
  height: 100%;
  background: #e2e8f0;
}

.ie-filter-item span {
  font-size: 11px;
  color: #64748b;
  display: block;
  text-align: center;
}

.ie-text-list {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin-top: 12px;
}

.ie-text-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 8px 10px;
  background: #f8fafc;
  border-radius: 6px;
  font-size: 13px;
}

.ie-text-item span {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  max-width: 140px;
}

.ie-export-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 12px;
}

.ie-export-row label {
  font-size: 12px;
  font-weight: 600;
  color: #64748b;
}

/* 底部提示 */
.image-editor-tips {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 16px;
}

.ie-tip-card {
  background: white;
  border-radius: 14px;
  padding: 20px;
  border: 1px solid #e8ecf1;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
  display: flex;
  gap: 14px;
  transition: all 0.3s ease;
}

.ie-tip-card:hover {
  transform: translateY(-3px);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
}

.ie-tip-icon {
  font-size: 28px;
  flex-shrink: 0;
  line-height: 1;
}

.ie-tip-content h4 {
  font-size: 14px;
  font-weight: 700;
  color: #1e293b;
  margin: 0 0 6px;
}

.ie-tip-content p {
  font-size: 13px;
  color: #64748b;
  margin: 0;
  line-height: 1.6;
}

@media (max-width: 768px) {
  .image-editor-hero {
    min-height: 160px;
    padding: 30px 16px;
  }

  .image-editor-hero-title {
    font-size: 24px;
  }

  .image-editor-container {
    padding: 0 12px 40px;
    margin-top: -20px;
  }

  .ie-workspace {
    grid-template-columns: 1fr;
    grid-template-rows: auto 1fr auto;
  }

  .ie-tool-sidebar {
    flex-direction: row;
    flex-wrap: wrap;
    justify-content: center;
    border-right: none;
    border-bottom: 1px solid #e8ecf1;
    padding: 8px;
  }

  .ie-tool-divider {
    display: none;
  }

  .ie-property-panel {
    border-left: none;
    border-top: 1px solid #e8ecf1;
    max-height: 300px;
  }

  .image-editor-tips {
    grid-template-columns: 1fr;
    gap: 10px;
  }
}

Task 2: 在 Utility.tsx 中注册图片编辑工具

Files:

  • Modify: src/pages/Utility/Utility.tsx

  • Step 3: 在 utilityTools 数组中添加图片编辑工具条目

在 utilityTools 数组中(id: 11 图片压缩之后)添加:

  {
    id: 15,
    name: "图片编辑",
    desc: "在线图片编辑,支持裁剪、滤镜、调节、文字、画笔",
    icon: <EditOutlined />,
    color: "#f59e0b",
    category: "设计",
    usageCount: 3456,
  },

注意:需要在文件顶部导入 EditOutlined:

import {
  ToolOutlined,
  SearchOutlined,
  CalculatorOutlined,
  CalendarOutlined,
  QrcodeOutlined,
  LinkOutlined,
  ClockCircleOutlined,
  TranslationOutlined,
  CompressOutlined,
  FileTextOutlined,
  CodeOutlined,
  PictureOutlined,
  ThunderboltOutlined,
  EditOutlined, // 新增
} from "@ant-design/icons";
  • Step 4: 在 routeMap 中添加图片编辑路由映射

在 handleToolClick 的 routeMap 中添加:

      "图片编辑": "/utility/image-editor",

Task 3: 在 App.tsx 中添加路由配置

Files:

  • Modify: src/App.tsx

  • Step 5: 添加 lazy import

在 App.tsx 的 import 区域添加:

const ImageEditor = lazy(() => import("@/pages/ImageEditor/ImageEditor"));
  • Step 6: 在 Routes 中添加新路由

在 <Routes> 中添加:

<Route path="/utility/image-editor" element={<ImageEditor />} />

Self-Review

1. Spec coverage

需求 对应 Task
图片上传(点击/拖拽) Task 1 Step 1
旋转/翻转 Task 1 Step 1
亮度/对比度/饱和度/灰度/模糊调节 Task 1 Step 1
滤镜预设(10种) Task 1 Step 1
文字添加 Task 1 Step 1
涂鸦画笔 Task 1 Step 1
撤销/重做 Task 1 Step 1
导出下载 Task 1 Step 1
工具列表注册 Task 2
路由配置 Task 3

无遗漏。

2. Placeholder scan

  • 无 "TBD"、"TODO"、"implement later"
  • 所有步骤包含完整代码
  • 无 "Similar to Task N" 引用
  • 所有文件路径精确

3. Type consistency

  • TextItem 接口在 Task 1 中定义并一致使用
  • filterPresets 对象类型一致
  • useRecordHistory 参数格式与现有代码一致
  • 状态变量命名一致

执行选项

计划已保存到 docs/plans/2026-06-13-image-editor.md。两个执行选项:

1. Subagent-Driven(推荐) - 每个 Task 分配一个独立子代理执行,逐个审查

2. Inline Execution - 在当前会话中按顺序批量执行

请选择执行方式?