Skip to content

Latest commit

 

History

History
1048 lines (859 loc) · 25 KB

File metadata and controls

1048 lines (859 loc) · 25 KB

1.4 实践项目:待办事项应用

📖 项目概述

本章将带领您完成一个完整的待办事项(Todo List)应用,这个项目将综合运用前面学到的所有知识,包括 HTTP 协议、Web 框架、前后端交互等。

🎯 项目目标

  • 掌握完整的 Web 应用开发流程
  • 实践前后端分离架构
  • 学会处理用户交互和数据持久化
  • 理解错误处理和用户体验优化

📋 功能需求

  1. 用户功能

    • 添加新的待办事项
    • 查看所有待办事项列表
    • 标记待办事项为已完成/未完成
    • 删除待办事项
    • 编辑待办事项内容
  2. 技术特性

    • 前后端分离架构
    • 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                # 项目说明

🚀 后端开发

1. 项目初始化

# 创建项目目录
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 pydantic

2. 依赖文件

backend/requirements.txt:

fastapi==0.104.1
uvicorn[standard]==0.24.0
sqlalchemy==2.0.23
pydantic==2.5.0

3. 数据库配置

backend/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()

4. 数据模型

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 = True

5. 主应用文件

backend/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

6. 启动后端服务

# 在 backend 目录下运行
uvicorn main:app --reload --host 0.0.0.0 --port 8000

访问 http://localhost:8000/docs 查看自动生成的 API 文档。

🎨 前端开发

1. HTML 结构

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">&times;</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>

2. CSS 样式

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;
    }
}

3. JavaScript 逻辑

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'
    });
}

🚀 运行项目

1. 启动后端服务

cd backend
uvicorn main:app --reload --host 0.0.0.0 --port 8000

2. 启动前端服务

可以使用任何静态文件服务器:

# 使用 Python 内置服务器
cd frontend
python -m http.server 3000

# 或使用 Node.js 的 live-server
npx live-server --port=3000

# 或使用 VS Code 的 Live Server 扩展

3. 访问应用

🧪 测试功能

  1. 添加任务:在表单中输入标题和描述,点击"添加任务"
  2. 查看任务:任务会显示在列表中,包含标题、描述和创建时间
  3. 完成任务:点击"完成"按钮标记任务为已完成
  4. 编辑任务:点击"编辑"按钮修改任务内容
  5. 删除任务:点击"删除"按钮移除任务
  6. 查看统计:页面顶部显示任务总数、已完成和待完成数量

🔧 扩展功能

1. 添加搜索功能

// 在 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);
}

2. 添加分类功能

// 添加任务分类
function addCategory(todo) {
    const categories = ['工作', '学习', '生活', '其他'];
    // 实现分类逻辑
}

3. 添加截止日期

// 在数据模型中添加截止日期字段
due_date: Optional[datetime] = None

// 在前端添加日期选择器
<input type="datetime-local" id="todoDueDate">

📚 学习总结

通过这个项目,您应该掌握:

  1. 前后端分离开发:理解 API 设计和数据交换
  2. 数据库操作:使用 SQLAlchemy 进行 CRUD 操作
  3. 用户界面设计:创建响应式和用户友好的界面
  4. 错误处理:处理网络请求和用户输入错误
  5. 状态管理:管理应用状态和数据同步
  6. 用户体验:添加加载状态、确认对话框等

🔍 知识检查

完成本项目后,请检查是否掌握:

  • 能够独立创建完整的 Web 应用
  • 理解前后端分离架构的实现
  • 掌握 RESTful API 的设计和使用
  • 能够处理用户交互和数据持久化
  • 学会错误处理和用户体验优化
  • 能够扩展和优化应用功能

上一节1.3 前后端交互 | 下一章第二章 后端开发进阶