36 KiB
图片编辑工具 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 - 使用记录: 调用
useRecordHistoryhook,参数包含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 - 在当前会话中按顺序批量执行
请选择执行方式?