附录 B:Pomodoro 番茄钟项目完整代码
💡 进群学习加 wx: agentupdate
(申请发送: agentupdate)
(申请发送: agentupdate)
本附录包含教程中构建的番茄钟应用程序的完整源代码。
项目概述
技术栈: React 18 + Vite + localStorage
主要功能:
- 25分钟工作/5分钟休息计时器
- Todo List(添加、删除、标记完成)
- localStorage 数据持久化
运行方式:
npm install
npm run dev
# 访问 http://localhost:5173
项目结构
my-pomodoro/
├── index.html
├── package.json
├── vite.config.js
├── src/
│ ├── main.jsx
│ ├── App.jsx
│ ├── components/
│ │ ├── Timer.jsx
│ │ └── TodoList.jsx
│ ├── hooks/
│ │ └── useLocalStorage.js
│ └── styles/
│ └── app.css
package.json
{
"name": "my-pomodoro",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.2"
}
}
vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})
index.html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>番茄钟 - Pomodoro Timer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
src/main.jsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './styles/app.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
src/App.jsx
import { useState } from 'react'
import Timer from './components/Timer'
import TodoList from './components/TodoList'
import useLocalStorage from './hooks/useLocalStorage'
function App() {
const [todos, setTodos] = useLocalStorage('pomodoro-todos', [])
const [pomodoroCount, setPomodoroCount] = useLocalStorage('pomodoro-count', 0)
const addTodo = (text) => {
const newTodo = {
id: Date.now(),
text,
completed: false,
createdAt: new Date().toISOString()
}
setTodos([...todos, newTodo])
}
const toggleTodo = (id) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
))
}
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id))
}
const incrementPomodoroCount = () => {
setPomodoroCount(prev => prev + 1)
}
return (
<div className="app">
<header className="app-header">
<h1>番茄钟</h1>
<p className="pomodoro-stats">
今日完成: {pomodoroCount} 个番茄钟
</p>
</header>
<main className="app-main">
<Timer onComplete={incrementPomodoroCount} />
<TodoList
todos={todos}
onAdd={addTodo}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
</main>
</div>
)
}
export default App
src/components/Timer.jsx
import { useState, useEffect, useRef } from 'react'
function Timer({ onComplete }) {
const WORK_DURATION = 25 * 60 // 25分钟
const BREAK_DURATION = 5 * 60 // 5分钟
const [timeLeft, setTimeLeft] = useState(WORK_DURATION)
const [isRunning, setIsRunning] = useState(false)
const [mode, setMode] = useState('work') // 'work' 或 'break'
const intervalRef = useRef(null)
// 计时器倒计时
useEffect(() => {
if (isRunning) {
intervalRef.current = setInterval(() => {
setTimeLeft(prev => {
if (prev <= 1) {
handleTimerComplete()
return prev
}
return prev - 1
})
}, 1000)
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
}
}
}, [isRunning, mode])
// 计时器完成处理
const handleTimerComplete = () => {
setIsRunning(false)
playNotificationSound()
if (mode === 'work') {
onComplete() // 增加番茄钟计数
setMode('break')
setTimeLeft(BREAK_DURATION)
} else {
setMode('work')
setTimeLeft(WORK_DURATION)
}
}
// 播放提示音(可选)
const playNotificationSound = () => {
try {
const audioContext = new (window.AudioContext || window.webkitAudioContext)()
const oscillator = audioContext.createOscillator()
const gainNode = audioContext.createGain()
oscillator.connect(gainNode)
gainNode.connect(audioContext.destination)
oscillator.frequency.value = 800
oscillator.type = 'sine'
gainNode.gain.value = 0.3
oscillator.start()
oscillator.stop(audioContext.currentTime + 0.2)
} catch (error) {
console.error('无法播放提示音:', error)
}
}
// 格式化时间显示 MM:SS
const formatTime = (seconds) => {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
// 开始/暂停计时器
const toggleTimer = () => {
setIsRunning(!isRunning)
}
// 重置计时器
const resetTimer = () => {
setIsRunning(false)
setMode('work')
setTimeLeft(WORK_DURATION)
}
// 切换模式
const switchMode = (newMode) => {
setIsRunning(false)
setMode(newMode)
setTimeLeft(newMode === 'work' ? WORK_DURATION : BREAK_DURATION)
}
return (
<div className={`timer-container ${mode}`}>
<div className="mode-indicator">
<button
className={`mode-button ${mode === 'work' ? 'active' : ''}`}
onClick={() => switchMode('work')}
>
工作时间
</button>
<button
className={`mode-button ${mode === 'break' ? 'active' : ''}`}
onClick={() => switchMode('break')}
>
休息时间
</button>
</div>
<div className="timer-display">
{formatTime(timeLeft)}
</div>
<div className="timer-controls">
<button
className="control-button start-pause"
onClick={toggleTimer}
>
{isRunning ? '暂停' : '开始'}
</button>
<button
className="control-button reset"
onClick={resetTimer}
>
重置
</button>
</div>
<div className="timer-status">
{mode === 'work' ? '专注工作 25 分钟' : '休息放松 5 分钟'}
</div>
</div>
)
}
export default Timer
src/components/TodoList.jsx
import { useState } from 'react'
function TodoList({ todos, onAdd, onToggle, onDelete }) {
const [newTodoText, setNewTodoText] = useState('')
const [filter, setFilter] = useState('all') // 'all', 'active', 'completed'
const handleSubmit = (e) => {
e.preventDefault()
if (newTodoText.trim()) {
onAdd(newTodoText.trim())
setNewTodoText('')
}
}
const filteredTodos = todos.filter(todo => {
if (filter === 'active') return !todo.completed
if (filter === 'completed') return todo.completed
return true
})
const activeCount = todos.filter(todo => !todo.completed).length
return (
<div className="todo-container">
<div className="todo-header">
<h2>待办事项</h2>
<span className="todo-count">
剩余 {activeCount} 项
</span>
</div>
<form className="todo-form" onSubmit={handleSubmit}>
<input
type="text"
className="todo-input"
placeholder="添加新的待办事项..."
value={newTodoText}
onChange={(e) => setNewTodoText(e.target.value)}
/>
<button type="submit" className="todo-add-button">
添加
</button>
</form>
<div className="todo-filters">
<button
className={`filter-button ${filter === 'all' ? 'active' : ''}`}
onClick={() => setFilter('all')}
>
全部
</button>
<button
className={`filter-button ${filter === 'active' ? 'active' : ''}`}
onClick={() => setFilter('active')}
>
进行中
</button>
<button
className={`filter-button ${filter === 'completed' ? 'active' : ''}`}
onClick={() => setFilter('completed')}
>
已完成
</button>
</div>
<ul className="todo-list">
{filteredTodos.length === 0 ? (
<li className="todo-empty">
{filter === 'all'
? '还没有待办事项,添加一个吧!'
: filter === 'active'
? '没有进行中的任务'
: '还没有完成任何任务'}
</li>
) : (
filteredTodos.map(todo => (
<li
key={todo.id}
className={`todo-item ${todo.completed ? 'completed' : ''}`}
>
<label className="todo-checkbox-label">
<input
type="checkbox"
className="todo-checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span className="todo-text">{todo.text}</span>
</label>
<button
className="todo-delete-button"
onClick={() => onDelete(todo.id)}
aria-label="删除待办事项"
>
删除
</button>
</li>
))
)}
</ul>
</div>
)
}
export default TodoList
src/hooks/useLocalStorage.js
import { useState, useEffect } from 'react'
function useLocalStorage(key, initialValue) {
// 从 localStorage 读取初始值
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key)
return item ? JSON.parse(item) : initialValue
} catch (error) {
console.error(`Error reading localStorage key "${key}":`, error)
return initialValue
}
})
// 更新 localStorage 当值变化时
const setValue = (value) => {
try {
// 支持函数形式,类似 useState
const valueToStore = value instanceof Function
? value(storedValue)
: value
setStoredValue(valueToStore)
// 保存到 localStorage
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(valueToStore))
}
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error)
}
}
return [storedValue, setValue]
}
export default useLocalStorage
src/styles/app.css
/* CSS 变量 */
:root {
--color-work-red: #e74c3c;
--color-work-light: #c0392b;
--color-break-green: #2ecc71;
--color-break-light: #27ae60;
--color-bg: #f5f7fa;
--color-card: #ffffff;
--color-text: #2c3e50;
--color-text-light: #7f8c8d;
--color-border: #e1e8ed;
--color-primary: #3498db;
--color-primary-hover: #2980b9;
--color-danger: #e74c3c;
--color-danger-hover: #c0392b;
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 20px rgba(0, 0, 0, 0.1);
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 16px;
--transition: all 0.3s ease;
}
/* 全局样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--color-bg);
color: var(--color-text);
line-height: 1.6;
}
/* 应用容器 */
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* 头部 */
.app-header {
background: var(--color-card);
padding: 2rem;
text-align: center;
box-shadow: var(--shadow-sm);
}
.app-header h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
color: var(--color-text);
}
.pomodoro-stats {
color: var(--color-text-light);
font-size: 1rem;
}
/* 主体内容 */
.app-main {
flex: 1;
max-width: 800px;
margin: 0 auto;
padding: 2rem;
width: 100%;
display: grid;
gap: 2rem;
}
/* 计时器容器 */
.timer-container {
background: var(--color-card);
border-radius: var(--radius-lg);
padding: 2rem;
box-shadow: var(--shadow-md);
text-align: center;
transition: var(--transition);
}
.timer-container.work {
border-left: 4px solid var(--color-work-red);
}
.timer-container.break {
border-left: 4px solid var(--color-break-green);
}
/* 模式指示器 */
.mode-indicator {
display: flex;
gap: 1rem;
justify-content: center;
margin-bottom: 2rem;
}
.mode-button {
padding: 0.75rem 1.5rem;
border: 2px solid var(--color-border);
background: transparent;
border-radius: var(--radius-md);
cursor: pointer;
font-size: 1rem;
color: var(--color-text);
transition: var(--transition);
}
.mode-button:hover {
background: var(--color-bg);
}
.mode-button.active {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.timer-container.work .mode-button.active {
background: var(--color-work-red);
border-color: var(--color-work-red);
}
.timer-container.break .mode-button.active {
background: var(--color-break-green);
border-color: var(--color-break-green);
}
/* 计时器显示 */
.timer-display {
font-size: 6rem;
font-weight: bold;
margin-bottom: 2rem;
font-family: 'Courier New', Courier, monospace;
transition: var(--transition);
}
.timer-container.work .timer-display {
color: var(--color-work-red);
}
.timer-container.break .timer-display {
color: var(--color-break-green);
}
/* 控制按钮 */
.timer-controls {
display: flex;
gap: 1rem;
justify-content: center;
margin-bottom: 1.5rem;
}
.control-button {
padding: 1rem 2rem;
font-size: 1.1rem;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: var(--transition);
font-weight: bold;
}
.control-button.start-pause {
background: var(--color-primary);
color: white;
}
.control-button.start-pause:hover {
background: var(--color-primary-hover);
}
.control-button.reset {
background: var(--color-border);
color: var(--color-text);
}
.control-button.reset:hover {
background: #cbd5e0;
}
/* 计时器状态 */
.timer-status {
color: var(--color-text-light);
font-size: 1rem;
}
/* Todo 容器 */
.todo-container {
background: var(--color-card);
border-radius: var(--radius-lg);
padding: 2rem;
box-shadow: var(--shadow-md);
}
/* Todo 头部 */
.todo-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.todo-header h2 {
font-size: 1.5rem;
color: var(--color-text);
}
.todo-count {
color: var(--color-text-light);
font-size: 0.9rem;
background: var(--color-bg);
padding: 0.5rem 1rem;
border-radius: var(--radius-md);
}
/* Todo 表单 */
.todo-form {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.todo-input {
flex: 1;
padding: 0.75rem 1rem;
border: 2px solid var(--color-border);
border-radius: var(--radius-md);
font-size: 1rem;
transition: var(--transition);
}
.todo-input:focus {
outline: none;
border-color: var(--color-primary);
}
.todo-add-button {
padding: 0.75rem 1.5rem;
background: var(--color-primary);
color: white;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
font-size: 1rem;
font-weight: bold;
transition: var(--transition);
}
.todo-add-button:hover {
background: var(--color-primary-hover);
}
/* Todo 过滤器 */
.todo-filters {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.filter-button {
padding: 0.5rem 1rem;
background: transparent;
border: 2px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
font-size: 0.9rem;
color: var(--color-text);
transition: var(--transition);
}
.filter-button:hover {
background: var(--color-bg);
}
.filter-button.active {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
/* Todo 列表 */
.todo-list {
list-style: none;
}
.todo-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
background: var(--color-bg);
border-radius: var(--radius-md);
margin-bottom: 0.5rem;
transition: var(--transition);
}
.todo-item:hover {
box-shadow: var(--shadow-sm);
}
.todo-checkbox-label {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
cursor: pointer;
}
.todo-checkbox {
width: 1.2rem;
height: 1.2rem;
cursor: pointer;
}
.todo-text {
font-size: 1rem;
color: var(--color-text);
transition: var(--transition);
}
.todo-item.completed .todo-text {
text-decoration: line-through;
color: var(--color-text-light);
}
.todo-delete-button {
padding: 0.5rem 1rem;
background: transparent;
color: var(--color-danger);
border: 1px solid var(--color-danger);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
transition: var(--transition);
}
.todo-delete-button:hover {
background: var(--color-danger);
color: white;
}
.todo-empty {
text-align: center;
color: var(--color-text-light);
padding: 2rem;
font-style: italic;
}
/* 响应式设计 */
@media (max-width: 768px) {
.app-main {
padding: 1rem;
}
.timer-display {
font-size: 4rem;
}
.timer-controls {
flex-direction: column;
}
.control-button {
width: 100%;
}
.todo-form {
flex-direction: column;
}
.todo-add-button {
width: 100%;
}
.todo-filters {
flex-wrap: wrap;
}
.filter-button {
flex: 1;
min-width: 80px;
}
}
/* 暗色模式支持(可选) */
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #1a1a1a;
--color-card: #2d2d2d;
--color-text: #e5e5e5;
--color-text-light: #a0a0a0;
--color-border: #404040;
}
}
/* 动画效果 */
@keyframes pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.02);
}
}
.timer-container.work.timer-pulse {
animation: pulse 2s ease-in-out infinite;
}
/* 打印样式 */
@media print {
.timer-controls,
.todo-form,
.todo-filters,
.todo-delete-button {
display: none;
}
}
运行说明
安装依赖
npm install
启动开发服务器
npm run dev
应用将在 http://localhost:5173 启动。
构建生产版本
npm run build
构建后的文件将输出到 dist 目录。
预览生产版本
npm run preview
功能特点
计时器功能
- 工作模式:25分钟倒计时
- 休息模式:5分钟倒计时
- 开始/暂停/重置控制
- 自动模式切换
- 完成提示音
- 可视化模式指示器
Todo List 功能
- 添加待办事项
- 标记完成/未完成
- 删除待办事项
- 过滤显示(全部/进行中/已完成)
- 实时计数显示
- localStorage 持久化存储
用户体验
- 响应式设计,支持移动端
- 平滑的过渡动画
- 暗色模式支持
- 键盘友好
- 清晰的视觉反馈
扩展建议
如果想要进一步扩展功能,可以考虑:
- 自定义时间设置:允许用户设置工作和休息时间
- 声音自定义:支持自定义提示音
- 统计功能:显示每日/每周/每月完成情况
- 番茄钟历史:记录每次番茄钟的开始和结束时间
- 标签分类:为待办事项添加标签
- 优先级设置:设置待办事项的优先级
- 通知功能:浏览器通知提醒
- 主题切换:支持多种颜色主题
- 数据导出:支持导出待办事项和统计数据
- 协作功能:支持多用户共享待办事项
技术要点
React Hooks 使用
useState:组件状态管理useEffect:副作用处理(计时器)useRef:存储计时器引用- 自定义 Hook
useLocalStorage:localStorage 持久化
性能优化
- 使用
useRef避免不必要的重新渲染 - 合理的组件拆分
- 事件处理函数的优化
代码组织
- 组件化设计
- 自定义 Hook 复用
- 清晰的目录结构
- 模块化样式
故障排除
localStorage 数据丢失
如果发现数据丢失,可能是:
- 浏览器隐私模式阻止了 localStorage
- 用户清除了浏览器数据
- 存储空间已满
计时器不准确
如果计时器不准确,可能是因为:
- 页面标签页被挂起(浏览器节能模式)
- 系统时间被修改
- 需要使用更精确的时间管理方案
样式显示问题
如果样式显示不正确,请检查:
- Vite 配置是否正确
- CSS 文件路径是否正确
- 浏览器缓存是否清除
总结
这个番茄钟项目展示了如何使用 React 18 和 Vite 构建一个功能完整的单页应用。通过本教程,您学会了:
- 使用 Vite 快速创建 React 项目
- 组件化开发思维
- React Hooks 的实际应用
- localStorage 数据持久化
- 响应式设计和样式管理
- 状态管理和事件处理
希望这个项目能帮助您更好地理解和应用 React 开发技术!