附录 B:Pomodoro 番茄钟项目完整代码

⏱ Est. reading time: 20 min Updated on 5/15/2026

本附录包含教程中构建的番茄钟应用程序的完整源代码。

项目概述

技术栈: 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 持久化存储

用户体验

  • 响应式设计,支持移动端
  • 平滑的过渡动画
  • 暗色模式支持
  • 键盘友好
  • 清晰的视觉反馈

扩展建议

如果想要进一步扩展功能,可以考虑:

  1. 自定义时间设置:允许用户设置工作和休息时间
  2. 声音自定义:支持自定义提示音
  3. 统计功能:显示每日/每周/每月完成情况
  4. 番茄钟历史:记录每次番茄钟的开始和结束时间
  5. 标签分类:为待办事项添加标签
  6. 优先级设置:设置待办事项的优先级
  7. 通知功能:浏览器通知提醒
  8. 主题切换:支持多种颜色主题
  9. 数据导出:支持导出待办事项和统计数据
  10. 协作功能:支持多用户共享待办事项

技术要点

React Hooks 使用

  • useState:组件状态管理
  • useEffect:副作用处理(计时器)
  • useRef:存储计时器引用
  • 自定义 Hook useLocalStorage:localStorage 持久化

性能优化

  • 使用 useRef 避免不必要的重新渲染
  • 合理的组件拆分
  • 事件处理函数的优化

代码组织

  • 组件化设计
  • 自定义 Hook 复用
  • 清晰的目录结构
  • 模块化样式

故障排除

localStorage 数据丢失

如果发现数据丢失,可能是:

  • 浏览器隐私模式阻止了 localStorage
  • 用户清除了浏览器数据
  • 存储空间已满

计时器不准确

如果计时器不准确,可能是因为:

  • 页面标签页被挂起(浏览器节能模式)
  • 系统时间被修改
  • 需要使用更精确的时间管理方案

样式显示问题

如果样式显示不正确,请检查:

  • Vite 配置是否正确
  • CSS 文件路径是否正确
  • 浏览器缓存是否清除

总结

这个番茄钟项目展示了如何使用 React 18 和 Vite 构建一个功能完整的单页应用。通过本教程,您学会了:

  1. 使用 Vite 快速创建 React 项目
  2. 组件化开发思维
  3. React Hooks 的实际应用
  4. localStorage 数据持久化
  5. 响应式设计和样式管理
  6. 状态管理和事件处理

希望这个项目能帮助您更好地理解和应用 React 开发技术!