# 计算器、日期计算器、图片压缩工具实现计划 > **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({ 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 (
{Array.from({ length: 6 }).map((_, i) => (
))}

科学计算器

{state.history}
{state.current}
{buttons.map((btn, idx) => ( ))}
⌨️

键盘支持

支持使用键盘输入数字和运算符,Enter 计算,Esc 清空,Backspace 退格。

🔢

科学功能

支持平方、开方、倒数、百分比等常用科学计算功能。

🔒

数据安全

所有计算在本地浏览器完成,数据不会上传到服务器。

); }; 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("interval"); const [startDate, setStartDate] = useState(null); const [endDate, setEndDate] = useState(null); const [baseDate, setBaseDate] = useState(null); const [dayCount, setDayCount] = useState(7); const [dayDirection, setDayDirection] = useState<"add" | "sub">("add"); const [copiedField, setCopiedField] = useState(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[]) => (
{results.map((item) => (
{item.label} {item.value}
))}
); const tabItems = [ { key: "interval", label: "日期间隔", children: (
→
{startDate && endDate ? ( renderResults(intervalResults) ) : (
选择两个日期后自动计算间隔
)}
), }, { key: "推算", label: "日期推算", children: (
setDayDirection(e.target.value)} buttonStyle="solid" > + -
setDayCount(v || 0)} style={{ width: "100%" }} size="large" addonAfter="天" />
{baseDate ? ( renderResults(推算Results) ) : (
选择起始日期后自动推算目标日期
)}
), }, ]; return (
{Array.from({ length: 6 }).map((_, i) => (
))}

日期计算器

📅

日期间隔

计算两个日期之间的天数、工作日、周数、月数和年数。

🧮

日期推算

从指定日期向前或向后推算任意天数,快速得到目标日期。

🔒

数据安全

所有计算在本地浏览器完成,不会上传任何数据到服务器。

); }; 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(null); useRecordHistory({ type: "tool", title: "图片压缩", description: "在线图片压缩工具,支持多种格式", category: "设计", link: "/utility/image-compressor", }); const [images, setImages] = useState([]); const [quality, setQuality] = useState(80); const [outputFormat, setOutputFormat] = useState("original"); const [isDragging, setIsDragging] = useState(false); const compressImage = useCallback( (file: File): Promise => { 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) => { 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 (
{Array.from({ length: 6 }).map((_, i) => (
))}

图片压缩

{images.length > 0 && (
)}
{quality}%

点击或拖拽图片到此处

支持 JPG、PNG、WebP、GIF,最多 10 张

{images.length > 0 && (
图片数量 {images.length} 张
原图总大小 {formatFileSize(totalOriginalSize)}
压缩后总大小 {formatFileSize(totalCompressedSize)}
节省空间 0 ? "savings" : ""}`}> {formatFileSize(totalSavings)} ({savingsPercent}%)
)}
{images.map((img) => (
{img.originalFile.name}
原图
{img.status === "compressing" ? (
加载中...
) : ( original )}
{formatFileSize(img.originalSize)} · {img.originalWidth}×{img.originalHeight}
压缩后 {img.status === "done" && ( -{Math.round(((img.originalSize - img.compressedSize) / img.originalSize) * 100)}% )}
{img.status === "compressing" ? (
) : img.status === "error" ? (
压缩失败
) : ( compressed )}
{img.status === "done" ? `${formatFileSize(img.compressedSize)} · ${img.compressedWidth}×${img.compressedHeight}` : "处理中..."}
{img.status === "done" && (
)}
))}
🖼️

本地处理

所有图片在浏览器本地压缩,不会上传到服务器,保护您的隐私。

⚙️

灵活设置

可自由调节压缩质量,选择输出格式,平衡画质与文件大小。

📦

批量处理

支持同时上传多张图片,一键批量压缩并下载。

); }; 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 = { "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` 的 `` 中添加: ```tsx } /> } /> } /> ``` --- ## 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,批量处理并设置检查点 **请选择执行方式?**