本章将带领您完成一个完整的待办事项(Todo List)应用,这个项目将综合运用前面学到的所有知识,包括 HTTP 协议、Web 框架、前后端交互等。
- 掌握完整的 Web 应用开发流程
- 实践前后端分离架构
- 学会处理用户交互和数据持久化
- 理解错误处理和用户体验优化
-
用户功能
- 添加新的待办事项
- 查看所有待办事项列表
- 标记待办事项为已完成/未完成
- 删除待办事项
- 编辑待办事项内容
-
技术特性
- 前后端分离架构
- RESTful API 设计
- 实时数据更新
- 响应式界面设计
- 数据持久化存储
后端:
- FastAPI(现代、高性能、自动文档)
- SQLite(轻量级数据库)
- Pydantic(数据验证)
前端:
- 原生 HTML/CSS/JavaScript
- Fetch API(HTTP 请求)
- LocalStorage(本地存储)
todo-app/
├── backend/
│ ├── main.py # FastAPI 应用
│ ├── models.py # 数据模型
│ ├── database.py # 数据库配置
│ └── requirements.txt # 依赖包
├── frontend/
│ ├── index.html # 主页面
│ ├── style.css # 样式文件
│ └── script.js # JavaScript 逻辑
└── README.md # 项目说明
# 创建项目目录
mkdir todo-app
cd todo-app
# 创建后端目录
mkdir backend
cd backend
# 创建虚拟环境
python -m venv venv
source venv/bin/activate # Linux/Mac
# venv\Scripts\activate # Windows
# 安装依赖
pip install fastapi uvicorn sqlalchemy pydanticbackend/requirements.txt:
fastapi==0.104.1
uvicorn[standard]==0.24.0
sqlalchemy==2.0.23
pydantic==2.5.0backend/database.py:
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
# 创建数据库引擎
SQLALCHEMY_DATABASE_URL = "sqlite:///./todos.db"
engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
)
# 创建会话工厂
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# 创建基础类
Base = declarative_base()
# 依赖注入:获取数据库会话
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()backend/models.py:
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.sql import func
from database import Base
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
# SQLAlchemy 模型(数据库表结构)
class TodoDB(Base):
__tablename__ = "todos"
id = Column(Integer, primary_key=True, index=True)
title = Column(String, index=True)
description = Column(String, nullable=True)
completed = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Pydantic 模型(API 数据验证)
class TodoBase(BaseModel):
title: str
description: Optional[str] = None
class TodoCreate(TodoBase):
pass
class TodoUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
completed: Optional[bool] = None
class Todo(TodoBase):
id: int
completed: bool
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = Truebackend/main.py:
from fastapi import FastAPI, HTTPException, Depends
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session
from typing import List
import models
import database
# 创建数据库表
models.Base.metadata.create_all(bind=database.engine)
app = FastAPI(title="Todo API", version="1.0.0")
# 配置 CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://127.0.0.1:5500"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# API 路由
@app.get("/")
async def root():
return {"message": "Todo API is running!"}
@app.get("/todos", response_model=List[models.Todo])
async def get_todos(db: Session = Depends(database.get_db)):
"""获取所有待办事项"""
todos = db.query(models.TodoDB).all()
return todos
@app.post("/todos", response_model=models.Todo)
async def create_todo(todo: models.TodoCreate, db: Session = Depends(database.get_db)):
"""创建新的待办事项"""
db_todo = models.TodoDB(**todo.dict())
db.add(db_todo)
db.commit()
db.refresh(db_todo)
return db_todo
@app.get("/todos/{todo_id}", response_model=models.Todo)
async def get_todo(todo_id: int, db: Session = Depends(database.get_db)):
"""获取单个待办事项"""
todo = db.query(models.TodoDB).filter(models.TodoDB.id == todo_id).first()
if todo is None:
raise HTTPException(status_code=404, detail="Todo not found")
return todo
@app.put("/todos/{todo_id}", response_model=models.Todo)
async def update_todo(todo_id: int, todo_update: models.TodoUpdate, db: Session = Depends(database.get_db)):
"""更新待办事项"""
db_todo = db.query(models.TodoDB).filter(models.TodoDB.id == todo_id).first()
if db_todo is None:
raise HTTPException(status_code=404, detail="Todo not found")
update_data = todo_update.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(db_todo, field, value)
db.commit()
db.refresh(db_todo)
return db_todo
@app.delete("/todos/{todo_id}")
async def delete_todo(todo_id: int, db: Session = Depends(database.get_db)):
"""删除待办事项"""
db_todo = db.query(models.TodoDB).filter(models.TodoDB.id == todo_id).first()
if db_todo is None:
raise HTTPException(status_code=404, detail="Todo not found")
db.delete(db_todo)
db.commit()
return {"message": "Todo deleted successfully"}
@app.patch("/todos/{todo_id}/toggle")
async def toggle_todo(todo_id: int, db: Session = Depends(database.get_db)):
"""切换待办事项完成状态"""
db_todo = db.query(models.TodoDB).filter(models.TodoDB.id == todo_id).first()
if db_todo is None:
raise HTTPException(status_code=404, detail="Todo not found")
db_todo.completed = not db_todo.completed
db.commit()
db.refresh(db_todo)
return db_todo# 在 backend 目录下运行
uvicorn main:app --reload --host 0.0.0.0 --port 8000访问 http://localhost:8000/docs 查看自动生成的 API 文档。
frontend/index.html:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>待办事项应用</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<header>
<h1>📝 待办事项</h1>
<p>管理您的任务和计划</p>
</header>
<!-- 添加新任务表单 -->
<div class="add-todo">
<form id="todoForm">
<div class="form-group">
<input type="text" id="todoTitle" placeholder="输入任务标题..." required>
</div>
<div class="form-group">
<textarea id="todoDescription" placeholder="任务描述(可选)..."></textarea>
</div>
<button type="submit" class="btn btn-primary">添加任务</button>
</form>
</div>
<!-- 任务统计 -->
<div class="todo-stats">
<span id="totalCount">总计: 0</span>
<span id="completedCount">已完成: 0</span>
<span id="pendingCount">待完成: 0</span>
</div>
<!-- 任务列表 -->
<div class="todo-list" id="todoList">
<!-- 任务项将在这里动态生成 -->
</div>
<!-- 加载状态 -->
<div id="loading" class="loading" style="display: none;">
<div class="spinner"></div>
<p>加载中...</p>
</div>
<!-- 错误提示 -->
<div id="error" class="error" style="display: none;">
<p id="errorMessage"></p>
</div>
</div>
<!-- 编辑任务模态框 -->
<div id="editModal" class="modal" style="display: none;">
<div class="modal-content">
<span class="close">×</span>
<h2>编辑任务</h2>
<form id="editForm">
<div class="form-group">
<label for="editTitle">标题:</label>
<input type="text" id="editTitle" required>
</div>
<div class="form-group">
<label for="editDescription">描述:</label>
<textarea id="editDescription"></textarea>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="editCompleted">
已完成
</label>
</div>
<button type="submit" class="btn btn-primary">保存</button>
<button type="button" class="btn btn-secondary" onclick="closeEditModal()">取消</button>
</form>
</div>
</div>
<script src="script.js"></script>
</body>
</html>frontend/style.css:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 15px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
}
header h1 {
font-size: 2.5em;
margin-bottom: 10px;
}
header p {
font-size: 1.1em;
opacity: 0.9;
}
.add-todo {
padding: 30px;
border-bottom: 1px solid #eee;
}
.form-group {
margin-bottom: 15px;
}
input[type="text"], textarea {
width: 100%;
padding: 12px;
border: 2px solid #e1e5e9;
border-radius: 8px;
font-size: 16px;
transition: border-color 0.3s ease;
}
input[type="text"]:focus, textarea:focus {
outline: none;
border-color: #667eea;
}
textarea {
resize: vertical;
min-height: 80px;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
transition: all 0.3s ease;
font-weight: 600;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5a6fd8;
transform: translateY(-2px);
}
.btn-secondary {
background: #6c757d;
color: white;
margin-left: 10px;
}
.btn-secondary:hover {
background: #5a6268;
}
.todo-stats {
padding: 20px 30px;
background: #f8f9fa;
display: flex;
justify-content: space-around;
font-weight: 600;
color: #495057;
}
.todo-list {
padding: 20px 30px;
}
.todo-item {
background: white;
border: 2px solid #e1e5e9;
border-radius: 10px;
padding: 20px;
margin-bottom: 15px;
transition: all 0.3s ease;
position: relative;
}
.todo-item:hover {
border-color: #667eea;
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.1);
}
.todo-item.completed {
background: #f8f9fa;
border-color: #28a745;
}
.todo-item.completed .todo-title {
text-decoration: line-through;
color: #6c757d;
}
.todo-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.todo-title {
font-size: 1.2em;
font-weight: 600;
color: #333;
}
.todo-actions {
display: flex;
gap: 10px;
}
.btn-small {
padding: 6px 12px;
font-size: 14px;
}
.btn-success {
background: #28a745;
color: white;
}
.btn-warning {
background: #ffc107;
color: #212529;
}
.btn-danger {
background: #dc3545;
color: white;
}
.todo-description {
color: #6c757d;
margin-bottom: 10px;
line-height: 1.5;
}
.todo-meta {
font-size: 0.9em;
color: #adb5bd;
display: flex;
justify-content: space-between;
}
.loading {
text-align: center;
padding: 40px;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error {
background: #f8d7da;
color: #721c24;
padding: 15px;
margin: 20px 30px;
border-radius: 8px;
border: 1px solid #f5c6cb;
}
/* 模态框样式 */
.modal {
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
}
.modal-content {
background-color: white;
margin: 10% auto;
padding: 30px;
border-radius: 15px;
width: 90%;
max-width: 500px;
position: relative;
}
.close {
position: absolute;
right: 20px;
top: 15px;
font-size: 28px;
font-weight: bold;
cursor: pointer;
color: #aaa;
}
.close:hover {
color: #000;
}
/* 响应式设计 */
@media (max-width: 768px) {
.container {
margin: 10px;
border-radius: 10px;
}
header {
padding: 20px;
}
header h1 {
font-size: 2em;
}
.add-todo, .todo-list {
padding: 20px;
}
.todo-stats {
flex-direction: column;
gap: 10px;
text-align: center;
}
.todo-header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.todo-actions {
width: 100%;
justify-content: flex-end;
}
}frontend/script.js:
// API 配置
const API_BASE_URL = 'http://localhost:8000';
// 全局变量
let todos = [];
let editingTodoId = null;
// DOM 元素
const todoForm = document.getElementById('todoForm');
const todoList = document.getElementById('todoList');
const loading = document.getElementById('loading');
const error = document.getElementById('error');
const editModal = document.getElementById('editModal');
const editForm = document.getElementById('editForm');
// 初始化应用
document.addEventListener('DOMContentLoaded', function() {
loadTodos();
setupEventListeners();
});
// 设置事件监听器
function setupEventListeners() {
// 添加任务表单
todoForm.addEventListener('submit', handleAddTodo);
// 编辑任务表单
editForm.addEventListener('submit', handleEditTodo);
// 关闭模态框
document.querySelector('.close').addEventListener('click', closeEditModal);
// 点击模态框外部关闭
window.addEventListener('click', function(event) {
if (event.target === editModal) {
closeEditModal();
}
});
}
// API 请求函数
async function apiRequest(endpoint, options = {}) {
const url = `${API_BASE_URL}${endpoint}`;
const config = {
headers: {
'Content-Type': 'application/json',
...options.headers
},
...options
};
try {
const response = await fetch(url, config);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('API 请求失败:', error);
throw error;
}
}
// 显示加载状态
function showLoading() {
loading.style.display = 'block';
error.style.display = 'none';
}
// 隐藏加载状态
function hideLoading() {
loading.style.display = 'none';
}
// 显示错误信息
function showError(message) {
error.style.display = 'block';
document.getElementById('errorMessage').textContent = message;
}
// 隐藏错误信息
function hideError() {
error.style.display = 'none';
}
// 加载所有任务
async function loadTodos() {
try {
showLoading();
todos = await apiRequest('/todos');
renderTodos();
updateStats();
hideError();
} catch (error) {
showError('加载任务失败: ' + error.message);
} finally {
hideLoading();
}
}
// 渲染任务列表
function renderTodos() {
if (todos.length === 0) {
todoList.innerHTML = '<p style="text-align: center; color: #6c757d; padding: 40px;">暂无任务,添加一个吧!</p>';
return;
}
todoList.innerHTML = todos.map(todo => `
<div class="todo-item ${todo.completed ? 'completed' : ''}" data-id="${todo.id}">
<div class="todo-header">
<div class="todo-title">${escapeHtml(todo.title)}</div>
<div class="todo-actions">
<button class="btn btn-small btn-success" onclick="toggleTodo(${todo.id})">
${todo.completed ? '↩️ 撤销' : '✅ 完成'}
</button>
<button class="btn btn-small btn-warning" onclick="editTodo(${todo.id})">
✏️ 编辑
</button>
<button class="btn btn-small btn-danger" onclick="deleteTodo(${todo.id})">
🗑️ 删除
</button>
</div>
</div>
${todo.description ? `<div class="todo-description">${escapeHtml(todo.description)}</div>` : ''}
<div class="todo-meta">
<span>创建时间: ${formatDate(todo.created_at)}</span>
${todo.updated_at ? `<span>更新时间: ${formatDate(todo.updated_at)}</span>` : ''}
</div>
</div>
`).join('');
}
// 更新统计信息
function updateStats() {
const total = todos.length;
const completed = todos.filter(todo => todo.completed).length;
const pending = total - completed;
document.getElementById('totalCount').textContent = `总计: ${total}`;
document.getElementById('completedCount').textContent = `已完成: ${completed}`;
document.getElementById('pendingCount').textContent = `待完成: ${pending}`;
}
// 添加新任务
async function handleAddTodo(event) {
event.preventDefault();
const title = document.getElementById('todoTitle').value.trim();
const description = document.getElementById('todoDescription').value.trim();
if (!title) {
alert('请输入任务标题');
return;
}
try {
const newTodo = await apiRequest('/todos', {
method: 'POST',
body: JSON.stringify({
title: title,
description: description || null
})
});
todos.push(newTodo);
renderTodos();
updateStats();
// 清空表单
todoForm.reset();
hideError();
} catch (error) {
showError('添加任务失败: ' + error.message);
}
}
// 切换任务完成状态
async function toggleTodo(id) {
try {
const updatedTodo = await apiRequest(`/todos/${id}/toggle`, {
method: 'PATCH'
});
const index = todos.findIndex(todo => todo.id === id);
if (index !== -1) {
todos[index] = updatedTodo;
renderTodos();
updateStats();
}
hideError();
} catch (error) {
showError('更新任务失败: ' + error.message);
}
}
// 编辑任务
function editTodo(id) {
const todo = todos.find(t => t.id === id);
if (!todo) return;
editingTodoId = id;
document.getElementById('editTitle').value = todo.title;
document.getElementById('editDescription').value = todo.description || '';
document.getElementById('editCompleted').checked = todo.completed;
editModal.style.display = 'block';
}
// 处理编辑表单提交
async function handleEditTodo(event) {
event.preventDefault();
const title = document.getElementById('editTitle').value.trim();
const description = document.getElementById('editDescription').value.trim();
const completed = document.getElementById('editCompleted').checked;
if (!title) {
alert('请输入任务标题');
return;
}
try {
const updatedTodo = await apiRequest(`/todos/${editingTodoId}`, {
method: 'PUT',
body: JSON.stringify({
title: title,
description: description || null,
completed: completed
})
});
const index = todos.findIndex(todo => todo.id === editingTodoId);
if (index !== -1) {
todos[index] = updatedTodo;
renderTodos();
updateStats();
}
closeEditModal();
hideError();
} catch (error) {
showError('更新任务失败: ' + error.message);
}
}
// 删除任务
async function deleteTodo(id) {
if (!confirm('确定要删除这个任务吗?')) {
return;
}
try {
await apiRequest(`/todos/${id}`, {
method: 'DELETE'
});
todos = todos.filter(todo => todo.id !== id);
renderTodos();
updateStats();
hideError();
} catch (error) {
showError('删除任务失败: ' + error.message);
}
}
// 关闭编辑模态框
function closeEditModal() {
editModal.style.display = 'none';
editingTodoId = null;
editForm.reset();
}
// 工具函数
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}cd backend
uvicorn main:app --reload --host 0.0.0.0 --port 8000可以使用任何静态文件服务器:
# 使用 Python 内置服务器
cd frontend
python -m http.server 3000
# 或使用 Node.js 的 live-server
npx live-server --port=3000
# 或使用 VS Code 的 Live Server 扩展- 前端应用:http://localhost:3000
- API 文档:http://localhost:8000/docs
- 添加任务:在表单中输入标题和描述,点击"添加任务"
- 查看任务:任务会显示在列表中,包含标题、描述和创建时间
- 完成任务:点击"完成"按钮标记任务为已完成
- 编辑任务:点击"编辑"按钮修改任务内容
- 删除任务:点击"删除"按钮移除任务
- 查看统计:页面顶部显示任务总数、已完成和待完成数量
// 在 HTML 中添加搜索框
<input type="text" id="searchInput" placeholder="搜索任务...">
// 在 JavaScript 中添加搜索逻辑
function filterTodos(searchTerm) {
const filtered = todos.filter(todo =>
todo.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
(todo.description && todo.description.toLowerCase().includes(searchTerm.toLowerCase()))
);
renderFilteredTodos(filtered);
}// 添加任务分类
function addCategory(todo) {
const categories = ['工作', '学习', '生活', '其他'];
// 实现分类逻辑
}// 在数据模型中添加截止日期字段
due_date: Optional[datetime] = None
// 在前端添加日期选择器
<input type="datetime-local" id="todoDueDate">通过这个项目,您应该掌握:
- 前后端分离开发:理解 API 设计和数据交换
- 数据库操作:使用 SQLAlchemy 进行 CRUD 操作
- 用户界面设计:创建响应式和用户友好的界面
- 错误处理:处理网络请求和用户输入错误
- 状态管理:管理应用状态和数据同步
- 用户体验:添加加载状态、确认对话框等
完成本项目后,请检查是否掌握:
- 能够独立创建完整的 Web 应用
- 理解前后端分离架构的实现
- 掌握 RESTful API 的设计和使用
- 能够处理用户交互和数据持久化
- 学会错误处理和用户体验优化
- 能够扩展和优化应用功能
上一节:1.3 前后端交互 | 下一章:第二章 后端开发进阶