You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何基于Python+FastAPI实现前端无刷新更新任务列表与数据库

问题:FastAPI待办应用实现无刷新状态更新

我跟着教程用Python和FastAPI做了个待办事项应用,之后改成了交互式列表——用户点击按钮就能修改待办状态,同时更新数据库和列表项的分段颜色。但现在一点按钮整个页面就刷新,还会回到列表顶部。查了好多资料都没找到办法,想实现点击按钮不用刷新整个页面,就能更新数据库并修改分段颜色。

相关代码如下:

main.py

from queue import Empty
from fastapi import FastAPI, Depends, Request, Form, status
from fastapi.staticfiles import StaticFiles


from starlette.responses import RedirectResponse, JSONResponse
from starlette.templating import Jinja2Templates

from sqlalchemy.orm import Session
from database import SessionLocal, engine
import models

models.Base.metadata.create_all(bind=engine)

templates = Jinja2Templates(directory="templates")

class UnicornException(Exception):
    def __init__(self, name: str):
        self.name = name

app = FastAPI()

app.mount("/js", StaticFiles(directory="js"), name="js")


def get_db():
    db = SessionLocal()
    try: 
        yield db
    finally:
        db.close()

@app.exception_handler(UnicornException)
async def unicorn_exception_handler(request: Request, exc: UnicornException):
    return JSONResponse(
        status_code=422,
        content={"message": f"Oops! {exc.name} did something. There goes a rainbow..."},
    )

@app.get("/")
async def home(req: Request, db: Session = Depends(get_db)):
    todos = db.query(models.Todo).all()
    return templates.TemplateResponse("base.html", { "request": req, "todo_list": todos })

@app.post("/add")
def add(req: Request, title: str = Form(..., min_length=2, max_length=280), db: Session = Depends(get_db)):
    if title == "": ## I have no idea how to catch the empty string. 
        raise UnicornException(name=title) 
    else:
        new_todo = models.Todo(title=title)
        db.add(new_todo)
        db.commit()
        url = app.url_path_for("home")
        return RedirectResponse(url=url, status_code=status.HTTP_201_CREATED)

@app.get("/approved/{todo_id}")
def add(req: Request, todo_id: int, db: Session = Depends(get_db)):
    todo = db.query(models.Todo).filter(models.Todo.id == todo_id).first()
    todo.complete = True
    todo.denied = False
    todo.indoubt = False
    db.commit()
    url = app.url_path_for("home")
    return RedirectResponse(url=url, status_code=status.HTTP_303_SEE_OTHER)

@app.get("/indoubt/{todo_id}")
def add(req: Request, todo_id: int, db: Session = Depends(get_db)):
    todo = db.query(models.Todo).filter(models.Todo.id == todo_id).first()
    todo.complete = False
    todo.denied = False
    todo.indoubt = True
    db.commit()
    url = app.url_path_for("home")
    return RedirectResponse(url=url, status_code=status.HTTP_303_SEE_OTHER)

@app.get("/denied/{todo_id}")
def add(req: Request, todo_id: int, db: Session = Depends(get_db)):
    todo = db.query(models.Todo).filter(models.Todo.id == todo_id).first()
    todo.complete = False
    todo.indoubt = False
    todo.denied = True
    db.commit()
    url = app.url_path_for("home")
    return RedirectResponse(url=url, status_code=status.HTTP_303_SEE_OTHER)


@app.get("/delete/{todo_id}")
def add(req: Request, todo_id: int, db: Session = Depends(get_db)):
    todo = db.query(models.Todo).filter(models.Todo.id == todo_id).first()
    db.delete(todo)
    db.commit()
    url = app.url_path_for("home")
    return RedirectResponse(url=url, status_code=status.HTTP_303_SEE_OTHER)

HTML(base.html)

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>More time for fun stuff.</title>
        <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/semantic-ui@2.4.2/dist/semantic.min.css">
        <script src="https://cdn.jsdelivr.net/npm/semantic-ui@2.4.2/dist/semantic.min.js"></script>
        <script src="/js/script.js"></script>

    </head>
    <body>
        <div style="margin-top: 50px;" class="ui container">
            <h1 class="ui center aligned header">More time for fun stuff</h1>

            <form class="ui form" action="/add" method="post">
                <div class="field">
                    <label>Task Title</label>
                    <input type="text" maxlength="280" name="title" placeholder="Enter new task..." onkeyup="countChars(this);">
                    <br>
                </div>
                <p id="charNum">280 characters remaining</p>
                <button class="ui blue button" type="submit">Add</button>
            </form>

            <hr>

            {% for todo in todo_list %} 
                {% if todo.complete == True %}
                <div class="ui green segment">
                    <p class="ui green big header">{{ todo.id }} | {{ todo.title }}</p>
                <a class="ui green small button" href="/approved/{{ todo.id }}" action="">Yes</a>
                <a class="ui red small button" href="/denied/{{ todo.id }}">No</a>
                <a class="ui yellow small button" href="/indoubt/{{ todo.id }}">In doubt</a>
               <!--  <span class="ui green label">Yes</span> -->
                {% elif todo.denied == True %}
                <div class="ui red segment">
                    <p class="ui red big header">{{ todo.id }} | {{ todo.title }}</p>
                <a class="ui label" href="/delete/{{ todo.id }}">🗑️</a>
                <a class="ui green small button" href="/approved/{{ todo.id }}">Yes</a>
                <a class="ui red small button" href="/denied/{{ todo.id }}">No</a>
                <a class="ui yellow small button" href="/indoubt/{{ todo.id }}">In doubt</a>
               <!--  <span class="ui red label">No</span> -->
                {% elif todo.indoubt == True %}
                <div class="ui yellow segment">
                    <p class="ui yellow big header">{{ todo.id }} | {{ todo.title }}</p>
                <a class="ui green small button" href="/approved/{{ todo.id }}">Yes</a>
                <a class="ui red small button" href="/denied/{{ todo.id }}">No</a>
                <a class="ui yellow small button" href="/indoubt/{{ todo.id }}">In doubt</a>
               <!-- <span class="ui yellow label">In doubt</span> -->
                {% else %}
                <div class="ui blue segment">
                    <p class="ui blue big header">{{ todo.id }} | {{ todo.title }}</p>
                    <a class="ui green small button" href="/approved/{{ todo.id }}">Yes</a>
                    <a class="ui red small button" href="/denied/{{ todo.id }}">No</a>
                    <a class="ui yellow small button" href="/indoubt/{{ todo.id }}">In doubt</a>
               <!-- <span class="ui blue label">Not answered</span> -->
                {% endif %}
                 
            </div>
           
            {% endfor %}
        </div>


    </body>

</html>

解决方案

要实现无刷新更新,核心是用异步AJAX请求替代传统的页面跳转,后端返回JSON数据而非重定向,前端拿到结果后直接修改DOM元素。

1. 修改后端接口,返回JSON响应

把原来返回重定向的接口改成返回JSON,告诉前端操作是否成功:

# 修改approved接口,其余indoubt/denied/delete同理
@app.get("/approved/{todo_id}")
def update_approved(todo_id: int, db: Session = Depends(get_db)):
    todo = db.query(models.Todo).filter(models.Todo.id == todo_id).first()
    if not todo:
        return JSONResponse(status_code=404, content={"message": "待办项不存在"})
    todo.complete = True
    todo.denied = False
    todo.indoubt = False
    db.commit()
    return JSONResponse(content={"status": "success", "new_state": "approved"})

# 修改delete接口
@app.get("/delete/{todo_id}")
def delete_todo(todo_id: int, db: Session = Depends(get_db)):
    todo = db.query(models.Todo).filter(models.Todo.id == todo_id).first()
    if not todo:
        return JSONResponse(status_code=404, content={"message": "待办项不存在"})
    db.delete(todo)
    db.commit()
    return JSONResponse(content={"status": "success"})

# 可选:修改add接口支持异步提交
@app.post("/add")
def add(title: str = Form(..., min_length=2, max_length=280), db: Session = Depends(get_db)):
    if not title.strip():
        raise UnicornException(name="空标题不允许") 
    new_todo = models.Todo(title=title.strip())
    db.add(new_todo)
    db.commit()
    db.refresh(new_todo)
    # 异步提交返回JSON,传统提交返回重定向
    return JSONResponse(content={"status": "success", "todo": {"id": new_todo.id, "title": new_todo.title}})

2. 调整前端HTML,替换跳转链接为可交互按钮

把<a>标签改成按钮,用data-*属性存储待办ID和操作类型,阻止默认跳转行为:

{% for todo in todo_list %} 
    {% set segment_class = "blue" %}
    {% set header_class = "blue" %}
    {% if todo.complete == True %}
        {% set segment_class = "green" %}
        {% set header_class = "green" %}
    {% elif todo.denied == True %}
        {% set segment_class = "red" %}
        {% set header_class = "red" %}
    {% elif todo.indoubt == True %}
        {% set segment_class = "yellow" %}
        {% set header_class = "yellow" %}
    {% endif %}
    <div class="ui {{ segment_class }} segment" data-todo-id="{{ todo.id }}">
        <p class="ui {{ header_class }} big header">{{ todo.id }} | {{ todo.title }}</p>
        <button class="ui green small button todo-action" data-action="approved" data-todo-id="{{ todo.id }}">Yes</button>
        <button class="ui red small button todo-action" data-action="denied" data-todo-id="{{ todo.id }}">No</button>
        <button class="ui yellow small button todo-action" data-action="indoubt" data-todo-id="{{ todo.id }}">In doubt</button>
        {% if todo.denied %}
        <button class="ui label todo-action" data-action="delete" data-todo-id="{{ todo.id }}">🗑️</button>
        {% endif %}
    </div>
{% endfor %}

3. 编写JavaScript实现异步请求和DOM更新

在js/script.js中添加代码,监听按钮点击事件,发送异步请求并更新页面:

document.addEventListener('DOMContentLoaded', function() {
    // 绑定所有操作按钮的点击事件
    const actionButtons = document.querySelectorAll('.todo-action');
    actionButtons.forEach(button => {
        button.addEventListener('click', async function(e) {
            e.preventDefault();
            const todoId = this.dataset.todoId;
            const action = this.dataset.action;

            try {
                const response = await fetch(`/${action}/${todoId}`);
                const result = await response.json();

                if (result.status === 'success') {
                    const todoSegment = document.querySelector(`[data-todo-id="${todoId}"]`);
                    if (action === 'delete') {
                        // 删除待办项元素
                        todoSegment.remove();
                    } else {
                        // 更新分段和标题的颜色
                        todoSegment.classList.remove('blue', 'green', 'red', 'yellow');
                        const header = todoSegment.querySelector('.big.header');
                        header.classList.remove('blue', 'green', 'red', 'yellow');

                        let newClass = '';
                        if (action === 'approved') newClass = 'green';
                        else if (action === 'denied') {
                            newClass = 'red';
                            // 动态添加删除按钮(仅denied状态显示)
                            if (!todoSegment.querySelector('[data-action="delete"]')) {
                                const deleteBtn = document.createElement('button');
                                deleteBtn.className = 'ui label todo-action';
                                deleteBtn.dataset.action = 'delete';
                                deleteBtn.dataset.todoId = todoId;
                                deleteBtn.textContent = '🗑️';
                                deleteBtn.addEventListener('click', this.clickHandler);
                                todoSegment.appendChild(deleteBtn);
                            }
                        } else if (action === 'indoubt') newClass = 'yellow';

                        todoSegment.classList.add(newClass);
                        header.classList.add(newClass);
                    }
                }
            } catch (error) {
                console.error('操作失败:', error);
                alert('操作失败,请重试');
            }
        });
    });

    // 可选:实现添加任务的无刷新提交
    const addForm = document.querySelector('.ui.form');
    addForm.addEventListener('submit', async function(e) {
        e.preventDefault();
        const titleInput = this.querySelector('input[name="title"]');
        const title = titleInput.value.trim();

        if (title.length < 2 || title.length > 280) {
            alert('任务标题长度需在2-280字符之间');
            return;
        }

        try {
            const response = await fetch('/add', {
                method: 'POST',
                headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                body: `title=${encodeURIComponent(title)}`
            });
            const result = await response.json();

            if (result.status === 'success') {
                // 这里可以选择刷新页面,或者动态添加新的待办项元素到页面
                window.location.reload();
            }
        } catch (error) {
            console.error('添加失败:', error);
            alert('添加失败,请重试');
        }
    });
});

内容的提问来源于stack exchange,提问作者UGN

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.13 17:30:52