Files
chunyu_prject_react/docs/plans/2026-06-13-calculator-datecalc-imagecompress.md
T
2026-08-05 23:59:22 +08:00

2467 lines
64 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 计算器、日期计算器、图片压缩工具实现计划
> **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:** 为小雨开发工具箱的实用工具中心补全 3 个缺失的工具页面:科学计算器、日期计算器、图片压缩工具,并在路由和工具列表中完成注册。
**Architecture:** 每个工具遵循项目已有的页面结构模式:Hero 横幅区 + 工具栏(返回按钮)+ 主内容面板 + 底部提示卡片。使用 React + TypeScript + Ant Design,纯前端实现(图片压缩使用 Canvas API)。同时更新 `Utility.tsx` 的 `routeMap` 和 `App.tsx` 的路由配置。
**Tech Stack:** React 18, TypeScript, Vite, Ant Design, React Router v6, dayjs
---
## 文件结构
| 文件 | 操作 | 说明 |
|------|------|------|
| `src/pages/Calculator/Calculator.tsx` | 创建 | 科学计算器页面组件 |
| `src/pages/Calculator/Calculator.css` | 创建 | 科学计算器样式 |
| `src/pages/DateCalculator/DateCalculator.tsx` | 创建 | 日期计算器页面组件 |
| `src/pages/DateCalculator/DateCalculator.css` | 创建 | 日期计算器样式 |
| `src/pages/ImageCompressor/ImageCompressor.tsx` | 创建 | 图片压缩工具页面组件 |
| `src/pages/ImageCompressor/ImageCompressor.css` | 创建 | 图片压缩工具样式 |
| `src/pages/Utility/Utility.tsx` | 修改 | 在 `routeMap` 中注册 3 个新工具路由 |
| `src/App.tsx` | 修改 | 添加 3 个新工具的路由配置 |
---
## 设计规范(所有新页面遵循)
- **页面结构:** Hero 横幅(渐变背景 + 浮动粒子动画 + 图标 + 标题)+ 主内容区(max-width: 1100px, margin: -30px auto 0)
- **Hero 渐变:** 计算器用 `#06b6d4 -> #3b82f6`,日期计算器用 `#3b82f6 -> #8b5cf6`,图片压缩用 `#6366f1 -> #ec4899`
- **返回按钮:** 使用 `ArrowLeftOutlined` 图标,className 为 `{prefix}-back-btn`,点击返回 `/utility`
- **使用记录:** 每个页面调用 `useRecordHistory` hook,参数包含 `type: "tool"`, `title`, `description`, `category`, `link`
- **响应式:** 移动端(max-width: 768px)时面板单列排列
---
## Task 1: 创建科学计算器页面
**Files:**
- Create: `src/pages/Calculator/Calculator.tsx`
- Create: `src/pages/Calculator/Calculator.css`
### 功能需求
- 支持基本运算:加、减、乘、除
- 支持科学运算:平方、开方、倒数、百分比、正负切换
- 支持退格(Backspace)、清空(AC/C)、等号计算
- 显示当前输入和计算历史
- 键盘支持(数字键、运算符键、Enter、Escape、Backspace)
### 组件设计
- 顶部:Hero 横幅(`CalculatorOutlined` 图标,标题"科学计算器")
- 主面板:计算器界面
- 显示屏区域:上方显示历史表达式,下方显示当前输入/结果
- 按键区域:4x5 或 5x4 网格布局
- 第一行:AC, +/-, %, ÷
- 第二行:7, 8, 9, ×
- 第三行:4, 5, 6, -
- 第四行:1, 2, 3, +
- 第五行:0, ., =, 以及科学功能键(x², √, 1/x)
- 等号按钮使用主色渐变背景
- [ ] **Step 1: 创建 Calculator.tsx 组件**
```tsx
import "./Calculator.css";
import React, { useState, useEffect, useCallback } from "react";
import { Button, message } from "antd";
import {
CalculatorOutlined,
ArrowLeftOutlined,
DeleteOutlined,
} from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import { useRecordHistory } from "@/hooks/useRecordHistory";
type Operator = "+" | "-" | "*" | "/" | null;
interface CalculatorState {
current: string;
previous: string;
operator: Operator;
overwrite: boolean;
history: string;
}
const Calculator: React.FC = () => {
const navigate = useNavigate();
useRecordHistory({
type: "tool",
title: "科学计算器",
description: "支持基本运算与科学计算功能",
category: "实用",
link: "/utility/calculator",
});
const [state, setState] = useState<CalculatorState>({
current: "0",
previous: "",
operator: null,
overwrite: false,
history: "",
});
const clear = () => {
setState({
current: "0",
previous: "",
operator: null,
overwrite: false,
history: "",
});
};
const deleteLast = () => {
setState((prev) => ({
...prev,
current: prev.current.length === 1 ? "0" : prev.current.slice(0, -1),
}));
};
const appendNumber = (num: string) => {
setState((prev) => {
if (prev.overwrite) {
return { ...prev, current: num, overwrite: false };
}
if (prev.current === "0" && num !== ".") {
return { ...prev, current: num };
}
if (num === "." && prev.current.includes(".")) {
return prev;
}
if (prev.current.length >= 15) return prev;
return { ...prev, current: prev.current + num };
});
};
const chooseOperator = (op: Operator) => {
setState((prev) => {
if (prev.previous === "") {
return {
...prev,
previous: prev.current,
operator: op,
overwrite: true,
history: `${prev.current} ${op}`,
};
}
const computed = compute(prev.previous, prev.current, prev.operator);
return {
...prev,
previous: computed,
current: computed,
operator: op,
overwrite: true,
history: `${computed} ${op}`,
};
});
};
const compute = (a: string, b: string, op: Operator): string => {
const numA = parseFloat(a);
const numB = parseFloat(b);
if (isNaN(numA) || isNaN(numB)) return "0";
let result = 0;
switch (op) {
case "+":
result = numA + numB;
break;
case "-":
result = numA - numB;
break;
case "*":
result = numA * numB;
break;
case "/":
result = numB === 0 ? NaN : numA / numB;
break;
default:
return b;
}
if (isNaN(result)) return "Error";
if (!isFinite(result)) return "Error";
const str = result.toString();
if (str.length > 15) {
return result.toExponential(8);
}
return str;
};
const calculate = () => {
setState((prev) => {
if (!prev.operator || prev.previous === "") return prev;
const result = compute(prev.previous, prev.current, prev.operator);
return {
...prev,
current: result,
previous: "",
operator: null,
overwrite: true,
history: `${prev.history} ${prev.current} =`,
};
});
};
const toggleSign = () => {
setState((prev) => ({
...prev,
current: prev.current.startsWith("-")
? prev.current.slice(1)
: "-" + prev.current,
}));
};
const percentage = () => {
setState((prev) => ({
...prev,
current: String(parseFloat(prev.current) / 100),
}));
};
const square = () => {
setState((prev) => {
const num = parseFloat(prev.current);
const result = num * num;
return {
...prev,
current: result > 1e15 ? result.toExponential(8) : String(result),
overwrite: true,
};
});
};
const sqrt = () => {
setState((prev) => {
const num = parseFloat(prev.current);
if (num < 0) {
message.error("不能对负数开平方");
return prev;
}
const result = Math.sqrt(num);
return {
...prev,
current: String(result),
overwrite: true,
};
});
};
const reciprocal = () => {
setState((prev) => {
const num = parseFloat(prev.current);
if (num === 0) {
message.error("不能除以零");
return prev;
}
const result = 1 / num;
return {
...prev,
current: result > 1e15 ? result.toExponential(8) : String(result),
overwrite: true,
};
});
};
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key >= "0" && e.key <= "9") appendNumber(e.key);
if (e.key === ".") appendNumber(".");
if (e.key === "+") chooseOperator("+");
if (e.key === "-") chooseOperator("-");
if (e.key === "*") chooseOperator("*");
if (e.key === "/") chooseOperator("/");
if (e.key === "Enter" || e.key === "=") calculate();
if (e.key === "Escape") clear();
if (e.key === "Backspace") deleteLast();
},
[]
);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
const buttons = [
{ label: "AC", type: "function", onClick: clear, className: "calc-ac" },
{ label: "±", type: "function", onClick: toggleSign },
{ label: "%", type: "function", onClick: percentage },
{ label: "÷", type: "operator", onClick: () => chooseOperator("/") },
{ label: "7", type: "number", onClick: () => appendNumber("7") },
{ label: "8", type: "number", onClick: () => appendNumber("8") },
{ label: "9", type: "number", onClick: () => appendNumber("9") },
{ label: "×", type: "operator", onClick: () => chooseOperator("*") },
{ label: "4", type: "number", onClick: () => appendNumber("4") },
{ label: "5", type: "number", onClick: () => appendNumber("5") },
{ label: "6", type: "number", onClick: () => appendNumber("6") },
{ label: "-", type: "operator", onClick: () => chooseOperator("-") },
{ label: "1", type: "number", onClick: () => appendNumber("1") },
{ label: "2", type: "number", onClick: () => appendNumber("2") },
{ label: "3", type: "number", onClick: () => appendNumber("3") },
{ label: "+", type: "operator", onClick: () => chooseOperator("+") },
{ label: "x²", type: "function", onClick: square },
{ label: "0", type: "number", onClick: () => appendNumber("0") },
{ label: ".", type: "number", onClick: () => appendNumber(".") },
{ label: "=", type: "equals", onClick: calculate },
];
return (
<div className="calculator-page">
<div className="calculator-hero">
<div className="calculator-hero-particles">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className={`calc-particle calc-particle-${i + 1}`} />
))}
</div>
<div className="calculator-hero-content">
<div className="calculator-hero-icon">
<CalculatorOutlined />
</div>
<h1 className="calculator-hero-title">科学计算器</h1>
</div>
</div>
<div className="calculator-container">
<div className="calculator-toolbar">
<Button
icon={<ArrowLeftOutlined />}
className="calc-back-btn"
onClick={() => navigate("/utility")}
>
返回工具列表
</Button>
</div>
<div className="calculator-main">
<div className="calc-display">
<div className="calc-history">{state.history}</div>
<div className="calc-current">{state.current}</div>
</div>
<div className="calc-keypad">
{buttons.map((btn, idx) => (
<button
key={idx}
className={`calc-btn calc-btn-${btn.type} ${btn.className || ""}`}
onClick={btn.onClick}
>
{btn.label}
</button>
))}
</div>
</div>
<div className="calculator-tips">
<div className="calc-tip-card">
<div className="calc-tip-icon">⌨️</div>
<div className="calc-tip-content">
<h4>键盘支持</h4>
<p>支持使用键盘输入数字和运算符,Enter 计算,Esc 清空,Backspace 退格。</p>
</div>
</div>
<div className="calc-tip-card">
<div className="calc-tip-icon">🔢</div>
<div className="calc-tip-content">
<h4>科学功能</h4>
<p>支持平方、开方、倒数、百分比等常用科学计算功能。</p>
</div>
</div>
<div className="calc-tip-card">
<div className="calc-tip-icon">🔒</div>
<div className="calc-tip-content">
<h4>数据安全</h4>
<p>所有计算在本地浏览器完成,数据不会上传到服务器。</p>
</div>
</div>
</div>
</div>
</div>
);
};
export default Calculator;
```
- [ ] **Step 2: 创建 Calculator.css 样式**
```css
.calculator-page {
min-height: 100vh;
background: #f0f2f5;
}
.calculator-hero {
min-height: 200px;
background: linear-gradient(135deg, #06b6d4 0%, #3b82f6 50%, #6366f1 100%);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
padding: 40px 20px;
}
.calculator-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: calcHeroLight 8s ease-in-out infinite alternate;
}
@keyframes calcHeroLight {
0% { transform: translate(0, 0); }
100% { transform: translate(-20px, -10px); }
}
.calculator-hero-particles {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
}
.calc-particle {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
animation: calcFloat 6s ease-in-out infinite;
}
.calc-particle-1 { width: 50px; height: 50px; top: 15%; left: 8%; animation-delay: 0s; }
.calc-particle-2 { width: 35px; height: 35px; top: 65%; left: 18%; animation-delay: 1s; }
.calc-particle-3 { width: 65px; height: 65px; top: 25%; right: 12%; animation-delay: 2s; }
.calc-particle-4 { width: 25px; height: 25px; top: 70%; right: 25%; animation-delay: 0.5s; }
.calc-particle-5 { width: 40px; height: 40px; top: 45%; left: 45%; animation-delay: 1.5s; }
.calc-particle-6 { width: 30px; height: 30px; bottom: 20%; left: 30%; animation-delay: 3s; }
@keyframes calcFloat {
0%, 100% { transform: translateY(0) scale(1); opacity: 0.3; }
50% { transform: translateY(-18px) scale(1.1); opacity: 0.6; }
}
.calculator-hero-content {
text-align: center;
position: relative;
z-index: 1;
}
.calculator-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);
}
.calculator-hero-title {
font-size: 32px;
font-weight: 800;
color: #fff;
margin: 0;
letter-spacing: 2px;
}
.calculator-container {
max-width: 500px;
margin: -30px auto 0;
padding: 0 20px 60px;
position: relative;
z-index: 2;
}
.calculator-toolbar {
display: flex;
align-items: center;
margin-bottom: 20px;
}
.calc-back-btn {
border-radius: 8px;
font-weight: 500;
color: #64748b;
border-color: #e2e8f0;
}
.calc-back-btn:hover {
color: #06b6d4;
border-color: #06b6d4;
}
.calculator-main {
background: #fff;
border-radius: 20px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
overflow: hidden;
margin-bottom: 24px;
}
.calc-display {
background: #1e1e2e;
padding: 20px 24px;
text-align: right;
}
.calc-history {
font-size: 14px;
color: #6c7086;
min-height: 20px;
margin-bottom: 4px;
word-break: break-all;
}
.calc-current {
font-size: 42px;
font-weight: 300;
color: #fff;
word-break: break-all;
line-height: 1.2;
}
.calc-keypad {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1px;
background: #e8ecf1;
}
.calc-btn {
border: none;
background: #fff;
padding: 20px;
font-size: 20px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
color: #1e293b;
}
.calc-btn:hover {
background: #f1f5f9;
}
.calc-btn:active {
background: #e2e8f0;
transform: scale(0.96);
}
.calc-btn-function {
background: #f8fafc;
color: #06b6d4;
font-size: 18px;
}
.calc-btn-function:hover {
background: #ecfeff;
}
.calc-btn-operator {
background: #f0f9ff;
color: #3b82f6;
font-size: 22px;
}
.calc-btn-operator:hover {
background: #dbeafe;
}
.calc-btn-equals {
background: linear-gradient(135deg, #06b6d4, #3b82f6);
color: #fff;
font-size: 24px;
}
.calc-btn-equals:hover {
background: linear-gradient(135deg, #0891b2, #2563eb);
}
.calc-ac {
color: #ef4444;
}
.calculator-tips {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.calc-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;
}
.calc-tip-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
}
.calc-tip-icon {
font-size: 28px;
flex-shrink: 0;
line-height: 1;
}
.calc-tip-content h4 {
font-size: 14px;
font-weight: 700;
color: #1e293b;
margin: 0 0 6px;
}
.calc-tip-content p {
font-size: 13px;
color: #64748b;
margin: 0;
line-height: 1.6;
}
@media (max-width: 768px) {
.calculator-hero {
min-height: 160px;
padding: 30px 16px;
}
.calculator-hero-title {
font-size: 24px;
}
.calculator-container {
padding: 0 12px 40px;
margin-top: -20px;
}
.calc-current {
font-size: 32px;
}
.calc-btn {
padding: 16px;
font-size: 18px;
}
.calculator-tips {
grid-template-columns: 1fr;
gap: 10px;
}
}
```
---
## Task 2: 创建日期计算器页面
**Files:**
- Create: `src/pages/DateCalculator/DateCalculator.tsx`
- Create: `src/pages/DateCalculator/DateCalculator.css`
### 功能需求
- **日期间隔计算**:选择两个日期,计算它们之间的天数、工作日、周数、月数、年数
- **日期推算**:选择一个起始日期,输入天数,推算目标日期(支持加减)
- **显示结果**:展示详细的间隔信息(总天数、工作日、自然周、自然月、自然年)
- **快捷操作**:今天、明天、一周后、一个月后、一年后等快捷按钮
### 组件设计
- 顶部:Hero 横幅(`CalendarOutlined` 图标,标题"日期计算器")
- 主内容区:两个 Tab 面板
- Tab 1 "日期间隔":两个 DatePicker + 计算结果列表
- Tab 2 "日期推算":一个 DatePicker + 数字输入(天数)+ 加减选择 + 计算结果
- 底部:3 个提示卡片
- [ ] **Step 3: 创建 DateCalculator.tsx 组件**
```tsx
import "./DateCalculator.css";
import React, { useState, useMemo } from "react";
import { Button, DatePicker, InputNumber, Radio, Tabs, message } from "antd";
import {
CalendarOutlined,
ArrowLeftOutlined,
CopyOutlined,
CheckOutlined,
} from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import { useRecordHistory } from "@/hooks/useRecordHistory";
import dayjs from "dayjs";
import "dayjs/locale/zh-cn";
dayjs.locale("zh-cn");
interface DateResult {
label: string;
value: string;
field: string;
}
const DateCalculator: React.FC = () => {
const navigate = useNavigate();
useRecordHistory({
type: "tool",
title: "日期计算器",
description: "日期间隔计算与日期推算",
category: "实用",
link: "/utility/date-calculator",
});
const [activeTab, setActiveTab] = useState<string>("interval");
const [startDate, setStartDate] = useState<dayjs.Dayjs | null>(null);
const [endDate, setEndDate] = useState<dayjs.Dayjs | null>(null);
const [baseDate, setBaseDate] = useState<dayjs.Dayjs | null>(null);
const [dayCount, setDayCount] = useState<number>(7);
const [dayDirection, setDayDirection] = useState<"add" | "sub">("add");
const [copiedField, setCopiedField] = useState<string | null>(null);
const intervalResults = useMemo((): DateResult[] => {
if (!startDate || !endDate) return [];
const start = startDate.startOf("day");
const end = endDate.startOf("day");
const diffDays = end.diff(start, "day");
const absDays = Math.abs(diffDays);
const isForward = diffDays >= 0;
const earlier = isForward ? start : end;
const later = isForward ? end : start;
// 工作日计算
let workDays = 0;
let current = earlier.clone();
while (current.isBefore(later) || current.isSame(later)) {
const day = current.day();
if (day !== 0 && day !== 6) workDays++;
current = current.add(1, "day");
}
if (!isForward) {
// 如果反向,需要重新计算
workDays = 0;
current = later.clone();
while (current.isBefore(earlier) || current.isSame(earlier)) {
const day = current.day();
if (day !== 0 && day !== 6) workDays++;
current = current.add(1, "day");
}
}
const weeks = Math.floor(absDays / 7);
const months = Math.abs(end.diff(start, "month", true));
const years = Math.abs(end.diff(start, "year", true));
const directionStr = isForward ? "之后" : "之前";
return [
{
label: "总天数",
value: `${absDays} 天${startDate.isSame(endDate, "day") ? "(同一天)" : ""}`,
field: "total-days",
},
{
label: "工作日",
value: `${workDays} 天(不含周末)`,
field: "work-days",
},
{
label: "周数",
value: `${weeks} 周 ${absDays % 7} 天`,
field: "weeks",
},
{
label: "月数",
value: `${Math.floor(months)} 个月 ${Math.round((months % 1) * 30)} 天(约)`,
field: "months",
},
{
label: "年数",
value: `${years.toFixed(2)} 年(约)`,
field: "years",
},
{
label: "起始日期",
value: startDate.format("YYYY年MM月DD日 dddd"),
field: "start",
},
{
label: "结束日期",
value: endDate.format("YYYY年MM月DD日 dddd"),
field: "end",
},
];
}, [startDate, endDate]);
const推算Results = useMemo((): DateResult[] => {
if (!baseDate) return [];
const result = dayDirection === "add"
? baseDate.add(dayCount, "day")
: baseDate.subtract(dayCount, "day");
const diffDays = Math.abs(result.diff(baseDate, "day"));
return [
{
label: "目标日期",
value: result.format("YYYY年MM月DD日 dddd"),
field: "target-date",
},
{
label: "目标日期(短格式)",
value: result.format("YYYY-MM-DD"),
field: "target-short",
},
{
label: "间隔天数",
value: `${diffDays} 天`,
field: "diff-days",
},
{
label: "目标日期时间戳",
value: String(result.valueOf()),
field: "target-timestamp",
},
{
label: "目标日期 ISO",
value: result.toISOString(),
field: "target-iso",
},
];
}, [baseDate, dayCount, dayDirection]);
const handleCopy = (text: string, field: string) => {
navigator.clipboard.writeText(text).then(() => {
setCopiedField(field);
message.success("已复制到剪贴板");
setTimeout(() => setCopiedField(null), 1500);
});
};
const setQuickDate = (type: string) => {
const now = dayjs();
switch (type) {
case "today":
setStartDate(now);
setEndDate(now);
break;
case "tomorrow":
setStartDate(now);
setEndDate(now.add(1, "day"));
break;
case "week":
setStartDate(now);
setEndDate(now.add(7, "day"));
break;
case "month":
setStartDate(now);
setEndDate(now.add(1, "month"));
break;
case "year":
setStartDate(now);
setEndDate(now.add(1, "year"));
break;
}
};
const setQuickBase = (type: string) => {
const now = dayjs();
setBaseDate(now);
switch (type) {
case "week":
setDayCount(7);
break;
case "month":
setDayCount(30);
break;
case "year":
setDayCount(365);
break;
case "100":
setDayCount(100);
break;
}
};
const renderResults = (results: DateResult[]) => (
<div className="dc-results">
{results.map((item) => (
<div key={item.field} className="dc-result-item">
<span className="dc-result-label">{item.label}</span>
<span className="dc-result-value">{item.value}</span>
<button
className={`dc-result-copy ${copiedField === item.field ? "copied" : ""}`}
onClick={() => handleCopy(item.value, item.field)}
>
{copiedField === item.field ? <CheckOutlined /> : <CopyOutlined />}
</button>
</div>
))}
</div>
);
const tabItems = [
{
key: "interval",
label: "日期间隔",
children: (
<div className="dc-tab-content">
<div className="dc-quick-btns">
<Button size="small" onClick={() => setQuickDate("today")}>今天</Button>
<Button size="small" onClick={() => setQuickDate("tomorrow")}>明天</Button>
<Button size="small" onClick={() => setQuickDate("week")}>一周后</Button>
<Button size="small" onClick={() => setQuickDate("month")}>一月后</Button>
<Button size="small" onClick={() => setQuickDate("year")}>一年后</Button>
</div>
<div className="dc-date-pickers">
<div className="dc-date-field">
<label>起始日期</label>
<DatePicker
value={startDate}
onChange={setStartDate}
placeholder="选择起始日期"
format="YYYY-MM-DD"
style={{ width: "100%" }}
size="large"
/>
</div>
<div className="dc-date-arrow">→</div>
<div className="dc-date-field">
<label>结束日期</label>
<DatePicker
value={endDate}
onChange={setEndDate}
placeholder="选择结束日期"
format="YYYY-MM-DD"
style={{ width: "100%" }}
size="large"
/>
</div>
</div>
{startDate && endDate ? (
renderResults(intervalResults)
) : (
<div className="dc-empty-hint">
<CalendarOutlined style={{ fontSize: 36, color: "#cbd5e1" }} />
<div>选择两个日期后自动计算间隔</div>
</div>
)}
</div>
),
},
{
key: "推算",
label: "日期推算",
children: (
<div className="dc-tab-content">
<div className="dc-quick-btns">
<Button size="small" onClick={() => setQuickBase("week")}>+7天</Button>
<Button size="small" onClick={() => setQuickBase("month")}>+30天</Button>
<Button size="small" onClick={() => setQuickBase("year")}>+365天</Button>
<Button size="small" onClick={() => setQuickBase("100")}>+100天</Button>
</div>
<div className="dc-calc-row">
<div className="dc-calc-field">
<label>起始日期</label>
<DatePicker
value={baseDate}
onChange={setBaseDate}
placeholder="选择起始日期"
format="YYYY-MM-DD"
style={{ width: "100%" }}
size="large"
/>
</div>
<div className="dc-calc-field">
<label>运算</label>
<Radio.Group
value={dayDirection}
onChange={(e) => setDayDirection(e.target.value)}
buttonStyle="solid"
>
<Radio.Button value="add">+</Radio.Button>
<Radio.Button value="sub">-</Radio.Button>
</Radio.Group>
</div>
<div className="dc-calc-field">
<label>天数</label>
<InputNumber
min={0}
max={99999}
value={dayCount}
onChange={(v) => setDayCount(v || 0)}
style={{ width: "100%" }}
size="large"
addonAfter="天"
/>
</div>
</div>
{baseDate ? (
renderResults(推算Results)
) : (
<div className="dc-empty-hint">
<CalendarOutlined style={{ fontSize: 36, color: "#cbd5e1" }} />
<div>选择起始日期后自动推算目标日期</div>
</div>
)}
</div>
),
},
];
return (
<div className="date-calculator-page">
<div className="date-calculator-hero">
<div className="date-calculator-hero-particles">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className={`dc-particle dc-particle-${i + 1}`} />
))}
</div>
<div className="date-calculator-hero-content">
<div className="date-calculator-hero-icon">
<CalendarOutlined />
</div>
<h1 className="date-calculator-hero-title">日期计算器</h1>
</div>
</div>
<div className="date-calculator-container">
<div className="date-calculator-toolbar">
<Button
icon={<ArrowLeftOutlined />}
className="dc-back-btn"
onClick={() => navigate("/utility")}
>
返回工具列表
</Button>
</div>
<div className="date-calculator-main">
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={tabItems}
className="dc-tabs"
/>
</div>
<div className="date-calculator-tips">
<div className="dc-tip-card">
<div className="dc-tip-icon">📅</div>
<div className="dc-tip-content">
<h4>日期间隔</h4>
<p>计算两个日期之间的天数、工作日、周数、月数和年数。</p>
</div>
</div>
<div className="dc-tip-card">
<div className="dc-tip-icon">🧮</div>
<div className="dc-tip-content">
<h4>日期推算</h4>
<p>从指定日期向前或向后推算任意天数,快速得到目标日期。</p>
</div>
</div>
<div className="dc-tip-card">
<div className="dc-tip-icon">🔒</div>
<div className="dc-tip-content">
<h4>数据安全</h4>
<p>所有计算在本地浏览器完成,不会上传任何数据到服务器。</p>
</div>
</div>
</div>
</div>
</div>
);
};
export default DateCalculator;
```
- [ ] **Step 4: 创建 DateCalculator.css 样式**
```css
.date-calculator-page {
min-height: 100vh;
background: #f0f2f5;
}
.date-calculator-hero {
min-height: 200px;
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 50%, #6366f1 100%);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
padding: 40px 20px;
}
.date-calculator-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: dcHeroLight 8s ease-in-out infinite alternate;
}
@keyframes dcHeroLight {
0% { transform: translate(0, 0); }
100% { transform: translate(-20px, -10px); }
}
.date-calculator-hero-particles {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
}
.dc-particle {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
animation: dcFloat 6s ease-in-out infinite;
}
.dc-particle-1 { width: 50px; height: 50px; top: 15%; left: 8%; animation-delay: 0s; }
.dc-particle-2 { width: 35px; height: 35px; top: 65%; left: 18%; animation-delay: 1s; }
.dc-particle-3 { width: 65px; height: 65px; top: 25%; right: 12%; animation-delay: 2s; }
.dc-particle-4 { width: 25px; height: 25px; top: 70%; right: 25%; animation-delay: 0.5s; }
.dc-particle-5 { width: 40px; height: 40px; top: 45%; left: 45%; animation-delay: 1.5s; }
.dc-particle-6 { width: 30px; height: 30px; bottom: 20%; left: 30%; animation-delay: 3s; }
@keyframes dcFloat {
0%, 100% { transform: translateY(0) scale(1); opacity: 0.3; }
50% { transform: translateY(-18px) scale(1.1); opacity: 0.6; }
}
.date-calculator-hero-content {
text-align: center;
position: relative;
z-index: 1;
}
.date-calculator-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);
}
.date-calculator-hero-title {
font-size: 32px;
font-weight: 800;
color: #fff;
margin: 0;
letter-spacing: 2px;
}
.date-calculator-container {
max-width: 800px;
margin: -30px auto 0;
padding: 0 20px 60px;
position: relative;
z-index: 2;
}
.date-calculator-toolbar {
display: flex;
align-items: center;
margin-bottom: 20px;
}
.dc-back-btn {
border-radius: 8px;
font-weight: 500;
color: #64748b;
border-color: #e2e8f0;
}
.dc-back-btn:hover {
color: #3b82f6;
border-color: #3b82f6;
}
.date-calculator-main {
background: #fff;
border-radius: 14px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
border: 1px solid #e8ecf1;
overflow: hidden;
margin-bottom: 24px;
}
.dc-tabs .ant-tabs-nav {
margin-bottom: 0;
padding: 0 16px;
}
.dc-tab-content {
padding: 24px;
}
.dc-quick-btns {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.dc-date-pickers {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 16px;
align-items: end;
margin-bottom: 20px;
}
.dc-date-field label,
.dc-calc-field label {
display: block;
font-size: 13px;
font-weight: 600;
color: #64748b;
margin-bottom: 8px;
}
.dc-date-arrow {
font-size: 20px;
color: #94a3b8;
padding-bottom: 8px;
text-align: center;
}
.dc-calc-row {
display: grid;
grid-template-columns: 2fr 1fr 1.5fr;
gap: 16px;
align-items: end;
margin-bottom: 20px;
}
.dc-results {
display: flex;
flex-direction: column;
gap: 10px;
}
.dc-result-item {
display: flex;
align-items: center;
justify-content: space-between;
background: #f8fafc;
border: 1px solid #e8ecf1;
border-radius: 10px;
padding: 12px 16px;
transition: all 0.2s ease;
animation: dcFadeIn 0.3s ease;
}
.dc-result-item:hover {
background: #f1f5f9;
border-color: #c7d2fe;
}
@keyframes dcFadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.dc-result-label {
font-size: 13px;
font-weight: 600;
color: #64748b;
min-width: 120px;
flex-shrink: 0;
}
.dc-result-value {
font-size: 14px;
color: #1e293b;
word-break: break-all;
flex: 1;
margin: 0 12px;
}
.dc-result-copy {
flex-shrink: 0;
width: 30px;
height: 30px;
border-radius: 6px;
border: 1px solid #e2e8f0;
background: #fff;
color: #94a3b8;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
transition: all 0.2s ease;
padding: 0;
}
.dc-result-copy:hover {
color: #3b82f6;
border-color: #93c5fd;
background: #eff6ff;
}
.dc-result-copy.copied {
color: #10b981;
border-color: #6ee7b7;
background: #ecfdf5;
}
.dc-empty-hint {
text-align: center;
padding: 40px 16px;
color: #94a3b8;
font-size: 14px;
}
.date-calculator-tips {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.dc-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;
}
.dc-tip-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
}
.dc-tip-icon {
font-size: 28px;
flex-shrink: 0;
line-height: 1;
}
.dc-tip-content h4 {
font-size: 14px;
font-weight: 700;
color: #1e293b;
margin: 0 0 6px;
}
.dc-tip-content p {
font-size: 13px;
color: #64748b;
margin: 0;
line-height: 1.6;
}
@media (max-width: 768px) {
.date-calculator-hero {
min-height: 160px;
padding: 30px 16px;
}
.date-calculator-hero-title {
font-size: 24px;
}
.date-calculator-container {
padding: 0 12px 40px;
margin-top: -20px;
}
.dc-date-pickers {
grid-template-columns: 1fr;
}
.dc-date-arrow {
transform: rotate(90deg);
padding: 0;
}
.dc-calc-row {
grid-template-columns: 1fr;
}
.date-calculator-tips {
grid-template-columns: 1fr;
gap: 10px;
}
.dc-result-item {
flex-wrap: wrap;
gap: 6px;
}
.dc-result-value {
margin: 4px 0;
width: 100%;
order: 3;
}
}
```
---
## Task 3: 创建图片压缩工具页面
**Files:**
- Create: `src/pages/ImageCompressor/ImageCompressor.tsx`
- Create: `src/pages/ImageCompressor/ImageCompressor.css`
### 功能需求
- **图片上传**:支持拖拽上传和点击上传,支持 JPG、PNG、WebP、GIF 格式
- **压缩设置**:可调节压缩质量(0-100 滑块),可选择输出格式(原格式/JPG/PNG/WebP)
- **预览对比**:压缩前/压缩后图片预览,支持左右对比
- **信息显示**:原图大小、压缩后大小、压缩率、尺寸信息
- **下载功能**:下载压缩后的图片
- **批量处理**:支持同时上传多张图片并逐一压缩
### 组件设计
- 顶部:Hero 横幅(`CompressOutlined` 图标,标题"图片压缩")
- 主内容区:
- 上传区域:大虚线边框拖拽区,支持多文件
- 设置面板:质量滑块 + 输出格式选择
- 结果列表:每张图片的对比卡片(原图/压缩后预览 + 信息 + 下载按钮)
- 底部:3 个提示卡片
- [ ] **Step 5: 创建 ImageCompressor.tsx 组件**
```tsx
import "./ImageCompressor.css";
import React, { useState, useRef, useCallback } from "react";
import { Button, Slider, Select, message, Progress } from "antd";
import {
CompressOutlined,
ArrowLeftOutlined,
UploadOutlined,
DownloadOutlined,
DeleteOutlined,
FileImageOutlined,
} from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import { useRecordHistory } from "@/hooks/useRecordHistory";
interface CompressedImage {
id: string;
originalFile: File;
originalUrl: string;
compressedUrl: string;
originalSize: number;
compressedSize: number;
originalWidth: number;
originalHeight: number;
compressedWidth: number;
compressedHeight: number;
format: string;
status: "compressing" | "done" | "error";
}
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
const generateId = () => Math.random().toString(36).substring(2, 9);
const ImageCompressor: React.FC = () => {
const navigate = useNavigate();
const fileInputRef = useRef<HTMLInputElement>(null);
useRecordHistory({
type: "tool",
title: "图片压缩",
description: "在线图片压缩工具,支持多种格式",
category: "设计",
link: "/utility/image-compressor",
});
const [images, setImages] = useState<CompressedImage[]>([]);
const [quality, setQuality] = useState<number>(80);
const [outputFormat, setOutputFormat] = useState<string>("original");
const [isDragging, setIsDragging] = useState(false);
const compressImage = useCallback(
(file: File): Promise<CompressedImage> => {
return new Promise((resolve) => {
const id = generateId();
const originalUrl = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) {
resolve({
id,
originalFile: file,
originalUrl,
compressedUrl: originalUrl,
originalSize: file.size,
compressedSize: file.size,
originalWidth: img.width,
originalHeight: img.height,
compressedWidth: img.width,
compressedHeight: img.height,
format: file.type,
status: "error",
});
return;
}
// 限制最大尺寸为 2048px(保持比例)
let width = img.width;
let height = img.height;
const maxSize = 2048;
if (width > maxSize || height > maxSize) {
if (width > height) {
height = Math.round((height * maxSize) / width);
width = maxSize;
} else {
width = Math.round((width * maxSize) / height);
height = maxSize;
}
}
canvas.width = width;
canvas.height = height;
ctx.drawImage(img, 0, 0, width, height);
let mimeType = file.type;
if (outputFormat === "jpeg") mimeType = "image/jpeg";
if (outputFormat === "png") mimeType = "image/png";
if (outputFormat === "webp") mimeType = "image/webp";
// 如果原图是 GIF,保持原格式
if (file.type === "image/gif" && outputFormat !== "original") {
mimeType = "image/webp";
}
canvas.toBlob(
(blob) => {
if (blob) {
const compressedUrl = URL.createObjectURL(blob);
resolve({
id,
originalFile: file,
originalUrl,
compressedUrl,
originalSize: file.size,
compressedSize: blob.size,
originalWidth: img.width,
originalHeight: img.height,
compressedWidth: width,
compressedHeight: height,
format: mimeType,
status: "done",
});
} else {
resolve({
id,
originalFile: file,
originalUrl,
compressedUrl: originalUrl,
originalSize: file.size,
compressedSize: file.size,
originalWidth: img.width,
originalHeight: img.height,
compressedWidth: img.width,
compressedHeight: img.height,
format: file.type,
status: "error",
});
}
},
mimeType,
quality / 100
);
};
img.onerror = () => {
resolve({
id,
originalFile: file,
originalUrl,
compressedUrl: originalUrl,
originalSize: file.size,
compressedSize: file.size,
originalWidth: 0,
originalHeight: 0,
compressedWidth: 0,
compressedHeight: 0,
format: file.type,
status: "error",
});
};
img.src = originalUrl;
});
},
[quality, outputFormat]
);
const handleFiles = async (files: FileList | null) => {
if (!files || files.length === 0) return;
const validTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"];
const validFiles = Array.from(files).filter((f) => validTypes.includes(f.type));
if (validFiles.length === 0) {
message.error("请上传 JPG、PNG、WebP 或 GIF 格式的图片");
return;
}
if (validFiles.length > 10) {
message.warning("一次最多处理 10 张图片");
}
const filesToProcess = validFiles.slice(0, 10);
// 先添加占位项
const placeholders: CompressedImage[] = filesToProcess.map((file) => ({
id: generateId(),
originalFile: file,
originalUrl: URL.createObjectURL(file),
compressedUrl: "",
originalSize: file.size,
compressedSize: 0,
originalWidth: 0,
originalHeight: 0,
compressedWidth: 0,
compressedHeight: 0,
format: file.type,
status: "compressing",
}));
setImages((prev) => [...prev, ...placeholders]);
// 逐个压缩
for (let i = 0; i < filesToProcess.length; i++) {
const result = await compressImage(filesToProcess[i]);
setImages((prev) =>
prev.map((img, idx) =>
prev.length - filesToProcess.length + i === idx ? result : img
)
);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
handleFiles(e.dataTransfer.files);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = () => {
setIsDragging(false);
};
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
handleFiles(e.target.files);
e.target.value = "";
};
const handleDownload = (img: CompressedImage) => {
const link = document.createElement("a");
link.href = img.compressedUrl;
const ext = img.format.split("/")[1] || "jpg";
link.download = `compressed_${img.originalFile.name.replace(/\.[^/.]+$/, "")}.${ext}`;
link.click();
};
const handleDownloadAll = () => {
images.filter((img) => img.status === "done").forEach(handleDownload);
};
const handleRemove = (id: string) => {
setImages((prev) => {
const img = prev.find((i) => i.id === id);
if (img) {
URL.revokeObjectURL(img.originalUrl);
if (img.compressedUrl) URL.revokeObjectURL(img.compressedUrl);
}
return prev.filter((i) => i.id !== id);
});
};
const handleClearAll = () => {
images.forEach((img) => {
URL.revokeObjectURL(img.originalUrl);
if (img.compressedUrl) URL.revokeObjectURL(img.compressedUrl);
});
setImages([]);
};
const totalOriginalSize = images.reduce((sum, img) => sum + img.originalSize, 0);
const totalCompressedSize = images.reduce((sum, img) => sum + img.compressedSize, 0);
const totalSavings = totalOriginalSize - totalCompressedSize;
const savingsPercent = totalOriginalSize > 0 ? Math.round((totalSavings / totalOriginalSize) * 100) : 0;
return (
<div className="image-compressor-page">
<div className="image-compressor-hero">
<div className="image-compressor-hero-particles">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className={`ic-particle ic-particle-${i + 1}`} />
))}
</div>
<div className="image-compressor-hero-content">
<div className="image-compressor-hero-icon">
<CompressOutlined />
</div>
<h1 className="image-compressor-hero-title">图片压缩</h1>
</div>
</div>
<div className="image-compressor-container">
<div className="image-compressor-toolbar">
<Button
icon={<ArrowLeftOutlined />}
className="ic-back-btn"
onClick={() => navigate("/utility")}
>
返回工具列表
</Button>
{images.length > 0 && (
<div className="ic-toolbar-actions">
<Button icon={<DownloadOutlined />} onClick={handleDownloadAll}>
全部下载
</Button>
<Button icon={<DeleteOutlined />} danger onClick={handleClearAll}>
清空全部
</Button>
</div>
)}
</div>
<div className="ic-settings-panel">
<div className="ic-setting-item">
<label>压缩质量</label>
<div className="ic-slider-wrapper">
<Slider
min={1}
max={100}
value={quality}
onChange={setQuality}
style={{ flex: 1 }}
/>
<span className="ic-quality-value">{quality}%</span>
</div>
</div>
<div className="ic-setting-item">
<label>输出格式</label>
<Select
value={outputFormat}
onChange={setOutputFormat}
options={[
{ label: "保持原格式", value: "original" },
{ label: "JPEG", value: "jpeg" },
{ label: "PNG", value: "png" },
{ label: "WebP", value: "webp" },
]}
style={{ width: 160 }}
/>
</div>
</div>
<div
className={`ic-upload-area ${isDragging ? "dragging" : ""}`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
multiple
onChange={handleFileInput}
style={{ display: "none" }}
/>
<UploadOutlined style={{ fontSize: 48, color: "#6366f1" }} />
<p className="ic-upload-text">点击或拖拽图片到此处</p>
<p className="ic-upload-hint">支持 JPG、PNG、WebP、GIF,最多 10 张</p>
</div>
{images.length > 0 && (
<div className="ic-stats-bar">
<div className="ic-stat">
<span className="ic-stat-label">图片数量</span>
<span className="ic-stat-value">{images.length} 张</span>
</div>
<div className="ic-stat">
<span className="ic-stat-label">原图总大小</span>
<span className="ic-stat-value">{formatFileSize(totalOriginalSize)}</span>
</div>
<div className="ic-stat">
<span className="ic-stat-label">压缩后总大小</span>
<span className="ic-stat-value">{formatFileSize(totalCompressedSize)}</span>
</div>
<div className="ic-stat">
<span className="ic-stat-label">节省空间</span>
<span className={`ic-stat-value ${savingsPercent > 0 ? "savings" : ""}`}>
{formatFileSize(totalSavings)} ({savingsPercent}%)
</span>
</div>
</div>
)}
<div className="ic-images-grid">
{images.map((img) => (
<div key={img.id} className="ic-image-card">
<div className="ic-image-card-header">
<span className="ic-image-name">{img.originalFile.name}</span>
<Button
size="small"
icon={<DeleteOutlined />}
danger
type="text"
onClick={() => handleRemove(img.id)}
/>
</div>
<div className="ic-image-compare">
<div className="ic-image-side">
<div className="ic-image-label">原图</div>
<div className="ic-image-preview">
{img.status === "compressing" ? (
<div className="ic-loading"><FileImageOutlined /> 加载中...</div>
) : (
<img src={img.originalUrl} alt="original" />
)}
</div>
<div className="ic-image-info">
{formatFileSize(img.originalSize)} · {img.originalWidth}×{img.originalHeight}
</div>
</div>
<div className="ic-image-side">
<div className="ic-image-label">
压缩后
{img.status === "done" && (
<span className="ic-savings-badge">
-{Math.round(((img.originalSize - img.compressedSize) / img.originalSize) * 100)}%
</span>
)}
</div>
<div className="ic-image-preview">
{img.status === "compressing" ? (
<div className="ic-loading">
<Progress percent={50} status="active" size="small" />
</div>
) : img.status === "error" ? (
<div className="ic-error">压缩失败</div>
) : (
<img src={img.compressedUrl} alt="compressed" />
)}
</div>
<div className="ic-image-info">
{img.status === "done"
? `${formatFileSize(img.compressedSize)} · ${img.compressedWidth}×${img.compressedHeight}`
: "处理中..."}
</div>
</div>
</div>
{img.status === "done" && (
<div className="ic-image-actions">
<Button
type="primary"
icon={<DownloadOutlined />}
size="small"
onClick={() => handleDownload(img)}
>
下载
</Button>
</div>
)}
</div>
))}
</div>
<div className="image-compressor-tips">
<div className="ic-tip-card">
<div className="ic-tip-icon">🖼️</div>
<div className="ic-tip-content">
<h4>本地处理</h4>
<p>所有图片在浏览器本地压缩,不会上传到服务器,保护您的隐私。</p>
</div>
</div>
<div className="ic-tip-card">
<div className="ic-tip-icon">⚙️</div>
<div className="ic-tip-content">
<h4>灵活设置</h4>
<p>可自由调节压缩质量,选择输出格式,平衡画质与文件大小。</p>
</div>
</div>
<div className="ic-tip-card">
<div className="ic-tip-icon">📦</div>
<div className="ic-tip-content">
<h4>批量处理</h4>
<p>支持同时上传多张图片,一键批量压缩并下载。</p>
</div>
</div>
</div>
</div>
</div>
);
};
export default ImageCompressor;
```
- [ ] **Step 6: 创建 ImageCompressor.css 样式**
```css
.image-compressor-page {
min-height: 100vh;
background: #f0f2f5;
}
.image-compressor-hero {
min-height: 200px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
padding: 40px 20px;
}
.image-compressor-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: icHeroLight 8s ease-in-out infinite alternate;
}
@keyframes icHeroLight {
0% { transform: translate(0, 0); }
100% { transform: translate(-20px, -10px); }
}
.image-compressor-hero-particles {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
}
.ic-particle {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
animation: icFloat 6s ease-in-out infinite;
}
.ic-particle-1 { width: 50px; height: 50px; top: 15%; left: 8%; animation-delay: 0s; }
.ic-particle-2 { width: 35px; height: 35px; top: 65%; left: 18%; animation-delay: 1s; }
.ic-particle-3 { width: 65px; height: 65px; top: 25%; right: 12%; animation-delay: 2s; }
.ic-particle-4 { width: 25px; height: 25px; top: 70%; right: 25%; animation-delay: 0.5s; }
.ic-particle-5 { width: 40px; height: 40px; top: 45%; left: 45%; animation-delay: 1.5s; }
.ic-particle-6 { width: 30px; height: 30px; bottom: 20%; left: 30%; animation-delay: 3s; }
@keyframes icFloat {
0%, 100% { transform: translateY(0) scale(1); opacity: 0.3; }
50% { transform: translateY(-18px) scale(1.1); opacity: 0.6; }
}
.image-compressor-hero-content {
text-align: center;
position: relative;
z-index: 1;
}
.image-compressor-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-compressor-hero-title {
font-size: 32px;
font-weight: 800;
color: #fff;
margin: 0;
letter-spacing: 2px;
}
.image-compressor-container {
max-width: 1100px;
margin: -30px auto 0;
padding: 0 20px 60px;
position: relative;
z-index: 2;
}
.image-compressor-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 12px;
}
.ic-back-btn {
border-radius: 8px;
font-weight: 500;
color: #64748b;
border-color: #e2e8f0;
}
.ic-back-btn:hover {
color: #6366f1;
border-color: #6366f1;
}
.ic-toolbar-actions {
display: flex;
gap: 8px;
}
.ic-settings-panel {
display: flex;
gap: 24px;
background: #fff;
border-radius: 14px;
padding: 20px 24px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
border: 1px solid #e8ecf1;
margin-bottom: 20px;
flex-wrap: wrap;
}
.ic-setting-item {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-width: 200px;
}
.ic-setting-item label {
font-size: 13px;
font-weight: 600;
color: #64748b;
}
.ic-slider-wrapper {
display: flex;
align-items: center;
gap: 12px;
}
.ic-quality-value {
font-size: 14px;
font-weight: 600;
color: #6366f1;
min-width: 40px;
}
.ic-upload-area {
background: #fff;
border: 2px dashed #c7d2fe;
border-radius: 14px;
padding: 48px 24px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
margin-bottom: 20px;
}
.ic-upload-area:hover {
border-color: #6366f1;
background: #f5f3ff;
}
.ic-upload-area.dragging {
border-color: #6366f1;
background: #ede9fe;
transform: scale(1.01);
}
.ic-upload-text {
font-size: 16px;
font-weight: 600;
color: #334155;
margin: 12px 0 4px;
}
.ic-upload-hint {
font-size: 13px;
color: #94a3b8;
margin: 0;
}
.ic-stats-bar {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 20px;
}
.ic-stat {
background: #fff;
border-radius: 12px;
padding: 16px;
text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
border: 1px solid #e8ecf1;
}
.ic-stat-label {
display: block;
font-size: 12px;
color: #94a3b8;
margin-bottom: 4px;
}
.ic-stat-value {
font-size: 16px;
font-weight: 700;
color: #1e293b;
}
.ic-stat-value.savings {
color: #22c55e;
}
.ic-images-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
margin-bottom: 24px;
}
.ic-image-card {
background: #fff;
border-radius: 14px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
border: 1px solid #e8ecf1;
overflow: hidden;
}
.ic-image-card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #f1f5f9;
background: #fafbfc;
}
.ic-image-name {
font-size: 13px;
font-weight: 600;
color: #334155;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 80%;
}
.ic-image-compare {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
background: #e8ecf1;
}
.ic-image-side {
background: #fff;
padding: 12px;
}
.ic-image-label {
font-size: 12px;
font-weight: 600;
color: #64748b;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.ic-savings-badge {
background: #dcfce7;
color: #16a34a;
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 700;
}
.ic-image-preview {
aspect-ratio: 4 / 3;
background: #f8fafc;
border-radius: 8px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.ic-image-preview img {
width: 100%;
height: 100%;
object-fit: contain;
}
.ic-loading {
color: #94a3b8;
font-size: 13px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.ic-error {
color: #ef4444;
font-size: 13px;
}
.ic-image-info {
font-size: 12px;
color: #94a3b8;
margin-top: 8px;
text-align: center;
}
.ic-image-actions {
padding: 12px 16px;
border-top: 1px solid #f1f5f9;
display: flex;
justify-content: center;
}
.image-compressor-tips {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.ic-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;
}
.ic-tip-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
}
.ic-tip-icon {
font-size: 28px;
flex-shrink: 0;
line-height: 1;
}
.ic-tip-content h4 {
font-size: 14px;
font-weight: 700;
color: #1e293b;
margin: 0 0 6px;
}
.ic-tip-content p {
font-size: 13px;
color: #64748b;
margin: 0;
line-height: 1.6;
}
@media (max-width: 768px) {
.image-compressor-hero {
min-height: 160px;
padding: 30px 16px;
}
.image-compressor-hero-title {
font-size: 24px;
}
.image-compressor-container {
padding: 0 12px 40px;
margin-top: -20px;
}
.image-compressor-toolbar {
flex-direction: column;
align-items: stretch;
}
.ic-settings-panel {
flex-direction: column;
gap: 16px;
}
.ic-stats-bar {
grid-template-columns: repeat(2, 1fr);
}
.ic-images-grid {
grid-template-columns: 1fr;
}
.image-compressor-tips {
grid-template-columns: 1fr;
gap: 10px;
}
}
```
---
## Task 4: 在 Utility.tsx 中注册新工具路由
**Files:**
- Modify: `src/pages/Utility/Utility.tsx:209-223`
- [ ] **Step 7: 在 routeMap 中添加 3 个新工具的路由映射**
在 `handleToolClick` 函数中的 `routeMap` 对象里添加以下 3 个条目:
```ts
const routeMap: Record<string, string> = {
"JSON格式化": "/utility/json-formatter",
"取色器": "/color-picker",
"时间戳转换": "/utility/timestamp",
"Base64编解码": "/utility/base64",
"二维码生成": "/qrcode-generator",
"颜色转换器": "/utility/color-converter",
"正则表达式测试": "/utility/regex",
"文本对比": "/utility/text-diff",
"编码转换器": "/utility/encoding-converter",
"字符编码转换": "/utility/charset-converter",
// 新增:
"计算器": "/utility/calculator",
"日期计算器": "/utility/date-calculator",
"图片压缩": "/utility/image-compressor",
};
```
---
## Task 5: 在 App.tsx 中添加路由配置
**Files:**
- Modify: `src/App.tsx`
- [ ] **Step 8: 添加新页面的 lazy import**
在 `App.tsx` 的 import 区域添加:
```tsx
const Calculator = lazy(() => import("@/pages/Calculator/Calculator"));
const DateCalculator = lazy(() => import("@/pages/DateCalculator/DateCalculator"));
const ImageCompressor = lazy(() => import("@/pages/ImageCompressor/ImageCompressor"));
```
- [ ] **Step 9: 在 Routes 中添加新路由**
在 `App.tsx` 的 `<Routes>` 中添加:
```tsx
<Route path="/utility/calculator" element={<Calculator />} />
<Route path="/utility/date-calculator" element={<DateCalculator />} />
<Route path="/utility/image-compressor" element={<ImageCompressor />} />
```
---
## Self-Review
### 1. Spec coverage
| 需求 | 对应任务 |
|------|---------|
| 科学计算器(基本运算 + 科学运算 + 键盘支持) | Task 1 |
| 日期计算器(日期间隔 + 日期推算 + 快捷按钮) | Task 2 |
| 图片压缩(上传 + 质量调节 + 格式选择 + 预览 + 下载) | Task 3 |
| 在工具列表中注册新路由 | Task 4 |
| 在 App 路由中配置 | Task 5 |
**无遗漏。**
### 2. Placeholder scan
- 无 "TBD"、"TODO"、"implement later"
- 所有步骤包含完整代码
- 无 "Similar to Task N" 引用
- 所有文件路径精确
### 3. Type consistency
- `CompressedImage` 接口在 Task 3 中定义并一致使用
- `CalculatorState` 接口在 Task 1 中定义并一致使用
- `DateResult` 接口在 Task 2 中定义并一致使用
- `useRecordHistory` 参数格式与现有代码一致
---
## 执行选项
**计划已保存到 `docs/plans/2026-06-13-calculator-datecalc-imagecompress.md`。两个执行选项:**
**1. Subagent-Driven(推荐)** - 每个 Task 分配一个独立子代理执行,我在每个 Task 完成后审查
**2. Inline Execution** - 在当前会话中按顺序执行所有 Task,批量处理并设置检查点
**请选择执行方式?**