2467 lines
64 KiB
Markdown
2467 lines
64 KiB
Markdown
# 计算器、日期计算器、图片压缩工具实现计划
|
||
|
||
> **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,批量处理并设置检查点
|
||
|
||
**请选择执行方式?**
|