浏览代码

feat: 满意错题本 - 完整版

sequoia00 1 月之前
当前提交
f5d2c7b29b

二进制
__pycache__/app.cpython-313.pyc


二进制
__pycache__/crud.cpython-313.pyc


二进制
__pycache__/database.cpython-313.pyc


二进制
__pycache__/models.cpython-313.pyc


+ 204 - 0
app.py

@@ -0,0 +1,204 @@
+from fastapi import FastAPI, Request, UploadFile, File, Form, HTTPException, Depends
+from fastapi.staticfiles import StaticFiles
+from fastapi.templating import Jinja2Templates
+from fastapi.responses import HTMLResponse, RedirectResponse
+from sqlmodel import Session
+from pathlib import Path
+import uuid, os
+from datetime import datetime
+
+from database import engine, init_db, get_session
+from models import Subject, ErrorImage, Evaluation
+import crud
+
+app = FastAPI(title="满意在线错题本")
+app.mount("/static", StaticFiles(directory="static"), name="static")
+templates = Jinja2Templates(directory="templates")
+
+init_db()
+
+UPLOAD_DIR = Path("static/uploads")
+UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
+
+COLORS = ["#6366f1","#8b5cf6","#ec4899","#f43f5e","#f97316","#eab308","#22c55e","#14b8a6","#06b6d4","#3b82f6"]
+
+# ── 首页 ───────────────────────────────────��──────────
+@app.get("/", response_class=HTMLResponse)
+async def index(request: Request, session: Session = Depends(get_session)):
+    subjects = crud.get_all_subjects(session)
+    today = datetime.now().strftime("%Y-%m-%d")
+    today_images = {}
+    for s in subjects:
+        imgs = crud.get_subject_images(session, s.id, date=today)
+        if imgs:
+            today_images[s.id] = imgs
+    evs = crud.get_evaluations(session)
+    return templates.TemplateResponse("index.html", {
+        "request": request, "subjects": subjects,
+        "today": today, "today_images": today_images, "colors": COLORS,
+        "evaluations": evs
+    })
+
+# ── 科目管理 ────────────────────────────────────────────
+@app.get("/subjects", response_class=HTMLResponse)
+async def subjects_page(request: Request, session: Session = Depends(get_session)):
+    subjects = crud.get_all_subjects(session)
+    return templates.TemplateResponse("subjects.html", {"request": request, "subjects": subjects, "colors": COLORS})
+
+@app.post("/subjects/create")
+async def create_subject(
+    request: Request,
+    name: str = Form(...),
+    color: str = Form(default="#6366f1"),
+    session: Session = Depends(get_session)
+):
+    subject = crud.create_subject(session, name.strip(), color)
+    return RedirectResponse(url=f"/subject/{subject.id}", status_code=303)
+
+@app.post("/subjects/{subject_id}/delete")
+async def delete_subject(subject_id: int, session: Session = Depends(get_session)):
+    if crud.delete_subject(session, subject_id):
+        return RedirectResponse(url="/subjects", status_code=303)
+    raise HTTPException(404, "科目不存在")
+
+# ── 科目详情页 ─────────────────────────────────────────
+@app.get("/subject/{subject_id}", response_class=HTMLResponse)
+async def subject_detail(
+    request: Request, subject_id: int, date: str | None = None,
+    session: Session = Depends(get_session)
+):
+    subject = session.get(Subject, subject_id)
+    if not subject:
+        raise HTTPException(404, "科目不存在")
+    dates = crud.get_dates_for_subject(session, subject_id)
+    if not date:
+        date = dates[0] if dates else datetime.now().strftime("%Y-%m-%d")
+    images = crud.get_subject_images(session, subject_id, date)
+    if not images and dates:
+        date = dates[0]
+        images = crud.get_subject_images(session, subject_id, date)
+    prev_date = None
+    next_date = None
+    if dates and date in dates:
+        idx = dates.index(date)
+        if idx < len(dates) - 1:
+            prev_date = dates[idx + 1]
+        if idx > 0:
+            next_date = dates[idx - 1]
+    return templates.TemplateResponse("subject_detail.html", {
+        "request": request, "subject": subject, "dates": dates,
+        "current_date": date, "images": images,
+        "prev_date": prev_date, "next_date": next_date
+    })
+
+# ── 上传错题图片 ────────────────────────────────────────
+@app.post("/subject/{subject_id}/upload")
+async def upload_error_images(
+    subject_id: int,
+    date: str = Form(...),
+    files: list[UploadFile] = File(...),
+    session: Session = Depends(get_session)
+):
+    subject = session.get(Subject, subject_id)
+    if not subject:
+        raise HTTPException(404, "科目不存在")
+    filenames, original_names = [], []
+    for f in files:
+        content = await f.read()
+        ext = Path(f.filename).suffix or ".jpg"
+        unique_name = f"{uuid.uuid4().hex}{ext}"
+        dest = UPLOAD_DIR / unique_name
+        dest.write_bytes(content)
+        filenames.append(unique_name)
+        original_names.append(f.filename)
+    crud.add_error_images(session, subject_id, date, filenames, original_names)
+    return RedirectResponse(url=f"/subject/{subject_id}?date={date}", status_code=303)
+
+# ── 删除单张图片 ────────────────────────────────────────
+@app.post("/image/{image_id}/delete")
+async def delete_image(image_id: int, session: Session = Depends(get_session)):
+    img = crud.delete_image(session, image_id)
+    if not img:
+        raise HTTPException(404, "图片不存在")
+    return RedirectResponse(url=f"/subject/{img.subject_id}?date={img.date}", status_code=303)
+
+# ── 时间线 ───────────────────────────────────────────────
+@app.get("/timeline", response_class=HTMLResponse)
+async def timeline(request: Request, date: str | None = None, session: Session = Depends(get_session)):
+    subjects = crud.get_all_subjects(session)
+    all_dates = crud.get_all_dates(session)
+    if not date:
+        date = all_dates[0] if all_dates else datetime.now().strftime("%Y-%m-%d")
+    timeline_data = []
+    for s in subjects:
+        imgs = crud.get_subject_images(session, s.id, date)
+        if imgs:
+            timeline_data.append({"subject": s, "images": imgs})
+    prev_date = None
+    next_date = None
+    if all_dates and date in all_dates:
+        idx = all_dates.index(date)
+        if idx < len(all_dates) - 1:
+            prev_date = all_dates[idx + 1]
+        if idx > 0:
+            next_date = all_dates[idx - 1]
+    return templates.TemplateResponse("timeline.html", {
+        "request": request, "subjects": subjects, "all_dates": all_dates,
+        "current_date": date, "timeline_data": timeline_data,
+        "prev_date": prev_date, "next_date": next_date
+    })
+
+# ── 复习汇总 ────────────────────────────────────────────
+@app.get("/review", response_class=HTMLResponse)
+async def review_page(request: Request, session: Session = Depends(get_session)):
+    data = crud.get_review_data(session)
+    subjects = crud.get_all_subjects(session)
+    return templates.TemplateResponse("review.html", {
+        "request": request, "review_data": data, "subjects": subjects
+    })
+
+# ── 知识评价 ────────────────────────────────────────────
+@app.get("/evaluations", response_class=HTMLResponse)
+async def evaluations_page(request: Request, subject_id: int | None = None, session: Session = Depends(get_session)):
+    subjects = crud.get_all_subjects(session)
+    evs = crud.get_evaluations(session, subject_id)
+    return templates.TemplateResponse("evaluations.html", {
+        "request": request, "subjects": subjects, "evaluations": evs,
+        "current_subject": subject_id, "today": datetime.now().strftime("%Y-%m-%d")
+    })
+
+@app.post("/evaluations/add")
+async def add_evaluation(
+    subject_id: int = Form(...),
+    date: str = Form(...),
+    title: str = Form(...),
+    content: str = Form(default=""),
+    file: UploadFile | None = File(None),
+    session: Session = Depends(get_session)
+):
+    filename = None
+    if file and file.filename:
+        ext = Path(file.filename).suffix or ".pdf"
+        unique_name = f"eval_{uuid.uuid4().hex}{ext}"
+        dest = UPLOAD_DIR / unique_name
+        dest.write_bytes(await file.read())
+        filename = unique_name
+    crud.add_evaluation(session, subject_id, date, title, content, filename)
+    return RedirectResponse(url=f"/evaluations?subject_id={subject_id}", status_code=303)
+
+@app.post("/evaluation/{eval_id}/delete")
+async def delete_evaluation(eval_id: int, session: Session = Depends(get_session)):
+    ev = session.get(Evaluation, eval_id)
+    if not ev:
+        raise HTTPException(404)
+    if ev.filename:
+        p = UPLOAD_DIR / ev.filename
+        if p.exists():
+            p.unlink()
+    session.delete(ev)
+    session.commit()
+    return RedirectResponse(url=f"/evaluations?subject_id={ev.subject_id}", status_code=303)
+
+if __name__ == "__main__":
+    import uvicorn
+    uvicorn.run("app:app", host="0.0.0.0", port=8000, reload=True)

+ 90 - 0
crud.py

@@ -0,0 +1,90 @@
+from sqlmodel import Session, select
+from models import Subject, ErrorImage, Evaluation
+from datetime import datetime
+from typing import List, Optional
+
+def create_subject(session: Session, name: str, color: str = "#6366f1") -> Subject:
+    existing = session.exec(select(Subject).where(Subject.name == name.strip())).first()
+    if existing:
+        return existing
+    subject = Subject(name=name.strip(), color=color)
+    session.add(subject)
+    session.commit()
+    session.refresh(subject)
+    return subject
+
+def get_all_subjects(session: Session) -> List[Subject]:
+    return list(session.exec(select(Subject).order_by(Subject.created_at.asc())).all())
+
+def get_subject(session: Session, subject_id: int) -> Optional[Subject]:
+    return session.get(Subject, subject_id)
+
+def delete_subject(session: Session, subject_id: int) -> bool:
+    subject = session.get(Subject, subject_id)
+    if not subject:
+        return False
+    # Delete all images files
+    from pathlib import Path
+    upload_dir = Path("static/uploads")
+    for img in subject.error_images:
+        p = upload_dir / img.filename
+        if p.exists():
+            p.unlink()
+    session.delete(subject)
+    session.commit()
+    return True
+
+def add_error_images(session: Session, subject_id: int, date: str, filenames: List[str], original_names: List[str]):
+    for fn, on in zip(filenames, original_names):
+        img = ErrorImage(subject_id=subject_id, date=date, filename=fn, original_name=on)
+        session.add(img)
+    session.commit()
+
+def get_subject_images(session: Session, subject_id: int, date: Optional[str] = None) -> List[ErrorImage]:
+    stmt = select(ErrorImage).where(ErrorImage.subject_id == subject_id)
+    if date:
+        stmt = stmt.where(ErrorImage.date == date)
+    return list(session.exec(stmt.order_by(ErrorImage.upload_time.asc())).all())
+
+def get_dates_for_subject(session: Session, subject_id: int) -> List[str]:
+    stmt = select(ErrorImage.date).where(ErrorImage.subject_id == subject_id).distinct().order_by(ErrorImage.date.desc())
+    return list(session.exec(stmt).all())
+
+def get_all_dates(session: Session) -> List[str]:
+    stmt = select(ErrorImage.date).distinct().order_by(ErrorImage.date.desc())
+    return list(session.exec(stmt).all())
+
+def add_evaluation(session: Session, subject_id: int, date: str, title: str, content: str, filename: Optional[str]) -> Evaluation:
+    ev = Evaluation(subject_id=subject_id, date=date, title=title, content=content, filename=filename)
+    session.add(ev)
+    session.commit()
+    session.refresh(ev)
+    return ev
+
+def get_evaluations(session: Session, subject_id: Optional[int] = None) -> List[Evaluation]:
+    stmt = select(Evaluation).order_by(Evaluation.date.desc(), Evaluation.upload_time.desc())
+    if subject_id:
+        stmt = stmt.where(Evaluation.subject_id == subject_id)
+    return list(session.exec(stmt).all())
+
+def get_review_data(session: Session) -> dict:
+    subjects = get_all_subjects(session)
+    data = {}
+    for s in subjects:
+        images = list(session.exec(
+            select(ErrorImage).where(ErrorImage.subject_id == s.id).order_by(ErrorImage.date.asc(), ErrorImage.upload_time.asc())
+        ).all())
+        dates = sorted(set(img.date for img in images))
+        data[s.id] = {"subject": s, "images": images, "dates": dates}
+    return data
+
+def delete_image(session: Session, image_id: int):
+    img = session.get(ErrorImage, image_id)
+    if img:
+        from pathlib import Path
+        path = Path("static/uploads") / img.filename
+        if path.exists():
+            path.unlink()
+        session.delete(img)
+        session.commit()
+    return img

+ 13 - 0
database.py

@@ -0,0 +1,13 @@
+from sqlmodel import SQLModel, create_engine, Session
+from pathlib import Path
+
+DATABASE_URL = "sqlite:///./错题本.db"
+
+engine = create_engine(DATABASE_URL, echo=False, connect_args={"check_same_thread": False})
+
+def init_db():
+    SQLModel.metadata.create_all(engine)
+
+def get_session():
+    with Session(engine) as session:
+        yield session

+ 34 - 0
models.py

@@ -0,0 +1,34 @@
+from sqlmodel import SQLModel, Field, Relationship, text
+from datetime import datetime
+from typing import Optional, List
+from sqlalchemy import Column, String, Text
+
+class Subject(SQLModel, table=True):
+    __tablename__ = "subject"
+    id: Optional[int] = Field(default=None, primary_key=True)
+    name: str = Field(max_length=100)
+    color: str = Field(default="#6366f1")
+    created_at: datetime = Field(default_factory=datetime.now)
+    error_images: List["ErrorImage"] = Relationship(back_populates="subject")
+    evaluations: List["Evaluation"] = Relationship(back_populates="subject")
+
+class ErrorImage(SQLModel, table=True):
+    __tablename__ = "error_image"
+    id: Optional[int] = Field(default=None, primary_key=True)
+    subject_id: int = Field(foreign_key="subject.id")
+    date: str = Field(max_length=20, index=True)
+    filename: str = Field(max_length=255)
+    original_name: str = Field(max_length=255)
+    upload_time: datetime = Field(default_factory=datetime.now)
+    subject: Subject = Relationship(back_populates="error_images")
+
+class Evaluation(SQLModel, table=True):
+    __tablename__ = "evaluation"
+    id: Optional[int] = Field(default=None, primary_key=True)
+    subject_id: int = Field(foreign_key="subject.id")
+    date: str = Field(max_length=20)
+    title: str = Field(max_length=200)
+    content: str = Field(default="")
+    filename: Optional[str] = Field(default=None, max_length=255)
+    upload_time: datetime = Field(default_factory=datetime.now)
+    subject: Subject = Relationship(back_populates="evaluations")

二进制
static/uploads/7f5ac2dc474c41b484c16a3ab105a923.jpg


+ 146 - 0
templates/base.html

@@ -0,0 +1,146 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>{% block title %}错题本{% endblock %}</title>
+  <script src="https://cdn.tailwindcss.com"></script>
+  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
+  <script>
+    tailwind.config = {
+      theme: {
+        extend: {
+          colors: {
+            primary: '#6366f1',
+            secondary: '#8b5cf6',
+            accent: '#f43f5e',
+          }
+        }
+      }
+    }
+  </script>
+  <style>
+    body { font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif; }
+    .sidebar-link.active { background: rgba(99,102,241,0.10); color: #6366f1; border-right: 3px solid #6366f1; }
+    .image-card:hover .image-actions { opacity: 1; }
+    .image-actions { opacity: 0; transition: opacity 0.2s; }
+    ::-webkit-scrollbar { width: 6px; height: 6px; }
+    ::-webkit-scrollbar-track { background: #f1f5f9; }
+    ::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
+    .modal-overlay { backdrop-filter: blur(6px); }
+    /* Lightbox */
+    #lightbox { cursor: zoom-out; }
+    #lightbox img { max-height: 85vh; max-width: 90vw; object-fit: contain; }
+    .date-chip { transition: all 0.15s; }
+    .date-chip:hover { transform: translateY(-1px); }
+  </style>
+  {% block extra_css %}{% endblock %}
+</head>
+<body class="bg-gray-50 min-h-screen">
+  <div class="flex min-h-screen">
+    <!-- Sidebar -->
+    <aside class="w-56 bg-white shadow-lg fixed h-full z-20 flex flex-col">
+      <div class="p-5 border-b border-gray-100">
+        <div class="flex items-center gap-3">
+          <div class="w-9 h-9 rounded-xl bg-primary flex items-center justify-center text-white text-base">
+            <i class="fa-solid fa-book-open"></i>
+          </div>
+          <div>
+            <h1 class="font-bold text-gray-800 text-sm leading-tight">满意错题本</h1>
+            <p class="text-[10px] text-gray-400 mt-0.5">在线复习助手</p>
+          </div>
+        </div>
+      </div>
+      <nav class="flex-1 py-3">
+        <a href="/" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-gray-500 hover:bg-gray-50 transition {% if request.url.path == '/' %}active{% endif %}">
+          <i class="fa-solid fa-house w-5 text-center text-base"></i><span class="text-sm font-medium">首页</span>
+        </a>
+        <a href="/subjects" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-gray-500 hover:bg-gray-50 transition {% if '/subjects' in request.url.path %}active{% endif %}">
+          <i class="fa-solid fa-folder w-5 text-center text-base"></i><span class="text-sm font-medium">科目管理</span>
+        </a>
+        <a href="/timeline" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-gray-500 hover:bg-gray-50 transition {% if '/timeline' in request.url.path %}active{% endif %}">
+          <i class="fa-solid fa-clock-rotate-left w-5 text-center text-base"></i><span class="text-sm font-medium">时间线</span>
+        </a>
+        <a href="/review" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-gray-500 hover:bg-gray-50 transition {% if '/review' in request.url.path %}active{% endif %}">
+          <i class="fa-solid fa-graduation-cap w-5 text-center text-base"></i><span class="text-sm font-medium">复习汇总</span>
+        </a>
+        <a href="/evaluations" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-gray-500 hover:bg-gray-50 transition {% if '/evaluations' in request.url.path %}active{% endif %}">
+          <i class="fa-solid fa-clipboard-check w-5 text-center text-base"></i><span class="text-sm font-medium">知识评价</span>
+        </a>
+      </nav>
+      <div class="p-4 border-t border-gray-100">
+        <p class="text-[10px] text-gray-300 text-center">© 2025 满意错题本</p>
+      </div>
+    </aside>
+
+    <!-- Main content -->
+    <main class="flex-1 ml-56">
+      <header class="bg-white/80 backdrop-blur border-b border-gray-100 px-8 py-3.5 flex items-center justify-between sticky top-0 z-10">
+        <h2 class="text-base font-semibold text-gray-700">{% block page_title %}{% endblock %}</h2>
+        <div class="text-xs text-gray-400" id="current-date"></div>
+      </header>
+      <div class="p-8">
+        {% block content %}{% endblock %}
+      </div>
+    </main>
+  </div>
+
+  <!-- Lightbox -->
+  <div id="lightbox" class="fixed inset-0 z-50 hidden items-center justify-center modal-overlay bg-black/70" onclick="closeLightbox(event)">
+    <button class="absolute top-4 right-4 text-white/80 hover:text-white text-2xl z-10" onclick="closeLightbox(event)">
+      <i class="fa-solid fa-xmark"></i>
+    </button>
+    <button id="lb-prev" class="absolute left-4 text-white/80 hover:text-white text-2xl hidden" onclick="navigateLightbox(-1,event)">
+      <i class="fa-solid fa-chevron-left"></i>
+    </button>
+    <button id="lb-next" class="absolute right-4 text-white/80 hover:text-white text-2xl hidden" onclick="navigateLightbox(1,event)">
+      <i class="fa-solid fa-chevron-right"></i>
+    </button>
+    <img id="lb-img" src="" alt="" class="rounded-lg shadow-2xl max-h-[85vh] max-w-[90vw] object-contain">
+    <p id="lb-caption" class="absolute bottom-4 left-1/2 -translate-x-1/2 text-white/80 text-sm bg-black/40 px-4 py-1.5 rounded-full"></p>
+  </div>
+
+  <script>
+    document.getElementById('current-date').textContent = new Date().toLocaleDateString('zh-CN', {year:'numeric',month:'long',day:'numeric',weekday:'long'});
+
+    // Lightbox
+    let lbImages = [];
+    let lbIndex = 0;
+    function openLightbox(src, caption, allImages, index) {
+      lbImages = allImages || [src];
+      lbIndex = index || 0;
+      updateLightbox();
+      const lb = document.getElementById('lightbox');
+      lb.classList.remove('hidden');
+      lb.classList.add('flex');
+      document.body.style.overflow = 'hidden';
+    }
+    function closeLightbox(e) {
+      if (e && e.target.tagName === 'IMG') return;
+      const lb = document.getElementById('lightbox');
+      lb.classList.add('hidden');
+      lb.classList.remove('flex');
+      document.body.style.overflow = '';
+    }
+    function navigateLightbox(dir, e) {
+      if (e) e.stopPropagation();
+      if (lbImages.length <= 1) return;
+      lbIndex = (lbIndex + dir + lbImages.length) % lbImages.length;
+      updateLightbox();
+    }
+    function updateLightbox() {
+      document.getElementById('lb-img').src = lbImages[lbIndex];
+      document.getElementById('lb-caption').textContent = lbImages[lbIndex + 1] ? `${lbIndex+1}/${lbImages.length} · ${document.getElementById('lb-img').dataset.caption || ''}` : '';
+      document.getElementById('lb-prev').classList.toggle('hidden', lbImages.length <= 1);
+      document.getElementById('lb-next').classList.toggle('hidden', lbImages.length <= 1);
+    }
+    document.addEventListener('keydown', e => {
+      if (document.getElementById('lightbox').classList.contains('hidden')) return;
+      if (e.key === 'ArrowLeft') navigateLightbox(-1);
+      else if (e.key === 'ArrowRight') navigateLightbox(1);
+      else if (e.key === 'Escape') closeLightbox(e);
+    });
+  </script>
+  {% block extra_js %}{% endblock %}
+</body>
+</html>

+ 132 - 0
templates/evaluations.html

@@ -0,0 +1,132 @@
+{% extends "base.html" %}
+{% block title %}知识评价 - 满意错题本{% endblock %}
+{% block page_title %}知识评价{% endblock %}
+
+{% block content %}
+<div class="flex items-center justify-between mb-6">
+  <div class="flex items-center gap-4">
+    <div class="w-10 h-10 rounded-xl bg-amber-100 flex items-center justify-center text-amber-600">
+      <i class="fa-solid fa-clipboard-check text-lg"></i>
+    </div>
+    <div>
+      <p class="text-sm font-semibold text-gray-700">知识评价</p>
+      <p class="text-xs text-gray-400 mt-0.5">记录老师定期评价结果</p>
+    </div>
+  </div>
+  <button onclick="openModal()" class="inline-flex items-center gap-2 bg-amber-500 text-white px-5 py-2.5 rounded-xl text-sm font-medium hover:bg-amber-600 transition shadow-sm">
+    <i class="fa-solid fa-plus"></i>新增评价
+  </button>
+</div>
+
+<!-- Subject filter -->
+<div class="bg-white rounded-2xl border border-gray-100 p-3 mb-6 flex items-center gap-2 overflow-x-auto">
+  <a href="/evaluations" class="flex-shrink-0 px-4 py-1.5 rounded-xl text-xs font-medium transition {% if not current_subject %}bg-amber-500 text-white shadow-sm{% else %}bg-gray-50 text-gray-500 hover:bg-gray-100{% endif %}">全部</a>
+  {% for s in subjects %}
+  <a href="/evaluations?subject_id={{ s.id }}" class="flex-shrink-0 px-4 py-1.5 rounded-xl text-xs font-medium transition {% if current_subject == s.id %}bg-amber-500 text-white shadow-sm{% else %}bg-gray-50 text-gray-500 hover:bg-gray-100{% endif %}">
+    <span class="w-2 h-2 rounded-full inline-block mr-1.5" style="background-color:{{ s.color }}"></span>{{ s.name }}
+  </a>
+  {% endfor %}
+</div>
+
+{% if evaluations %}
+<div class="space-y-3">
+  {% for ev in evaluations %}
+  {% set subject = subjects | selectattr('id','equalto',ev.subject_id) | first %}
+  <div class="bg-white rounded-2xl border border-gray-100 p-5 hover:shadow-sm transition">
+    <div class="flex items-start gap-4">
+      {% if subject %}
+      <span class="w-3 h-3 rounded-full mt-1.5 flex-shrink-0" style="background-color:{{ subject.color }};box-shadow:0 0 0 3px {{ subject.color }}20"></span>
+      {% endif %}
+      <div class="flex-1 min-w-0">
+        <div class="flex items-center gap-3 mb-1 flex-wrap">
+          <h4 class="font-bold text-gray-800 text-sm">{{ ev.title }}</h4>
+          <span class="text-xs text-gray-400 bg-gray-100 px-2.5 py-0.5 rounded-full">{{ ev.date }}</span>
+          {% if subject %}<span class="text-xs text-gray-400">· {{ subject.name }}</span>{% endif %}
+        </div>
+        {% if ev.content %}
+        <p class="text-sm text-gray-500 leading-relaxed mt-2">{{ ev.content }}</p>
+        {% endif %}
+        {% if ev.filename %}
+        <div class="mt-3 flex items-center gap-2">
+          <a href="/static/uploads/{{ ev.filename }}" target="_blank" class="inline-flex items-center gap-2 text-xs text-amber-600 hover:text-amber-700 bg-amber-50 px-3 py-1.5 rounded-lg transition">
+            <i class="fa-solid fa-paperclip"></i>{{ ev.filename }}
+          </a>
+        </div>
+        {% endif %}
+      </div>
+      <div class="flex-shrink-0 flex items-center gap-3">
+        <span class="text-xs text-gray-300 hidden sm:block">{{ ev.upload_time.strftime('%m-%d %H:%M') }}</span>
+        <form method="POST" action="/evaluation/{{ ev.id }}/delete" onsubmit="return confirm('确定删除此评价?')">
+          <button type="submit" class="text-gray-300 hover:text-red-400 transition" title="删除"><i class="fa-solid fa-trash-can"></i></button>
+        </form>
+      </div>
+    </div>
+  </div>
+  {% endfor %}
+</div>
+{% else %}
+<div class="bg-white rounded-2xl border border-gray-100 p-16 text-center">
+  <div class="text-6xl text-gray-200 mb-4"><i class="fa-solid fa-clipboard"></i></div>
+  <p class="text-gray-400 mb-6">暂无评价记录</p>
+  <button onclick="openModal()" class="inline-flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl text-sm font-medium hover:bg-amber-600 transition">
+    <i class="fa-solid fa-plus"></i>新增第一条评价
+  </button>
+</div>
+{% endif %}
+
+<!-- Modal -->
+<div id="evalModal" class="fixed inset-0 z-50 hidden items-center justify-center modal-overlay bg-black/40">
+  <div class="bg-white rounded-2xl w-full max-w-lg mx-4 shadow-2xl max-h-[90vh] overflow-y-auto">
+    <div class="px-6 py-5 border-b border-gray-100 flex items-center justify-between sticky top-0 bg-white z-10 rounded-t-2xl">
+      <h3 class="font-bold text-gray-800">新增知识评价</h3>
+      <button onclick="closeModal()" class="text-gray-400 hover:text-gray-600"><i class="fa-solid fa-xmark text-lg"></i></button>
+    </div>
+    <form method="POST" action="/evaluations/add" enctype="multipart/form-data" class="p-6 space-y-5">
+      <div>
+        <label class="block text-sm font-medium text-gray-600 mb-2">科目 <span class="text-red-400">*</span></label>
+        <select name="subject_id" required class="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-amber-300 focus:border-amber-400 transition bg-white">
+          <option value="">请选择科目</option>
+          {% for s in subjects %}
+          <option value="{{ s.id }}">{{ s.name }}</option>
+          {% endfor %}
+        </select>
+      </div>
+      <div class="grid grid-cols-2 gap-4">
+        <div>
+          <label class="block text-sm font-medium text-gray-600 mb-2">评价日期</label>
+          <input type="date" name="date" required value="{{ today }}"
+            class="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-amber-300 transition">
+        </div>
+        <div>
+          <label class="block text-sm font-medium text-gray-600 mb-2">标题</label>
+          <input type="text" name="title" required placeholder="简要描述评价内容"
+            class="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-amber-300 transition">
+        </div>
+      </div>
+      <div>
+        <label class="block text-sm font-medium text-gray-600 mb-2">详细内容</label>
+        <textarea name="content" rows="4" placeholder="描述老师的评价内容、知识点掌握情况、改进建议等..."
+          class="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-amber-300 transition resize-none"></textarea>
+      </div>
+      <div>
+        <label class="block text-sm font-medium text-gray-600 mb-2">附件(可选)</label>
+        <input type="file" name="file" accept=".pdf,.jpg,.jpeg,.png,.webp"
+          class="w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-xl file:border-0 file:text-sm file:font-medium file:bg-amber-50 file:text-amber-600 hover:file:bg-amber-100">
+      </div>
+      <div class="flex gap-3 pt-2">
+        <button type="button" onclick="closeModal()" class="flex-1 border border-gray-200 text-gray-600 py-2.5 rounded-xl text-sm font-medium hover:bg-gray-50 transition">取消</button>
+        <button type="submit" class="flex-1 bg-amber-500 text-white py-2.5 rounded-xl text-sm font-medium hover:bg-amber-600 transition">保存评价</button>
+      </div>
+    </form>
+  </div>
+</div>
+{% endblock %}
+
+{% block extra_js %}
+<script>
+const today=new Date().toISOString().slice(0,10);
+document.querySelectorAll('input[name="date"][value]').forEach(el=>{if(!el.value)el.value=today;});
+function openModal(){const m=document.getElementById('evalModal');m.classList.remove('hidden');m.classList.add('flex');}
+function closeModal(){const m=document.getElementById('evalModal');m.classList.add('hidden');m.classList.remove('flex');}
+</script>
+{% endblock %}

+ 142 - 0
templates/index.html

@@ -0,0 +1,142 @@
+{% extends "base.html" %}
+{% block title %}首页 - 满意错题本{% endblock %}
+{% block page_title %}首页{% endblock %}
+
+{% block content %}
+<!-- Stats -->
+<div class="grid grid-cols-4 gap-5 mb-8">
+  <div class="bg-white rounded-2xl p-5 shadow-sm border border-gray-100">
+    <div class="flex items-center justify-between">
+      <div>
+        <p class="text-gray-400 text-xs mb-1">科目总数</p>
+        <p class="text-3xl font-bold text-gray-800">{{ subjects|length }}</p>
+      </div>
+      <div class="w-11 h-11 rounded-xl bg-indigo-50 flex items-center justify-center text-primary text-lg">
+        <i class="fa-solid fa-folder-open"></i>
+      </div>
+    </div>
+  </div>
+  <div class="bg-white rounded-2xl p-5 shadow-sm border border-gray-100">
+    <div class="flex items-center justify-between">
+      <div>
+        <p class="text-gray-400 text-xs mb-1">今日错题</p>
+        <p class="text-3xl font-bold text-gray-800">{{ today_images.values() | map(attribute='__len__') | sum }}</p>
+      </div>
+      <div class="w-11 h-11 rounded-xl bg-pink-50 flex items-center justify-center text-accent text-lg">
+        <i class="fa-solid fa-image"></i>
+      </div>
+    </div>
+  </div>
+  <div class="bg-white rounded-2xl p-5 shadow-sm border border-gray-100">
+    <div class="flex items-center justify-between">
+      <div>
+        <p class="text-gray-400 text-xs mb-1">累计记录</p>
+        <p class="text-3xl font-bold text-gray-800">{{ subjects | map(attribute='error_images') | map('length') | sum }}</p>
+      </div>
+      <div class="w-11 h-11 rounded-xl bg-emerald-50 flex items-center justify-center text-emerald-500 text-lg">
+        <i class="fa-solid fa-images"></i>
+      </div>
+    </div>
+  </div>
+  <div class="bg-white rounded-2xl p-5 shadow-sm border border-gray-100">
+    <div class="flex items-center justify-between">
+      <div>
+        <p class="text-gray-400 text-xs mb-1">知识评价</p>
+        <p class="text-3xl font-bold text-gray-800">{{ evaluations|length }}</p>
+      </div>
+      <div class="w-11 h-11 rounded-xl bg-amber-50 flex items-center justify-center text-amber-500 text-lg">
+        <i class="fa-solid fa-clipboard-list"></i>
+      </div>
+    </div>
+  </div>
+</div>
+
+<!-- 今日错题 -->
+<div class="mb-8">
+  <div class="flex items-center justify-between mb-4">
+    <div class="flex items-center gap-3">
+      <h3 class="text-sm font-semibold text-gray-700">今日错题</h3>
+      <span class="text-xs text-gray-400 bg-gray-100 px-2.5 py-1 rounded-full">{{ today }}</span>
+    </div>
+    <a href="/timeline?date={{ today }}" class="text-xs text-primary hover:underline">查看全部 <i class="fa-solid fa-arrow-right"></i></a>
+  </div>
+  {% if today_images %}
+  <div class="grid grid-cols-3 gap-5">
+    {% for subject_id, imgs in today_images.items() %}
+      {% set subject = subjects | selectattr('id', 'equalto', subject_id) | first %}
+      {% if subject %}
+      <div class="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
+        <div class="px-5 py-3.5 flex items-center gap-3 border-b border-gray-50">
+          <span class="w-2.5 h-2.5 rounded-full flex-shrink-0" style="background-color:{{ subject.color }}"></span>
+          <span class="font-semibold text-gray-700 text-sm">{{ subject.name }}</span>
+          <span class="ml-auto text-xs text-gray-400">{{ imgs|length }}张</span>
+        </div>
+        <div class="p-4 grid grid-cols-2 gap-3">
+          {% for img in imgs[:4] %}
+          <div class="aspect-[3/4] rounded-xl overflow-hidden bg-gray-100 group relative cursor-pointer" onclick="openLightbox('/static/uploads/{{ img.filename }}','{{ img.original_name }}',todayImg{{ subject_id }},{{ loop.index0 }})">
+            <img src="/static/uploads/{{ img.filename }}" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy">
+          </div>
+          {% endfor %}
+        </div>
+        <div class="px-5 py-2.5 bg-gray-50 border-t border-gray-100">
+          <a href="/subject/{{ subject_id }}?date={{ today }}" class="text-xs text-primary font-medium hover:underline flex items-center gap-1">
+            <i class="fa-solid fa-eye"></i>查看��整记录
+          </a>
+        </div>
+      </div>
+      {% endif %}
+    {% endfor %}
+  </div>
+  {% else %}
+  <div class="bg-white rounded-2xl border border-gray-100 p-12 text-center">
+    <div class="text-5xl text-gray-200 mb-4"><i class="fa-solid fa-camera"></i></div>
+    <p class="text-gray-400 mb-4">今日暂无错题记录</p>
+    <a href="/subjects" class="inline-flex items-center gap-2 bg-primary text-white px-5 py-2.5 rounded-xl text-sm font-medium hover:bg-indigo-600 transition">
+      <i class="fa-solid fa-plus"></i>去添加错题
+    </a>
+  </div>
+  {% endif %}
+</div>
+
+<!-- 快捷入口 -->
+<div class="grid grid-cols-4 gap-5">
+  <a href="/subjects" class="bg-white rounded-2xl p-5 shadow-sm border border-gray-100 hover:shadow-md transition group">
+    <div class="w-11 h-11 rounded-xl bg-indigo-50 flex items-center justify-center text-primary text-lg mb-3 group-hover:scale-110 transition">
+      <i class="fa-solid fa-folder-plus"></i>
+    </div>
+    <p class="font-semibold text-gray-700 text-sm">管理科目</p>
+    <p class="text-xs text-gray-400 mt-1">创建、编辑各科目录</p>
+  </a>
+  <a href="/timeline" class="bg-white rounded-2xl p-5 shadow-sm border border-gray-100 hover:shadow-md transition group">
+    <div class="w-11 h-11 rounded-xl bg-pink-50 flex items-center justify-center text-accent text-lg mb-3 group-hover:scale-110 transition">
+      <i class="fa-solid fa-clock-rotate-left"></i>
+    </div>
+    <p class="font-semibold text-gray-700 text-sm">时间线浏览</p>
+    <p class="text-xs text-gray-400 mt-1">按日期查看所有错题</p>
+  </a>
+  <a href="/review" class="bg-white rounded-2xl p-5 shadow-sm border border-gray-100 hover:shadow-md transition group">
+    <div class="w-11 h-11 rounded-xl bg-emerald-50 flex items-center justify-center text-emerald-500 text-lg mb-3 group-hover:scale-110 transition">
+      <i class="fa-solid fa-bookmark"></i>
+    </div>
+    <p class="font-semibold text-gray-700 text-sm">复习汇总</p>
+    <p class="text-xs text-gray-400 mt-1">期中/期末一键汇总</p>
+  </a>
+  <a href="/evaluations" class="bg-white rounded-2xl p-5 shadow-sm border border-gray-100 hover:shadow-md transition group">
+    <div class="w-11 h-11 rounded-xl bg-amber-50 flex items-center justify-center text-amber-500 text-lg mb-3 group-hover:scale-110 transition">
+      <i class="fa-solid fa-chart-line"></i>
+    </div>
+    <p class="font-semibold text-gray-700 text-sm">知识评价</p>
+    <p class="text-xs text-gray-400 mt-1">记录老师定期评价</p>
+  </a>
+</div>
+{% endblock %}
+
+{% block extra_js %}
+<script>
+{% for subject_id, imgs in today_images.items() %}
+const todayImg{{ subject_id }} = [
+  {% for img in imgs %}" /static/uploads/{{ img.filename }}",{% endfor %}
+];
+{% endfor %}
+</script>
+{% endblock %}

+ 81 - 0
templates/review.html

@@ -0,0 +1,81 @@
+{% extends "base.html" %}
+{% block title %}复习汇总 - 满意错题本{% endblock %}
+{% block page_title %}复习汇总{% endblock %}
+
+{% block content %}
+<div class="flex items-center justify-between mb-6">
+  <div class="flex items-center gap-4">
+    <div class="w-10 h-10 rounded-xl bg-emerald-100 flex items-center justify-center text-emerald-600">
+      <i class="fa-solid fa-graduation-cap text-lg"></i>
+    </div>
+    <div>
+      <p class="text-sm font-semibold text-gray-700">期中/期末复习汇总</p>
+      <p class="text-xs text-gray-400 mt-0.5">按日期排序所有错题,方便系统复习</p>
+    </div>
+  </div>
+  <button onclick="window.print()" class="inline-flex items-center gap-2 bg-white border border-gray-200 text-gray-600 px-4 py-2 rounded-xl text-sm font-medium hover:bg-gray-50 transition">
+    <i class="fa-solid fa-print"></i>打印
+  </button>
+</div>
+
+{% if review_data %}
+<div class="space-y-8">
+  {% for subject_id, data in review_data.items() %}
+  {% set subject = data.subject %}
+  <div>
+    <!-- Subject header -->
+    <div class="flex items-center gap-4 mb-4">
+      <span class="w-4 h-4 rounded-full shadow-sm flex-shrink-0" style="background-color:{{ subject.color }};box-shadow:0 0 0 3px {{ subject.color }}20"></span>
+      <h3 class="text-base font-bold text-gray-800">{{ subject.name }}</h3>
+      <span class="text-xs text-gray-400 bg-gray-100 px-3 py-1 rounded-full">{{ data.images|length }} 张错题 · {{ data.dates|length }} 个日期</span>
+      <a href="/subject/{{ subject_id }}" class="ml-auto text-xs text-primary hover:underline bg-indigo-50 px-3 py-1.5 rounded-lg flex items-center gap-1"><i class="fa-solid fa-pen"></i>管理</a>
+    </div>
+    <!-- Timeline of dates -->
+    <div class="bg-white rounded-2xl border border-gray-100 overflow-hidden">
+      {% for date_str in data.dates %}
+      {% set day_images = [] %}
+      {% for img in data.images if img.date == date_str %}{% set x = day_images.append(img) %}{% endfor %}
+      <div class="{% if not loop.last %}border-b border-gray-50{% endif %}">
+        <div class="px-5 py-2.5 bg-gray-50 flex items-center gap-3">
+          <i class="fa-regular fa-calendar text-gray-400 text-sm"></i>
+          <span class="text-sm font-semibold text-gray-600">{{ date_str }}</span>
+          <span class="text-xs text-gray-400">{{ day_images|length }} 张</span>
+        </div>
+        <div class="p-4 grid grid-cols-5 gap-3">
+          {% for img in day_images %}
+          <div class="group relative aspect-[3/4] rounded-xl overflow-hidden bg-gray-100 cursor-pointer" onclick="openLightbox('/static/uploads/{{ img.filename }}','{{ img.original_name }}',reviewLb{{ subject_id }}{{ loop.index0 }},{{ loop.index0 }})">
+            <img src="/static/uploads/{{ img.filename }}" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy">
+            <div class="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition"></div>
+            <div class="absolute bottom-1.5 left-1.5 right-1.5">
+              <p class="text-[10px] text-white/80 truncate px-1 py-0.5 bg-black/30 rounded">{{ img.original_name }}</p>
+            </div>
+          </div>
+          {% endfor %}
+        </div>
+      </div>
+      {% endfor %}
+    </div>
+  </div>
+  {% endfor %}
+</div>
+{% else %}
+<div class="bg-white rounded-2xl border border-gray-100 p-16 text-center">
+  <div class="text-6xl text-gray-200 mb-4"><i class="fa-solid fa-book-open-reader"></i></div>
+  <p class="text-gray-400 mb-2">暂无错题数据</p>
+  <p class="text-sm text-gray-300 mb-6">上传错题后即可在此查看汇总</p>
+  <a href="/subjects" class="inline-flex items-center gap-2 bg-primary text-white px-6 py-3 rounded-xl text-sm font-medium hover:bg-indigo-600 transition">
+    <i class="fa-solid fa-plus"></i>先去添加错题
+  </a>
+</div>
+{% endif %}
+{% endblock %}
+
+{% block extra_js %}
+<script>
+{% for subject_id, data in review_data.items() %}
+const reviewLb{{ subject_id }} = [
+  {% for img in data.images %}" /static/uploads/{{ img.filename }}",{% endfor %}
+];
+{% endfor %}
+</script>
+{% endblock %}

+ 124 - 0
templates/subject_detail.html

@@ -0,0 +1,124 @@
+{% extends "base.html" %}
+{% block title %}{{ subject.name }} - 满意错题本{% endblock %}
+{% block page_title %}{{ subject.name }}{% endblock %}
+
+{% block content %}
+<div class="flex items-center gap-3 mb-6">
+  <a href="/subjects" class="text-sm text-gray-400 hover:text-gray-600 transition flex items-center gap-1">
+    <i class="fa-solid fa-arrow-left"></i>返回
+  </a>
+  <span class="w-2.5 h-2.5 rounded-full" style="background-color:{{ subject.color }}"></span>
+  <span class="text-xs text-gray-400">{{ images|length }} 条记录</span>
+</div>
+
+<!-- Date nav -->
+<div class="bg-white rounded-2xl border border-gray-100 p-4 mb-6">
+  <div class="flex items-center gap-3 overflow-x-auto pb-1">
+    {% if prev_date %}
+    <a href="?date={{ prev_date }}" class="flex-shrink-0 w-9 h-9 rounded-xl bg-gray-50 hover:bg-gray-100 flex items-center justify-center text-gray-500 transition" title="上一日期">
+      <i class="fa-solid fa-chevron-left text-sm"></i>
+    </a>
+    {% else %}
+    <span class="flex-shrink-0 w-9 h-9 rounded-xl bg-gray-50 flex items-center justify-center text-gray-200">
+      <i class="fa-solid fa-chevron-left text-sm"></i>
+    </span>
+    {% endif %}
+
+    {% for d in dates %}
+    <a href="?date={{ d }}" class="date-chip flex-shrink-0 px-3 py-1.5 rounded-xl text-xs font-medium transition {% if d == current_date %}bg-primary text-white shadow-sm{% else %}bg-gray-50 text-gray-500 hover:bg-gray-100{% endif %}">
+      {{ d }}
+    </a>
+    {% endfor %}
+
+    {% if next_date %}
+    <a href="?date={{ next_date }}" class="flex-shrink-0 w-9 h-9 rounded-xl bg-gray-50 hover:bg-gray-100 flex items-center justify-center text-gray-500 transition" title="下一日期">
+      <i class="fa-solid fa-chevron-right text-sm"></i>
+    </a>
+    {% else %}
+    <span class="flex-shrink-0 w-9 h-9 rounded-xl bg-gray-50 flex items-center justify-center text-gray-200">
+      <i class="fa-solid fa-chevron-right text-sm"></i>
+    </span>
+    {% endif %}
+
+    <span class="text-gray-200 flex-shrink-0">|</span>
+    <form method="GET" action="" class="flex-shrink-0 flex items-center gap-2">
+      <input type="date" name="date" value="{{ current_date }}"
+        onchange="this.form.submit()"
+        class="border border-gray-200 rounded-xl px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-primary/30 text-gray-500">
+    </form>
+  </div>
+</div>
+
+<!-- Upload form -->
+<div class="bg-white rounded-2xl border border-dashed border-gray-200 p-5 mb-6">
+  <h4 class="font-semibold text-gray-700 text-sm mb-3"><i class="fa-solid fa-cloud-arrow-up mr-2 text-primary"></i>上传错题图片</h4>
+  <form method="POST" action="/subject/{{ subject.id }}/upload" enctype="multipart/form-data" id="uploadForm">
+    <input type="hidden" name="date" value="{{ current_date }}">
+    <div class="flex items-start gap-4">
+      <label class="flex-1 cursor-pointer">
+        <div class="border-2 border-dashed border-gray-200 rounded-xl p-6 text-center hover:border-primary/50 hover:bg-indigo-50/30 transition" id="dropZone">
+          <i class="fa-solid fa-images text-2xl text-gray-300 mb-2 block"></i>
+          <p class="text-sm text-gray-500">点击选择或拖拽图片到此</p>
+          <p class="text-xs text-gray-300 mt-1">支持 JPG、PNG、WEBP,可多选</p>
+          <input type="file" name="files" multiple accept="image/*" class="hidden" id="fileInput" onchange="updateFileLabel()">
+        </div>
+      </label>
+      <button type="submit" class="flex-shrink-0 bg-primary text-white px-5 py-2.5 rounded-xl text-sm font-medium hover:bg-indigo-600 transition shadow-sm">
+        <i class="fa-solid fa-upload mr-1.5"></i>上传
+      </button>
+    </div>
+    <p id="fileLabel" class="text-xs text-primary mt-2 min-h-[18px]"></p>
+  </form>
+</div>
+
+<!-- Image grid -->
+{% if images %}
+<div class="mb-4 flex items-center justify-between">
+  <h3 class="font-semibold text-gray-700 text-sm">{{ current_date }} 的错题({{ images|length }} 张)</h3>
+  <span class="text-xs text-gray-400">点击可放大查看</span>
+</div>
+<div class="grid grid-cols-4 gap-4">
+  {% for img in images %}
+  <div class="image-card group relative aspect-[3/4] rounded-2xl overflow-hidden bg-gray-100 shadow-sm cursor-pointer" onclick="openLightbox('/static/uploads/{{ img.filename }}','{{ img.original_name }}',lbAllImages,{{ loop.index0 }})">
+    <img src="/static/uploads/{{ img.filename }}" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy">
+    <div class="image-actions absolute inset-0 bg-black/30 opacity-0 transition flex items-center justify-center gap-2">
+      <button onclick="event.stopPropagation();openLightbox('/static/uploads/{{ img.filename }}','{{ img.original_name }}',lbAllImages,{{ loop.index0 }})" class="w-9 h-9 rounded-full bg-white/90 flex items-center justify-center text-gray-700 hover:bg-white transition" title="查看">
+        <i class="fa-solid fa-magnifying-glass-plus text-sm"></i>
+      </button>
+      <form method="POST" action="/image/{{ img.id }}/delete" onsubmit="return confirm('确定删除这张错题图片?')" onclick="event.stopPropagation()">
+        <button type="submit" class="w-9 h-9 rounded-full bg-white/90 flex items-center justify-center text-red-500 hover:bg-red-50 transition" title="删除">
+          <i class="fa-solid fa-trash-can text-sm"></i>
+        </button>
+      </form>
+    </div>
+    <div class="absolute bottom-2 left-2 right-2">
+      <p class="text-[10px] text-white/80 truncate px-1.5 py-0.5 bg-black/30 rounded">{{ img.original_name }}</p>
+    </div>
+  </div>
+  {% endfor %}
+</div>
+{% else %}
+<div class="bg-white rounded-2xl border border-gray-100 p-12 text-center">
+  <div class="text-5xl text-gray-200 mb-4"><i class="fa-regular fa-folder-open"></i></div>
+  <p class="text-gray-400 text-sm">该日期暂无错题记录</p>
+</div>
+{% endif %}
+{% endblock %}
+
+{% block extra_js %}
+<script>
+const lbAllImages = [
+  {% for img in images %}
+  "/static/uploads/{{ img.filename }}",
+  {% endfor %}
+];
+function updateFileLabel(){
+  const f=document.getElementById('fileInput').files;
+  document.getElementById('fileLabel').textContent=f.length?f.length+' 个文件已选择':'';
+}
+const dz=document.getElementById('dropZone');
+dz.addEventListener('dragover',e=>{e.preventDefault();dz.classList.add('border-primary','bg-indigo-50/30');});
+dz.addEventListener('dragleave',()=>dz.classList.remove('border-primary','bg-indigo-50/30'));
+dz.addEventListener('drop',e=>{e.preventDefault();dz.classList.remove('border-primary','bg-indigo-50/30');document.getElementById('fileInput').files=e.dataTransfer.files;updateFileLabel();});
+</script>
+{% endblock %}

+ 98 - 0
templates/subjects.html

@@ -0,0 +1,98 @@
+{% extends "base.html" %}
+{% block title %}科目管理 - 满意错题本{% endblock %}
+{% block page_title %}科目管理{% endblock %}
+
+{% block content %}
+<div class="flex items-center justify-between mb-6">
+  <div>
+    <p class="text-xs text-gray-400">管理你的所有科目</p>
+  </div>
+  <button onclick="openModal()" class="inline-flex items-center gap-2 bg-primary text-white px-5 py-2.5 rounded-xl text-sm font-medium hover:bg-indigo-600 transition shadow-sm">
+    <i class="fa-solid fa-plus"></i>新建科目
+  </button>
+</div>
+
+<!-- Subject list -->
+{% if subjects %}
+<div class="grid grid-cols-3 gap-5">
+  {% for subject in subjects %}
+  <div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-5 hover:shadow-md transition group">
+    <div class="flex items-start justify-between mb-4">
+      <div class="flex items-center gap-3">
+        <span class="w-4 h-4 rounded-full shadow-sm flex-shrink-0" style="background-color:{{ subject.color }};box-shadow:0 0 0 3px {{ subject.color }}20"></span>
+        <h3 class="font-bold text-gray-800 text-base">{{ subject.name }}</h3>
+      </div>
+      <form method="POST" action="/subjects/{{ subject.id }}/delete" onsubmit="return confirm('确定删除「{{ subject.name }}」?将同时删除所有错题记录。')">
+        <button type="submit" class="text-gray-300 hover:text-red-400 transition opacity-0 group-hover:opacity-100" title="删除科目">
+          <i class="fa-solid fa-trash-can"></i>
+        </button>
+      </form>
+    </div>
+    <div class="flex items-center gap-4 text-xs text-gray-400 mb-5">
+      <span><i class="fa-solid fa-image mr-1"></i>{{ subject.error_images|length }} 条记录</span>
+      <span><i class="fa-solid fa-calendar mr-1"></i>{{ subject.created_at.strftime('%Y-%m-%d') }}</span>
+    </div>
+    <a href="/subject/{{ subject.id }}" class="block w-full text-center bg-gray-50 hover:bg-indigo-50 text-gray-600 hover:text-primary py-2.5 rounded-xl text-sm font-medium transition">
+      <i class="fa-solid fa-arrow-right-to-bracket mr-1"></i>进入科目
+    </a>
+  </div>
+  {% endfor %}
+</div>
+{% else %}
+<div class="bg-white rounded-2xl border border-gray-100 p-16 text-center">
+  <div class="text-6xl text-gray-200 mb-4"><i class="fa-solid fa-folder-open"></i></div>
+  <p class="text-gray-400 mb-6">还没有科目,先创建一个吧</p>
+  <button onclick="openModal()" class="inline-flex items-center gap-2 bg-primary text-white px-6 py-3 rounded-xl text-sm font-medium hover:bg-indigo-600 transition">
+    <i class="fa-solid fa-plus"></i>新建第一个科目
+  </button>
+</div>
+{% endif %}
+
+<!-- Create modal -->
+<div id="createModal" class="fixed inset-0 z-50 hidden items-center justify-center modal-overlay bg-black/40">
+  <div class="bg-white rounded-2xl w-full max-w-md mx-4 shadow-2xl">
+    <div class="px-6 py-5 border-b border-gray-100 flex items-center justify-between sticky top-0 bg-white z-10 rounded-t-2xl">
+      <h3 class="font-bold text-gray-800">新建科目</h3>
+      <button onclick="closeModal()" class="text-gray-400 hover:text-gray-600"><i class="fa-solid fa-xmark text-lg"></i></button>
+    </div>
+    <form method="POST" action="/subjects/create" class="p-6">
+      <div class="mb-5">
+        <label class="block text-sm font-medium text-gray-600 mb-2">科目名称</label>
+        <input type="text" name="name" required placeholder="例如:数学、英语、物理..."
+          class="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition">
+      </div>
+      <div class="mb-6">
+        <label class="block text-sm font-medium text-gray-600 mb-2">主题色</label>
+        <div class="flex gap-2 flex-wrap">
+          {% for c in colors %}
+          <label class="cursor-pointer">
+            <input type="radio" name="color" value="{{ c }}" {% if loop.first %}checked{% endif %} class="hidden peer">
+            <span class="w-8 h-8 rounded-full block peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-gray-400 transition hover:scale-110"
+              style="background-color:{{ c }};"></span>
+          </label>
+          {% endfor %}
+        </div>
+      </div>
+      <div class="flex gap-3">
+        <button type="button" onclick="closeModal()" class="flex-1 border border-gray-200 text-gray-600 py-2.5 rounded-xl text-sm font-medium hover:bg-gray-50 transition">取消</button>
+        <button type="submit" class="flex-1 bg-primary text-white py-2.5 rounded-xl text-sm font-medium hover:bg-indigo-600 transition">创建</button>
+      </div>
+    </form>
+  </div>
+</div>
+{% endblock %}
+
+{% block extra_js %}
+<script>
+function openModal(){const m=document.getElementById('createModal');m.classList.remove('hidden');m.classList.add('flex');}
+function closeModal(){const m=document.getElementById('createModal');m.classList.add('hidden');m.classList.remove('flex');}
+document.querySelectorAll('input[name="color"]').forEach(r=>{
+  r.addEventListener('change',function(){
+    document.querySelectorAll('input[name="color"]~span').forEach(s=>s.style.boxShadow='none');
+    this.nextElementSibling.style.boxShadow='0 0 0 3px '+this.value+'40';
+  });
+});
+// Init color ring
+document.querySelector('input[name="color"]:checked')?.nextElementSibling.style.boxShadow='0 0 0 3px '+document.querySelector('input[name="color"]:checked').value+'40';
+</script>
+{% endblock %}

+ 81 - 0
templates/timeline.html

@@ -0,0 +1,81 @@
+{% extends "base.html" %}
+{% block title %}时间线 - 满意错题本{% endblock %}
+{% block page_title %}时间线{% endblock %}
+
+{% block content %}
+<!-- Date nav -->
+<div class="bg-white rounded-2xl border border-gray-100 p-4 mb-6 flex items-center gap-3">
+  {% if prev_date %}
+  <a href="?date={{ prev_date }}" class="flex-shrink-0 w-9 h-9 rounded-xl bg-gray-50 hover:bg-gray-100 flex items-center justify-center text-gray-500 transition" title="前一天">
+    <i class="fa-solid fa-chevron-left text-sm"></i>
+  </a>
+  {% else %}
+  <span class="flex-shrink-0 w-9 h-9 rounded-xl bg-gray-50 flex items-center justify-center text-gray-200">
+    <i class="fa-solid fa-chevron-left text-sm"></i>
+  </span>
+  {% endif %}
+
+  <div class="flex items-center gap-2 overflow-x-auto flex-1 pb-1">
+    {% for d in all_dates %}
+    <a href="?date={{ d }}" class="date-chip flex-shrink-0 px-3 py-1.5 rounded-xl text-xs font-medium transition {% if d == current_date %}bg-primary text-white shadow-sm{% else %}bg-gray-50 text-gray-500 hover:bg-gray-100{% endif %}">
+      {{ d }}
+    </a>
+    {% endfor %}
+  </div>
+
+  {% if next_date %}
+  <a href="?date={{ next_date }}" class="flex-shrink-0 w-9 h-9 rounded-xl bg-gray-50 hover:bg-gray-100 flex items-center justify-center text-gray-500 transition" title="后一天">
+    <i class="fa-solid fa-chevron-right text-sm"></i>
+  </a>
+  {% else %}
+  <span class="flex-shrink-0 w-9 h-9 rounded-xl bg-gray-50 flex items-center justify-center text-gray-200">
+    <i class="fa-solid fa-chevron-right text-sm"></i>
+  </span>
+  {% endif %}
+
+  <input type="date" value="{{ current_date }}" onchange="location.href='/timeline?date='+this.value"
+    class="border border-gray-200 rounded-xl px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-primary/30 text-gray-500 flex-shrink-0">
+</div>
+
+{% if timeline_data %}
+<div class="space-y-5">
+  {% for item in timeline_data %}
+  <div class="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
+    <div class="px-5 py-3.5 flex items-center gap-3 border-b border-gray-50">
+      <span class="w-3 h-3 rounded-full flex-shrink-0" style="background-color:{{ item.subject.color }};box-shadow:0 0 0 3px {{ item.subject.color }}20"></span>
+      <span class="font-bold text-gray-800 text-sm">{{ item.subject.name }}</span>
+      <span class="text-xs text-gray-400 ml-auto">{{ item.images|length }} 张错题</span>
+      <a href="/subject/{{ item.subject.id }}?date={{ current_date }}" class="text-xs text-primary hover:underline ml-2 flex items-center gap-1"><i class="fa-solid fa-arrow-right"></i>进入科目</a>
+    </div>
+    <div class="p-4 grid grid-cols-5 gap-3">
+      {% for img in item.images %}
+      <div class="group relative aspect-[3/4] rounded-xl overflow-hidden bg-gray-100 cursor-pointer" onclick="openLightbox('/static/uploads/{{ img.filename }}','{{ img.original_name }}',lb{{ item.subject.id }},{{ loop.index0 }})">
+        <img src="/static/uploads/{{ img.filename }}" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy">
+        <div class="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition"></div>
+        <div class="absolute bottom-1.5 left-1.5 right-1.5">
+          <p class="text-[10px] text-white/80 truncate px-1 py-0.5 bg-black/30 rounded">{{ img.original_name }}</p>
+        </div>
+      </div>
+      {% endfor %}
+    </div>
+  </div>
+  {% endfor %}
+</div>
+{% else %}
+<div class="bg-white rounded-2xl border border-gray-100 p-16 text-center">
+  <div class="text-6xl text-gray-200 mb-4"><i class="fa-regular fa-calendar-xmark"></i></div>
+  <p class="text-gray-400 mb-2">该日期暂无错题记录</p>
+  <p class="text-sm text-gray-300">请选择其他日期或前往科目页上传错题</p>
+</div>
+{% endif %}
+{% endblock %}
+
+{% block extra_js %}
+<script>
+{% for item in timeline_data %}
+const lb{{ item.subject.id }} = [
+  {% for img in item.images %}" /static/uploads/{{ img.filename }}",{% endfor %}
+];
+{% endfor %}
+</script>
+{% endblock %}

二进制
错题本.db