Project_TodoApp.jsx
React.js/lessons/05_state_useState/projects/Project_TodoApp.jsx
/**
* Project: 待辦事項 App — useState 練習
*
* 學習重點:
* - useState 管理多種狀態(字串、陣列、物件)
* - 不可變性更新(新增、刪除、修改陣列中的物件)
* - 函式形式的 setState
* - 派生狀態(過濾、統計)
*
* 使用方式:將此檔案內容複製到你的 App.jsx 中
*/
import { useState } from 'react';
function TodoApp() {
const [todos, setTodos] = useState([
{ id: 1, text: '學習 React 基礎', done: true, priority: 'high', createdAt: '2024-01-10' },
{ id: 2, text: '練習 useState Hook', done: false, priority: 'high', createdAt: '2024-01-11' },
{ id: 3, text: '閱讀 React 官方文件', done: false, priority: 'medium', createdAt: '2024-01-12' },
{ id: 4, text: '建立第一個專案', done: false, priority: 'low', createdAt: '2024-01-13' },
]);
const [inputValue, setInputValue] = useState('');
const [priority, setPriority] = useState('medium');
const [filter, setFilter] = useState('all');
const [editingId, setEditingId] = useState(null);
const [editText, setEditText] = useState('');
// 新增
const addTodo = () => {
if (!inputValue.trim()) return;
setTodos((prev) => [
...prev,
{
id: Date.now(),
text: inputValue.trim(),
done: false,
priority,
createdAt: new Date().toISOString().split('T')[0],
},
]);
setInputValue('');
};
// 切換完成
const toggleTodo = (id) => {
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
);
};
// 刪除
const deleteTodo = (id) => {
setTodos((prev) => prev.filter((t) => t.id !== id));
};
// 開始編輯
const startEditing = (todo) => {
setEditingId(todo.id);
setEditText(todo.text);
};
// 儲存編輯
const saveEdit = (id) => {
if (!editText.trim()) return;
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, text: editText.trim() } : t))
);
setEditingId(null);
setEditText('');
};
// 清除已完成
const clearCompleted = () => {
setTodos((prev) => prev.filter((t) => !t.done));
};
// 全部標為完成
const toggleAll = () => {
const allDone = todos.every((t) => t.done);
setTodos((prev) => prev.map((t) => ({ ...t, done: !allDone })));
};
// 過濾
const filteredTodos = todos.filter((t) => {
if (filter === 'active') return !t.done;
if (filter === 'completed') return t.done;
return true;
});
// 統計
const stats = {
total: todos.length,
active: todos.filter((t) => !t.done).length,
completed: todos.filter((t) => t.done).length,
};
const priorityConfig = {
high: { label: '高', color: '#ef4444', bg: '#fef2f2' },
medium: { label: '中', color: '#f59e0b', bg: '#fffbeb' },
low: { label: '低', color: '#22c55e', bg: '#f0fdf4' },
};
return (
<div style={{
maxWidth: '580px',
margin: '32px auto',
fontFamily: "'Segoe UI', 'Noto Sans TC', sans-serif",
padding: '0 16px',
}}>
{/* 標題 */}
<div style={{ textAlign: 'center', marginBottom: '32px' }}>
<h1 style={{ margin: '0 0 4px', color: '#0f172a', fontSize: '2rem' }}>
Todo List
</h1>
<p style={{ margin: 0, color: '#94a3b8', fontSize: '0.9rem' }}>
{stats.active} 項待完成 · {stats.completed} 項已完成
</p>
</div>
{/* 輸入區 */}
<div style={{
display: 'flex',
gap: '8px',
marginBottom: '20px',
backgroundColor: '#f8fafc',
padding: '12px',
borderRadius: '12px',
}}>
<input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addTodo()}
placeholder="新增待辦事項..."
style={{
flex: 1,
padding: '10px 14px',
border: '2px solid #e2e8f0',
borderRadius: '8px',
fontSize: '0.95rem',
outline: 'none',
}}
/>
<select
value={priority}
onChange={(e) => setPriority(e.target.value)}
style={{
padding: '8px',
border: '2px solid #e2e8f0',
borderRadius: '8px',
backgroundColor: 'white',
cursor: 'pointer',
}}
>
<option value="high">高</option>
<option value="medium">中</option>
<option value="low">低</option>
</select>
<button
onClick={addTodo}
disabled={!inputValue.trim()}
style={{
padding: '10px 20px',
backgroundColor: inputValue.trim() ? '#4f46e5' : '#cbd5e1',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: inputValue.trim() ? 'pointer' : 'not-allowed',
fontWeight: 'bold',
fontSize: '0.95rem',
}}
>
新增
</button>
</div>
{/* 篩選列 */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px',
}}>
<div style={{ display: 'flex', gap: '4px' }}>
{[
{ key: 'all', label: '全部' },
{ key: 'active', label: '未完成' },
{ key: 'completed', label: '已完成' },
].map((f) => (
<button
key={f.key}
onClick={() => setFilter(f.key)}
style={{
padding: '6px 14px',
borderRadius: '8px',
border: 'none',
backgroundColor: filter === f.key ? '#4f46e5' : 'transparent',
color: filter === f.key ? 'white' : '#64748b',
cursor: 'pointer',
fontSize: '0.85rem',
fontWeight: filter === f.key ? '600' : '400',
}}
>
{f.label}
</button>
))}
</div>
<div style={{ display: 'flex', gap: '8px' }}>
{stats.total > 0 && (
<button
onClick={toggleAll}
style={{
padding: '6px 12px',
border: '1px solid #e2e8f0',
borderRadius: '8px',
backgroundColor: 'white',
color: '#64748b',
cursor: 'pointer',
fontSize: '0.8rem',
}}
>
{todos.every((t) => t.done) ? '取消全選' : '全選'}
</button>
)}
{stats.completed > 0 && (
<button
onClick={clearCompleted}
style={{
padding: '6px 12px',
border: '1px solid #fca5a5',
borderRadius: '8px',
backgroundColor: 'white',
color: '#ef4444',
cursor: 'pointer',
fontSize: '0.8rem',
}}
>
清除已完成
</button>
)}
</div>
</div>
{/* 待辦列表 */}
<div>
{filteredTodos.length === 0 ? (
<div style={{ textAlign: 'center', padding: '48px 0', color: '#cbd5e1' }}>
<p style={{ fontSize: '2.5rem', margin: '0 0 8px' }}>📋</p>
<p style={{ margin: 0 }}>
{filter === 'all' ? '還沒有待辦事項' : '沒有符合篩選的項目'}
</p>
</div>
) : (
filteredTodos.map((todo) => (
<div
key={todo.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '12px 16px',
backgroundColor: 'white',
borderRadius: '10px',
marginBottom: '8px',
border: '1px solid #f1f5f9',
opacity: todo.done ? 0.6 : 1,
transition: 'all 0.2s',
}}
>
{/* Checkbox */}
<input
type="checkbox"
checked={todo.done}
onChange={() => toggleTodo(todo.id)}
style={{ width: '18px', height: '18px', cursor: 'pointer', accentColor: '#4f46e5' }}
/>
{/* 優先級標籤 */}
<span
style={{
padding: '2px 8px',
borderRadius: '6px',
backgroundColor: priorityConfig[todo.priority].bg,
color: priorityConfig[todo.priority].color,
fontSize: '0.7rem',
fontWeight: 'bold',
flexShrink: 0,
}}
>
{priorityConfig[todo.priority].label}
</span>
{/* 文字 / 編輯 */}
{editingId === todo.id ? (
<input
value={editText}
onChange={(e) => setEditText(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && saveEdit(todo.id)}
onBlur={() => saveEdit(todo.id)}
autoFocus
style={{
flex: 1,
padding: '4px 8px',
border: '2px solid #818cf8',
borderRadius: '6px',
fontSize: '0.9rem',
outline: 'none',
}}
/>
) : (
<span
onDoubleClick={() => startEditing(todo)}
style={{
flex: 1,
textDecoration: todo.done ? 'line-through' : 'none',
color: todo.done ? '#94a3b8' : '#1e293b',
cursor: 'text',
fontSize: '0.95rem',
}}
title="雙擊編輯"
>
{todo.text}
</span>
)}
{/* 日期 */}
<span style={{ color: '#cbd5e1', fontSize: '0.75rem', flexShrink: 0 }}>
{todo.createdAt}
</span>
{/* 刪除 */}
<button
onClick={() => deleteTodo(todo.id)}
style={{
background: 'none',
border: 'none',
color: '#e2e8f0',
cursor: 'pointer',
fontSize: '1.1rem',
padding: '0 4px',
flexShrink: 0,
}}
onMouseEnter={(e) => (e.target.style.color = '#ef4444')}
onMouseLeave={(e) => (e.target.style.color = '#e2e8f0')}
>
×
</button>
</div>
))
)}
</div>
{/* 進度條 */}
{stats.total > 0 && (
<div style={{ marginTop: '24px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.8rem', color: '#94a3b8', marginBottom: '6px' }}>
<span>完成進度</span>
<span>{Math.round((stats.completed / stats.total) * 100)}%</span>
</div>
<div style={{ height: '6px', backgroundColor: '#f1f5f9', borderRadius: '3px', overflow: 'hidden' }}>
<div
style={{
height: '100%',
width: `${(stats.completed / stats.total) * 100}%`,
backgroundColor: stats.completed === stats.total ? '#22c55e' : '#4f46e5',
borderRadius: '3px',
transition: 'width 0.3s ease',
}}
/>
</div>
</div>
)}
</div>
);
}
export default TodoApp;
Artículos relacionados
App.js
App.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/App.js).
Leer artículo →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).
Leer artículo →index.js
index.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/index.js).
Leer artículo →reportWebVitals.js
reportWebVitals.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/reportWebVitals.js).
Leer artículo →setupTests.js
setupTests.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/setupTests.js).
Leer artículo →Project_HelloReact.jsx
Project_HelloReact.jsx — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/projects/Project_HelloReact.jsx).
Leer artículo →