1356 lines
36 KiB
Markdown
1356 lines
36 KiB
Markdown
# 图片编辑工具 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)
|
||
- 支持调节导出质量
|
||
|
||
### 组件状态设计
|
||
|
||
```ts
|
||
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 主组件**
|
||
|
||
```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 样式**
|
||
|
||
```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 图片压缩之后)添加:
|
||
|
||
```tsx
|
||
{
|
||
id: 15,
|
||
name: "图片编辑",
|
||
desc: "在线图片编辑,支持裁剪、滤镜、调节、文字、画笔",
|
||
icon: <EditOutlined />,
|
||
color: "#f59e0b",
|
||
category: "设计",
|
||
usageCount: 3456,
|
||
},
|
||
```
|
||
|
||
注意:需要在文件顶部导入 `EditOutlined`:
|
||
|
||
```tsx
|
||
import {
|
||
ToolOutlined,
|
||
SearchOutlined,
|
||
CalculatorOutlined,
|
||
CalendarOutlined,
|
||
QrcodeOutlined,
|
||
LinkOutlined,
|
||
ClockCircleOutlined,
|
||
TranslationOutlined,
|
||
CompressOutlined,
|
||
FileTextOutlined,
|
||
CodeOutlined,
|
||
PictureOutlined,
|
||
ThunderboltOutlined,
|
||
EditOutlined, // 新增
|
||
} from "@ant-design/icons";
|
||
```
|
||
|
||
- [ ] **Step 4: 在 routeMap 中添加图片编辑路由映射**
|
||
|
||
在 `handleToolClick` 的 `routeMap` 中添加:
|
||
|
||
```tsx
|
||
"图片编辑": "/utility/image-editor",
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: 在 App.tsx 中添加路由配置
|
||
|
||
**Files:**
|
||
- Modify: `src/App.tsx`
|
||
|
||
- [ ] **Step 5: 添加 lazy import**
|
||
|
||
在 `App.tsx` 的 import 区域添加:
|
||
|
||
```tsx
|
||
const ImageEditor = lazy(() => import("@/pages/ImageEditor/ImageEditor"));
|
||
```
|
||
|
||
- [ ] **Step 6: 在 Routes 中添加新路由**
|
||
|
||
在 `<Routes>` 中添加:
|
||
|
||
```tsx
|
||
<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** - 在当前会话中按顺序批量执行
|
||
|
||
**请选择执行方式?**
|