3v324v23's picture
Fix double submission bug and upgrade features: category, search, and edit modal
7df15d6
from fastapi import FastAPI, Request, Depends, HTTPException, Form
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from sqlalchemy import create_engine, Column, Integer, String, Text, DateTime, Boolean
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, Session
from sqlalchemy import or_
from datetime import datetime
import uvicorn
import os
from pydantic import BaseModel
from typing import List, Optional
# --- 数据库配置 ---
DATABASE_URL = "sqlite:///./notes.db"
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
# --- 数据库模型 ---
class Note(Base):
__tablename__ = "notes"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(100), index=True)
content = Column(Text)
category = Column(String(50), default="默认")
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
is_done = Column(Boolean, default=False)
# 创建表
Base.metadata.create_all(bind=engine)
# --- 依赖项 ---
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
# --- FastAPI 应用初始化 ---
app = FastAPI(
title="高级 Python 后端项目 (FastAPI + SQLite)",
description="一个功能完备的笔记/任务管理系统,支持 CRUD、分类、搜索、实时编辑及自动持久化。",
version="3.0.0"
)
# --- API 路由 (CRUD) ---
@app.get("/api/notes")
def read_notes(q: Optional[str] = None, category: Optional[str] = None, db: Session = Depends(get_db)):
query = db.query(Note)
if q:
query = query.filter(or_(Note.title.contains(q), Note.content.contains(q)))
if category and category != "全部":
query = query.filter(Note.category == category)
notes = query.order_by(Note.created_at.desc()).all()
return [{"id": n.id, "title": n.title, "content": n.content, "category": n.category,
"created_at": n.created_at.strftime("%Y-%m-%d %H:%M:%S"), "is_done": n.is_done} for n in notes]
@app.post("/api/notes")
def create_note(title: str = Form(...), content: str = Form(...), category: str = Form("默认"), db: Session = Depends(get_db)):
# 后端防重逻辑:检查是否在 1 秒内创建了内容完全一样的记录
existing = db.query(Note).filter(
Note.title == title,
Note.content == content
).order_by(Note.created_at.desc()).first()
if existing and (datetime.now() - existing.created_at).total_seconds() < 1:
return RedirectResponse(url="/", status_code=303)
new_note = Note(title=title, content=content, category=category)
db.add(new_note)
db.commit()
db.refresh(new_note)
return RedirectResponse(url="/", status_code=303)
@app.post("/api/notes/{note_id}/edit")
def edit_note(note_id: int, title: str = Form(...), content: str = Form(...), category: str = Form(...), db: Session = Depends(get_db)):
note = db.query(Note).filter(Note.id == note_id).first()
if not note:
raise HTTPException(status_code=404, detail="Note not found")
note.title = title
note.content = content
note.category = category
db.commit()
return RedirectResponse(url="/", status_code=303)
@app.post("/api/notes/{note_id}/toggle")
def toggle_note(note_id: int, db: Session = Depends(get_db)):
note = db.query(Note).filter(Note.id == note_id).first()
if not note:
raise HTTPException(status_code=404, detail="Note not found")
note.is_done = not note.is_done
db.commit()
return RedirectResponse(url="/", status_code=303)
@app.post("/api/notes/{note_id}/delete")
def delete_note(note_id: int, db: Session = Depends(get_db)):
note = db.query(Note).filter(Note.id == note_id).first()
if not note:
raise HTTPException(status_code=404, detail="Note not found")
db.delete(note)
db.commit()
return RedirectResponse(url="/", status_code=303)
# --- 页面路由 (HTML) ---
@app.get("/", response_class=HTMLResponse)
async def home(request: Request, q: Optional[str] = None, cat: Optional[str] = None, db: Session = Depends(get_db)):
# 获取所有分类供筛选
categories = [c[0] for c in db.query(Note.category).distinct().all()]
if "默认" not in categories: categories.append("默认")
# 构造列表 HTML (服务器端渲染以保证首屏速度)
query = db.query(Note)
if q: query = query.filter(or_(Note.title.contains(q), Note.content.contains(q)))
if cat and cat != "全部": query = query.filter(Note.category == cat)
notes = query.order_by(Note.created_at.desc()).all()
notes_html = ""
for note in notes:
status_class = "opacity-50 line-through" if note.is_done else ""
btn_text = '<i class="fas fa-undo"></i> 重做' if note.is_done else '<i class="fas fa-check"></i> 完成'
btn_color = "bg-yellow-500 hover:bg-yellow-600" if note.is_done else "bg-green-500 hover:bg-green-600"
notes_html += f"""
<div class="note-card bg-white p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300 border-l-4 {'border-green-400' if note.is_done else 'border-blue-400'} mb-4 group"
data-id="{note.id}" data-title="{note.title}" data-content="{note.content}" data-category="{note.category}">
<div class="flex justify-between items-start">
<div class="flex-1">
<div class="flex items-center space-x-2 mb-1">
<span class="px-2 py-0.5 bg-gray-100 text-gray-500 text-xs rounded-full">{note.category}</span>
<h3 class="text-lg font-bold text-gray-800 {status_class}">{note.title}</h3>
</div>
<p class="text-gray-600 whitespace-pre-wrap text-sm leading-relaxed {status_class}">{note.content}</p>
<div class="flex items-center mt-3 text-xs text-gray-400 space-x-3">
<span><i class="far fa-calendar-alt mr-1"></i> {note.created_at.strftime('%Y-%m-%d %H:%M')}</span>
{f'<span class="text-green-500"><i class="fas fa-check-circle"></i> 已完成</span>' if note.is_done else ''}
</div>
</div>
<div class="flex flex-col space-y-2 ml-4 opacity-0 group-hover:opacity-100 transition-opacity">
<button onclick='openEditModal({note.id}, "{note.title}", `{note.content}`, "{note.category}")'
class="text-blue-500 hover:text-blue-700 p-2 bg-blue-50 rounded-lg transition-colors" title="编辑">
<i class="fas fa-edit"></i>
</button>
<form action="/api/notes/{note.id}/toggle" method="post" class="inline">
<button type="submit" class="text-white {btn_color} w-8 h-8 rounded-lg flex items-center justify-center transition-colors" title="{ '重做' if note.is_done else '完成' }">
{btn_text.split(' ')[0]}
</button>
</form>
<form action="/api/notes/{note.id}/delete" method="post" class="inline" onsubmit="return confirm('确定要删除吗?')">
<button type="submit" class="text-red-500 hover:text-red-700 p-2 bg-red-50 rounded-lg transition-colors" title="删除">
<i class="fas fa-trash-alt"></i>
</button>
</form>
</div>
</div>
</div>
"""
if not notes:
notes_html = """
<div class="text-center py-20 bg-white rounded-2xl border-2 border-dashed border-gray-200">
<div class="text-gray-300 text-6xl mb-4"><i class="fas fa-folder-open"></i></div>
<p class="text-gray-500">没找到相关记录哦</p>
<a href="/" class="text-blue-500 hover:underline text-sm mt-2 inline-block">清除搜索条件</a>
</div>
"""
html_content = f"""
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>我的记录 - 高级 Python 后端项目</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap');
body {{ font-family: 'Noto Sans SC', sans-serif; }}
.note-card {{ animation: slideIn 0.3s ease-out; }}
@keyframes slideIn {{
from {{ opacity: 0; transform: translateY(10px); }}
to {{ opacity: 1; transform: translateY(0); }}
}}
.modal {{ display: none; }}
.modal.active {{ display: flex; }}
</style>
</head>
<body class="bg-[#f8fafc] min-h-screen">
<!-- 顶部导航 -->
<nav class="bg-white border-b border-gray-200 sticky top-0 z-50 px-4 py-3">
<div class="container mx-auto flex justify-between items-center">
<div class="flex items-center space-x-3">
<div class="bg-blue-600 p-2 rounded-lg text-white">
<i class="fas fa-book-open"></i>
</div>
<h1 class="text-xl font-bold text-gray-800 tracking-tight">我的记录</h1>
</div>
<div class="hidden md:flex items-center space-x-6">
<a href="/docs" class="text-sm text-gray-500 hover:text-blue-600 transition-colors">API 开发文档</a>
<div class="h-4 w-px bg-gray-200"></div>
<span class="text-xs text-gray-400">Powered by FastAPI</span>
</div>
</div>
</nav>
<main class="container mx-auto px-4 py-8 flex flex-col lg:flex-row gap-8">
<!-- 左侧:新建 & 筛选 -->
<aside class="w-full lg:w-1/3 space-y-6">
<!-- 搜索框 -->
<div class="bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
<form action="/" method="get" class="relative">
<input type="text" name="q" value="{q or ''}" placeholder="搜索内容或标题..."
class="w-full pl-10 pr-4 py-2 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all">
<i class="fas fa-search absolute left-3 top-3 text-gray-400"></i>
</form>
</div>
<!-- 新建笔记卡片 -->
<div class="bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
<h2 class="text-lg font-bold mb-5 text-gray-800 flex items-center">
<i class="fas fa-pencil-alt mr-2 text-blue-500"></i> 新建笔记
</h2>
<form id="createForm" action="/api/notes" method="post" class="space-y-4" onsubmit="handleSubmit(this)">
<div>
<input type="text" name="title" required placeholder="标题"
class="w-full px-4 py-2 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition-all">
</div>
<div>
<select name="category" class="w-full px-4 py-2 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition-all">
<option value="默认">默认分类</option>
<option value="工作">工作</option>
<option value="生活">生活</option>
<option value="灵感">灵感</option>
<option value="学习">学习</option>
</select>
</div>
<div>
<textarea name="content" rows="4" required placeholder="今天想记录点什么?"
class="w-full px-4 py-2 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition-all"></textarea>
</div>
<button type="submit" id="submitBtn" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 rounded-xl shadow-lg shadow-blue-200 transition-all active:scale-95">
保存记录
</button>
</form>
</div>
<!-- 分类筛选 -->
<div class="bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
<h3 class="text-sm font-bold text-gray-400 uppercase tracking-wider mb-4">分类筛选</h3>
<div class="flex flex-wrap gap-2">
<a href="/?cat=全部" class="px-4 py-2 rounded-lg text-sm transition-all {'bg-blue-600 text-white shadow-md' if cat=='全部' or not cat else 'bg-gray-50 text-gray-600 hover:bg-gray-100'}">全部</a>
{"".join([f'<a href="/?cat={c}" class="px-4 py-2 rounded-lg text-sm transition-all {"bg-blue-600 text-white shadow-md" if cat==c else "bg-gray-50 text-gray-600 hover:bg-gray-100"}">{c}</a>' for c in categories])}
</div>
</div>
</aside>
<!-- 右侧:列表展示 -->
<section class="flex-1">
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold text-gray-800">
{ f'“{q}” 的搜索结果' if q else f'{cat or "全部"} 记录' }
</h2>
<span class="text-sm text-gray-400">共 {len(notes)} 条</span>
</div>
<div id="notes-list" class="space-y-4">
{notes_html}
</div>
</section>
</main>
<!-- 编辑弹窗 (Modal) -->
<div id="editModal" class="modal fixed inset-0 z-[60] bg-black/50 backdrop-blur-sm items-center justify-center p-4">
<div class="bg-white w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden">
<div class="px-6 py-4 bg-gray-50 border-b border-gray-100 flex justify-between items-center">
<h3 class="font-bold text-gray-800">编辑记录</h3>
<button onclick="closeModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
</div>
<form id="editForm" method="post" class="p-6 space-y-4" onsubmit="handleSubmit(this)">
<div>
<label class="block text-xs font-bold text-gray-400 mb-1">标题</label>
<input type="text" id="editTitle" name="title" required
class="w-full px-4 py-2 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none">
</div>
<div>
<label class="block text-xs font-bold text-gray-400 mb-1">分类</label>
<select id="editCategory" name="category" class="w-full px-4 py-2 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none">
<option value="默认">默认分类</option>
<option value="工作">工作</option>
<option value="生活">生活</option>
<option value="灵感">灵感</option>
<option value="学习">学习</option>
</select>
</div>
<div>
<label class="block text-xs font-bold text-gray-400 mb-1">内容</label>
<textarea id="editContent" name="content" rows="5" required
class="w-full px-4 py-2 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none"></textarea>
</div>
<div class="flex space-x-3 pt-2">
<button type="button" onclick="closeModal()" class="flex-1 py-3 bg-gray-100 text-gray-600 font-bold rounded-xl hover:bg-gray-200 transition-colors">取消</button>
<button type="submit" class="flex-1 py-3 bg-blue-600 text-white font-bold rounded-xl hover:bg-blue-700 shadow-lg shadow-blue-200 transition-colors">保存修改</button>
</div>
</form>
</div>
</div>
<script>
// 处理提交:防止双击
function handleSubmit(form) {{
const btn = form.querySelector('button[type="submit"]');
if (btn) {{
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 处理中...';
}}
return true;
}}
// 打开编辑弹窗
function openEditModal(id, title, content, category) {{
const modal = document.getElementById('editModal');
const form = document.getElementById('editForm');
form.action = `/api/notes/${{id}}/edit`;
document.getElementById('editTitle').value = title;
document.getElementById('editContent').value = content;
document.getElementById('editCategory').value = category;
modal.classList.add('active');
document.body.style.overflow = 'hidden';
}}
// 关闭弹窗
function closeModal() {{
const modal = document.getElementById('editModal');
modal.classList.remove('active');
document.body.style.overflow = 'auto';
}}
// 点击遮罩层关闭
document.getElementById('editModal').addEventListener('click', function(e) {{
if (e.target === this) closeModal();
}});
</script>
</body>
</html>
"""
return HTMLResponse(content=html_content)
if __name__ == "__main__":
port = int(os.environ.get("PORT", 7860))
uvicorn.run(app, host="0.0.0.0", port=port)