我们将创建一个具有以下功能的待办清单应用:
todo-app/
├── src/
│ ├── components/
│ │ ├── TodoApp.js # 主组件
│ │ ├── TodoForm.js # 输入表单组件
│ │ ├── TodoList.js # 列表组件
│ │ ├── TodoItem.js # 单个待办事项组件
│ │ ├── TodoFilter.js # 筛选组件
│ │ └── TodoStats.js # 统计组件
│ ├── hooks/
│ │ └── useLocalStorage.js # 自定义Hook
│ ├── utils/
│ │ └── constants.js # 常量定义
│ ├── App.js # 根组件
│ └── index.js # 入口文件
└── package.json
// App.js - 根组件
import React from 'react';
import TodoApp from './components/TodoApp';
import './App.css';
function App() {
return (
<div className="App">
<header className="App-header">
<h1><i className="fas fa-list-check"></i> React待办清单</h1>
<p>一个功能完整的React待办事项应用</p>
</header>
<main>
<TodoApp />
</main>
<footer>
<p>使用React Hooks构建 © 2023</p>
</footer>
</div>
);
}
export default App;
// index.js - 入口文件
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// utils/constants.js
export const FILTERS = {
ALL: 'all',
ACTIVE: 'active',
COMPLETED: 'completed'
};
export const LOCAL_STORAGE_KEY = 'react-todos';
// hooks/useLocalStorage.js
import { useState, useEffect } from 'react';
function useLocalStorage(key, initialValue) {
// 从localStorage读取初始值
const readValue = () => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.warn(`读取localStorage键"${key}"时出错:`, error);
return initialValue;
}
};
const [storedValue, setStoredValue] = useState(readValue);
// 当storedValue变化时,保存到localStorage
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(storedValue));
} catch (error) {
console.warn(`保存到localStorage键"${key}"时出错:`, error);
}
}, [key, storedValue]);
return [storedValue, setStoredValue];
}
export default useLocalStorage;
// components/TodoApp.js
import React, { useState } from 'react';
import useLocalStorage from '../hooks/useLocalStorage';
import TodoForm from './TodoForm';
import TodoList from './TodoList';
import TodoFilter from './TodoFilter';
import TodoStats from './TodoStats';
import { FILTERS, LOCAL_STORAGE_KEY } from '../utils/constants';
function TodoApp() {
// 使用自定义Hook管理待办事项
const [todos, setTodos] = useLocalStorage(LOCAL_STORAGE_KEY, []);
const [filter, setFilter] = useState(FILTERS.ALL);
// 添加待办事项
const addTodo = (text) => {
if (!text.trim()) return;
const newTodo = {
id: Date.now(),
text: text.trim(),
completed: false,
createdAt: new Date().toISOString()
};
setTodos([newTodo, ...todos]);
};
// 切换待办事项状态
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 updateTodo = (id, newText) => {
if (!newText.trim()) {
deleteTodo(id);
return;
}
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, text: newText.trim() } : todo
));
};
// 清除所有已完成事项
const clearCompleted = () => {
setTodos(todos.filter(todo => !todo.completed));
};
// 根据筛选条件过滤待办事项
const filteredTodos = todos.filter(todo => {
switch (filter) {
case FILTERS.ACTIVE:
return !todo.completed;
case FILTERS.COMPLETED:
return todo.completed;
default:
return true;
}
});
return (
<div className="todo-app">
<TodoForm onAdd={addTodo} />
<TodoFilter
currentFilter={filter}
onFilterChange={setFilter}
onClearCompleted={clearCompleted}
hasCompleted={todos.some(todo => todo.completed)}
/>
<TodoList
todos={filteredTodos}
onToggle={toggleTodo}
onDelete={deleteTodo}
onUpdate={updateTodo}
/>
<TodoStats todos={todos} />
</div>
);
}
export default TodoApp;
// components/TodoForm.js
import React, { useState } from 'react';
function TodoForm({ onAdd }) {
const [input, setInput] = useState('');
const [error, setError] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (!input.trim()) {
setError('请输入待办事项内容');
return;
}
onAdd(input);
setInput('');
setError('');
};
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
handleSubmit(e);
}
};
return (
<form onSubmit={handleSubmit} className="todo-form">
<div className="input-group">
<input
type="text"
value={input}
onChange={(e) => {
setInput(e.target.value);
setError('');
}}
onKeyDown={handleKeyDown}
placeholder="请输入待办事项..."
className={`form-control ${error ? 'is-invalid' : ''}`}
autoFocus
/>
<button
type="submit"
className="btn btn-primary"
disabled={!input.trim()}
>
<i className="fas fa-plus"></i> 添加
</button>
</div>
{error && <div className="invalid-feedback d-block">{error}</div>}
<small className="form-text text-muted">
按Enter键快速添加
</small>
</form>
);
}
export default TodoForm;
// components/TodoList.js
import React from 'react';
import TodoItem from './TodoItem';
function TodoList({ todos, onToggle, onDelete, onUpdate }) {
if (todos.length === 0) {
return (
<div className="todo-empty">
<i className="fas fa-clipboard-list fa-3x"></i>
<h4>暂无待办事项</h4>
<p>添加你的第一个待办事项吧!</p>
</div>
);
}
return (
<div className="todo-list">
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={onToggle}
onDelete={onDelete}
onUpdate={onUpdate}
/>
))}
</div>
);
}
export default TodoList;
// components/TodoItem.js
import React, { useState } from 'react';
function TodoItem({ todo, onToggle, onDelete, onUpdate }) {
const [isEditing, setIsEditing] = useState(false);
const [editText, setEditText] = useState(todo.text);
const handleEdit = () => {
setIsEditing(true);
};
const handleSave = () => {
onUpdate(todo.id, editText);
setIsEditing(false);
};
const handleCancel = () => {
setEditText(todo.text);
setIsEditing(false);
};
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
handleSave();
} else if (e.key === 'Escape') {
handleCancel();
}
};
return (
<div className={`todo-item ${todo.completed ? 'completed' : ''}`}>
{isEditing ? (
<div className="todo-edit">
<input
type="text"
value={editText}
onChange={(e) => setEditText(e.target.value)}
onKeyDown={handleKeyDown}
className="form-control"
autoFocus
/>
<div className="todo-edit-actions">
<button
onClick={handleSave}
className="btn btn-sm btn-success"
disabled={!editText.trim()}
>
<i className="fas fa-check"></i>
</button>
<button
onClick={handleCancel}
className="btn btn-sm btn-secondary"
>
<i className="fas fa-times"></i>
</button>
</div>
</div>
) : (
<>
<div className="todo-content">
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
className="todo-checkbox"
/>
<span className="todo-text" onClick={() => onToggle(todo.id)}>
{todo.text}
</span>
<small className="todo-date">
{new Date(todo.createdAt).toLocaleDateString()}
</small>
</div>
<div className="todo-actions">
<button
onClick={handleEdit}
className="btn btn-sm btn-outline-primary"
title="编辑"
>
<i className="fas fa-edit"></i>
</button>
<button
onClick={() => onDelete(todo.id)}
className="btn btn-sm btn-outline-danger"
title="删除"
>
<i className="fas fa-trash"></i>
</button>
</div>
</>
)}
</div>
);
}
export default TodoItem;
// components/TodoFilter.js
import React from 'react';
import { FILTERS } from '../utils/constants';
function TodoFilter({ currentFilter, onFilterChange, onClearCompleted, hasCompleted }) {
const filters = [
{ key: FILTERS.ALL, label: '全部' },
{ key: FILTERS.ACTIVE, label: '进行中' },
{ key: FILTERS.COMPLETED, label: '已完成' }
];
return (
<div className="todo-filter">
<div className="filter-buttons">
{filters.map(({ key, label }) => (
<button
key={key}
onClick={() => onFilterChange(key)}
className={`btn btn-sm ${
currentFilter === key ? 'btn-primary' : 'btn-outline-primary'
}`}
>
{label}
</button>
))}
</div>
{hasCompleted && (
<button
onClick={onClearCompleted}
className="btn btn-sm btn-outline-danger"
>
<i className="fas fa-trash"></i> 清除已完成
</button>
)}
</div>
);
}
export default TodoFilter;
// components/TodoStats.js
import React from 'react';
function TodoStats({ todos }) {
const total = todos.length;
const completed = todos.filter(todo => todo.completed).length;
const pending = total - completed;
const completionRate = total > 0 ? Math.round((completed / total) * 100) : 0;
return (
<div className="todo-stats">
<div className="stats-grid">
<div className="stat-item">
<div className="stat-label">总计</div>
<div className="stat-value">{total}</div>
</div>
<div className="stat-item">
<div className="stat-label">进行中</div>
<div className="stat-value text-warning">{pending}</div>
</div>
<div className="stat-item">
<div className="stat-label">已完成</div>
<div className="stat-value text-success">{completed}</div>
</div>
<div className="stat-item">
<div className="stat-label">完成率</div>
<div className="stat-value">
{completionRate}%
<div className="progress" style={{ height: '5px', marginTop: '5px' }}>
<div
className="progress-bar bg-success"
style={{ width: `${completionRate}%` }}
></div>
</div>
</div>
</div>
</div>
</div>
);
}
export default TodoStats;
/* App.css */
.App {
max-width: 800px;
margin: 0 auto;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
}
.App-header {
text-align: center;
margin-bottom: 30px;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 10px;
}
.App-header h1 {
margin: 0;
font-size: 2.5rem;
}
.todo-app {
background: white;
border-radius: 10px;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.1);
padding: 30px;
margin-bottom: 30px;
}
.todo-form {
margin-bottom: 30px;
}
.todo-form .input-group {
margin-bottom: 10px;
}
.todo-list {
margin: 20px 0;
}
.todo-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
margin-bottom: 10px;
background: #f8f9fa;
border-radius: 8px;
border-left: 4px solid #007bff;
transition: all 0.3s ease;
}
.todo-item.completed {
border-left-color: #28a745;
background: #f0fff4;
}
.todo-item.completed .todo-text {
text-decoration: line-through;
color: #6c757d;
}
.todo-content {
flex: 1;
display: flex;
align-items: center;
gap: 15px;
}
.todo-checkbox {
width: 20px;
height: 20px;
cursor: pointer;
}
.todo-text {
flex: 1;
cursor: pointer;
font-size: 1.1rem;
}
.todo-date {
color: #6c757d;
font-size: 0.85rem;
}
.todo-actions {
display: flex;
gap: 5px;
}
.todo-edit {
display: flex;
gap: 10px;
flex: 1;
}
.todo-edit input {
flex: 1;
}
.todo-edit-actions {
display: flex;
gap: 5px;
}
.todo-empty {
text-align: center;
padding: 40px 20px;
color: #6c757d;
}
.todo-empty i {
color: #dee2e6;
margin-bottom: 15px;
}
.todo-filter {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
}
.filter-buttons {
display: flex;
gap: 10px;
}
.todo-stats {
margin-top: 30px;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 20px;
}
.stat-item {
text-align: center;
padding: 15px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
}
.stat-label {
font-size: 0.9rem;
color: #6c757d;
margin-bottom: 5px;
}
.stat-value {
font-size: 2rem;
font-weight: bold;
}
footer {
text-align: center;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #dee2e6;
color: #6c757d;
}
/* 响应式设计 */
@media (max-width: 768px) {
.App {
padding: 10px;
}
.todo-app {
padding: 20px;
}
.todo-item {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.todo-actions {
justify-content: flex-end;
}
.todo-filter {
flex-direction: column;
gap: 15px;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
// components/AnimatedTodoItem.js
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
function AnimatedTodoItem({ todo, onToggle, onDelete, onUpdate }) {
const [isEditing, setIsEditing] = useState(false);
return (
<motion.div
className={`todo-item ${todo.completed ? 'completed' : ''}`}
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: -100 }}
transition={{ duration: 0.3 }}
layout
>
<AnimatePresence>
{isEditing ? (
<motion.div
className="todo-edit"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{/* 编辑模式内容 */}
</motion.div>
) : (
<motion.div
className="todo-content"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{/* 展示模式内容 */}
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}
// hooks/useKeyboardShortcuts.js
import { useEffect } from 'react';
function useKeyboardShortcuts(onAdd, onClearCompleted) {
useEffect(() => {
const handleKeyDown = (e) => {
// Ctrl + N: 聚焦到输入框
if (e.ctrlKey && e.key === 'n') {
e.preventDefault();
document.querySelector('.todo-form input')?.focus();
}
// Ctrl + D: 清除已完成
if (e.ctrlKey && e.key === 'd') {
e.preventDefault();
onClearCompleted();
}
// Ctrl + A: 全选/取消全选
if (e.ctrlKey && e.key === 'a') {
e.preventDefault();
// 实现全选逻辑
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onAdd, onClearCompleted]);
}
// 使用react-beautiful-dnd
import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd';
function DraggableTodoList({ todos, onReorder }) {
const handleDragEnd = (result) => {
if (!result.destination) return;
const reorderedTodos = Array.from(todos);
const [removed] = reorderedTodos.splice(result.source.index, 1);
reorderedTodos.splice(result.destination.index, 0, removed);
onReorder(reorderedTodos);
};
return (
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="todos">
{(provided) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
className="todo-list"
>
{todos.map((todo, index) => (
<Draggable key={todo.id} draggableId={todo.id.toString()} index={index}>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<TodoItem todo={todo} />
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
);
}
# 1. 安装依赖
npm install
# 2. 开发模式运行
npm start
# 3. 构建生产版本
npm run build
# 4. 部署到GitHub Pages
npm run deploy
# 5. 部署到Vercel
vercel
# 6. 部署到Netlify
netlify deploy --prod