Project_PomodoroTimer.jsx
React.js/lessons/10_useEffect/projects/Project_PomodoroTimer.jsx
/**
* Project: 番茄鐘計時器 — useEffect 練習
*
* 學習重點:
* - useEffect 搭配 setInterval(計時器)
* - 清除函式(clearInterval)
* - 依賴陣列的正確使用
* - 修改 document.title(副作用)
* - useEffect 中的條件邏輯
*
* 使用方式:將此檔案內容複製到你的 App.jsx 中
*/
import { useState, useEffect } from 'react';
function PomodoroTimer() {
const [mode, setMode] = useState('work'); // 'work' | 'shortBreak' | 'longBreak'
const [timeLeft, setTimeLeft] = useState(25 * 60);
const [isRunning, setIsRunning] = useState(false);
const [completedPomodoros, setCompletedPomodoros] = useState(0);
const [sessions, setSessions] = useState([]);
const durations = {
work: 25 * 60,
shortBreak: 5 * 60,
longBreak: 15 * 60,
};
const modeConfig = {
work: { label: '專注', color: '#ef4444', bg: '#fef2f2', icon: '🍅' },
shortBreak: { label: '短休息', color: '#22c55e', bg: '#f0fdf4', icon: '☕' },
longBreak: { label: '長休息', color: '#3b82f6', bg: '#eff6ff', icon: '🌴' },
};
// 主要計時器 Effect
useEffect(() => {
if (!isRunning) return;
const intervalId = setInterval(() => {
setTimeLeft((prev) => {
if (prev <= 1) {
setIsRunning(false);
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(intervalId);
}, [isRunning]);
// 時間到的 Effect
useEffect(() => {
if (timeLeft === 0 && !isRunning) {
if (mode === 'work') {
const newCount = completedPomodoros + 1;
setCompletedPomodoros(newCount);
setSessions((prev) => [
{ time: new Date().toLocaleTimeString('zh-TW', { hour: '2-digit', minute: '2-digit' }), type: 'work' },
...prev,
]);
// 每 4 個番茄鐘後長休息
if (newCount % 4 === 0) {
switchMode('longBreak');
} else {
switchMode('shortBreak');
}
} else {
switchMode('work');
}
// 通知
try {
if (Notification.permission === 'granted') {
new Notification(`${modeConfig[mode].label}結束!`);
}
} catch {}
}
}, [timeLeft, isRunning]);
// 更新頁面標題
useEffect(() => {
const config = modeConfig[mode];
if (isRunning) {
document.title = `${formatTime(timeLeft)} - ${config.label} | 番茄鐘`;
} else {
document.title = '番茄鐘計時器';
}
return () => {
document.title = '番茄鐘計時器';
};
}, [timeLeft, isRunning, mode]);
// 鍵盤快捷鍵
useEffect(() => {
const handleKeyDown = (e) => {
if (e.code === 'Space') {
e.preventDefault();
setIsRunning((prev) => !prev);
}
if (e.code === 'KeyR') {
resetTimer();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [mode]);
const switchMode = (newMode) => {
setMode(newMode);
setTimeLeft(durations[newMode]);
setIsRunning(false);
};
const resetTimer = () => {
setTimeLeft(durations[mode]);
setIsRunning(false);
};
const formatTime = (seconds) => {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
};
const progress = 1 - timeLeft / durations[mode];
const config = modeConfig[mode];
const circumference = 2 * Math.PI * 140;
const strokeDashoffset = circumference * (1 - progress);
return (
<div style={{
minHeight: '100vh',
backgroundColor: config.bg,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
fontFamily: "'Segoe UI', sans-serif",
padding: '20px',
transition: 'background-color 0.5s',
}}>
<h1 style={{ color: '#0f172a', marginBottom: '8px', fontSize: '1.8rem' }}>
{config.icon} 番茄鐘
</h1>
<p style={{ color: '#94a3b8', margin: '0 0 32px' }}>
已完成 {completedPomodoros} 個番茄鐘
</p>
{/* 模式切換 */}
<div style={{ display: 'flex', gap: '8px', marginBottom: '32px', backgroundColor: 'rgba(255,255,255,0.8)', padding: '4px', borderRadius: '12px' }}>
{Object.entries(modeConfig).map(([key, cfg]) => (
<button
key={key}
onClick={() => switchMode(key)}
style={{
padding: '8px 20px', borderRadius: '10px', border: 'none',
backgroundColor: mode === key ? cfg.color : 'transparent',
color: mode === key ? 'white' : '#64748b',
cursor: 'pointer', fontWeight: mode === key ? 'bold' : 'normal',
fontSize: '0.9rem', transition: 'all 0.2s',
}}
>
{cfg.icon} {cfg.label}
</button>
))}
</div>
{/* 圓形計時器 */}
<div style={{ position: 'relative', width: '300px', height: '300px', marginBottom: '32px' }}>
<svg width="300" height="300" viewBox="0 0 300 300" style={{ transform: 'rotate(-90deg)' }}>
<circle cx="150" cy="150" r="140" fill="none" stroke="#e2e8f0" strokeWidth="8" />
<circle
cx="150" cy="150" r="140"
fill="none"
stroke={config.color}
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
style={{ transition: 'stroke-dashoffset 0.5s linear' }}
/>
</svg>
<div style={{
position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
textAlign: 'center',
}}>
<div style={{ fontSize: '4rem', fontWeight: 'bold', color: '#0f172a', fontFamily: 'monospace', letterSpacing: '4px' }}>
{formatTime(timeLeft)}
</div>
<div style={{ color: config.color, fontWeight: '600', fontSize: '1rem', marginTop: '4px' }}>
{config.label}
</div>
</div>
</div>
{/* 控制按鈕 */}
<div style={{ display: 'flex', gap: '12px', marginBottom: '32px' }}>
<button
onClick={() => setIsRunning(!isRunning)}
style={{
padding: '14px 40px', borderRadius: '14px', border: 'none',
backgroundColor: isRunning ? '#64748b' : config.color,
color: 'white', cursor: 'pointer', fontWeight: 'bold',
fontSize: '1.1rem', minWidth: '140px', transition: 'background-color 0.2s',
}}
>
{isRunning ? '⏸ 暫停' : '▶ 開始'}
</button>
<button
onClick={resetTimer}
style={{
padding: '14px 24px', borderRadius: '14px',
border: '2px solid #e2e8f0', backgroundColor: 'white',
color: '#64748b', cursor: 'pointer', fontSize: '1rem',
}}
>
↻ 重置
</button>
</div>
{/* 番茄鐘進度 */}
<div style={{ display: 'flex', gap: '8px', marginBottom: '24px' }}>
{[1, 2, 3, 4].map((i) => (
<div
key={i}
style={{
width: '24px', height: '24px', borderRadius: '50%',
backgroundColor: completedPomodoros % 4 >= i ? config.color : '#e2e8f0',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '0.7rem', color: 'white', transition: 'background-color 0.3s',
}}
>
{completedPomodoros % 4 >= i ? '✓' : i}
</div>
))}
<span style={{ color: '#94a3b8', fontSize: '0.8rem', marginLeft: '4px' }}>
→ 長休息
</span>
</div>
{/* 歷史記錄 */}
{sessions.length > 0 && (
<div style={{
backgroundColor: 'rgba(255,255,255,0.8)', borderRadius: '12px', padding: '16px',
width: '100%', maxWidth: '300px',
}}>
<h3 style={{ margin: '0 0 8px', fontSize: '0.9rem', color: '#475569' }}>
今日記錄
</h3>
{sessions.slice(0, 8).map((s, i) => (
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0', fontSize: '0.8rem', color: '#64748b' }}>
<span>🍅 完成一個番茄鐘</span>
<span>{s.time}</span>
</div>
))}
</div>
)}
<p style={{ color: '#cbd5e1', fontSize: '0.75rem', marginTop: '24px' }}>
快捷鍵:Space 開始/暫停 · R 重置
</p>
</div>
);
}
export default PomodoroTimer;
Articles liés
App.js
App.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/App.js).
Lire l'article →App.test.js
App.test.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/App.test.js).
Lire l'article →index.js
index.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/index.js).
Lire l'article →reportWebVitals.js
reportWebVitals.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/reportWebVitals.js).
Lire l'article →setupTests.js
setupTests.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/setupTests.js).
Lire l'article →Project_HelloReact.jsx
Project_HelloReact.jsx — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/projects/Project_HelloReact.jsx).
Lire l'article →