13 Commits ca54846386 ... 83e71421c6

Autor SHA1 Mensaje Fecha
  sequoia00 83e71421c6 提交docker版本,解决日期更新问题 hace 3 semanas
  sequoia00 8e334aa87d 添加新内容 hace 4 semanas
  sequoia00 15217e5866 fix: 删除文字错题时避免删除uploads目录 hace 1 mes
  sequoia00 de725c7be7 feat: 时间线/复习汇总/首页支持文字错题展示 hace 1 mes
  sequoia00 6f0d4ac7f3 feat: 新增文字错题录入功能,修复乱码 hace 1 mes
  sequoia00 6725bf0f24 feat: 新增文字错题录入功能 hace 1 mes
  sequoia00 1e83f302a7 feat: 新增文字错题录入功能 hace 1 mes
  sequoia00 64b6d9535a feat: 新增文字错题录入功能 hace 1 mes
  sequoia00 689ef28d56 feat: 新增文字错题录入功能 hace 1 mes
  sequoia00 841163940b fix: 修复乱码/侧边栏结构/评价图片预览 hace 1 mes
  sequoia00 d30761fd59 fix: 全面修复所有中文乱码 hace 1 mes
  sequoia00 d11d1744c0 fix: 修复乱码/侧边栏顺序/评价图片预览 hace 1 mes
  sequoia00 12eb1a26e4 fix: 侧边栏拆分全部科目+科目管理,评价图片内联预览 hace 1 mes
Se han modificado 56 ficheros con 2628 adiciones y 74 borrados
  1. 5 0
      .dockerignore
  2. 7 0
      .env
  3. 18 0
      Dockerfile
  4. 16 0
      README.md
  5. BIN
      __pycache__/app.cpython-311.pyc
  6. BIN
      __pycache__/app.cpython-313.pyc
  7. BIN
      __pycache__/crud.cpython-313.pyc
  8. BIN
      __pycache__/models.cpython-313.pyc
  9. 3 0
      add_text_fields.sql
  10. 74 7
      app.py
  11. 11 1
      crud.py
  12. 10 0
      docker-compose.yml
  13. 4 2
      models.py
  14. 2034 0
      nohup.out
  15. 6 0
      requirements.txt
  16. BIN
      static/uploads/00cd974662c444f6a0b64eeda73eff9a.jpg
  17. BIN
      static/uploads/14d44e8f70eb4e1f89dc306bd32b82b2.jpg
  18. BIN
      static/uploads/359ffa28167a4e719bf9ba1262756f7f.jpg
  19. BIN
      static/uploads/39677511d52e4ab98bbd92bc2d037115.jpg
  20. BIN
      static/uploads/41de06c32593445eb9bdc461d29f4d62.jpg
  21. BIN
      static/uploads/43d9870c9b944a97b328b828d391ee1f.jpg
  22. BIN
      static/uploads/44a329d2e0fa476180b007d96e2549e1.jpg
  23. BIN
      static/uploads/61d74c330fa74fc8a03e451ae51b9ec9.jpg
  24. BIN
      static/uploads/6f37bfeb56a74495883474425a940ee2.jpg
  25. BIN
      static/uploads/7099cb5a4e804367b386a33da3231fce.jpg
  26. BIN
      static/uploads/7f1c5b89c0ae4def99f8c18a1c894d01.jpg
  27. BIN
      static/uploads/9a5e6a553c574a3aa38047be09ea5e35.jpg
  28. BIN
      static/uploads/d1c6e5189c7f44b3981b5a77bdaed2d5.jpg
  29. BIN
      static/uploads/db65dd7c7d9c413eac55cecbe0090409.jpg
  30. BIN
      static/uploads/e71f2a0642064a84b82ecf89dece55ea.jpg
  31. 24 12
      templates/base.html
  32. 13 1
      templates/evaluations.html
  33. 17 3
      templates/index.html
  34. 33 4
      templates/review.html
  35. 129 32
      templates/subject_detail.html
  36. 53 12
      templates/timeline.html
  37. 171 0
      templates/timeline_new.html
  38. BIN
      vendor/annotated_types-0.8.0-py3-none-any.whl
  39. BIN
      vendor/anyio-4.15.1-py3-none-any.whl
  40. BIN
      vendor/click-8.5.0-py3-none-any.whl
  41. BIN
      vendor/fastapi-0.115.5-py3-none-any.whl
  42. BIN
      vendor/greenlet-3.5.6-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl
  43. BIN
      vendor/h11-0.16.0-py3-none-any.whl
  44. BIN
      vendor/idna-3.20-py3-none-any.whl
  45. BIN
      vendor/jinja2-3.1.4-py3-none-any.whl
  46. BIN
      vendor/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
  47. BIN
      vendor/pydantic-2.13.5-py3-none-any.whl
  48. BIN
      vendor/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
  49. BIN
      vendor/pymysql-1.1.2-py3-none-any.whl
  50. BIN
      vendor/python_multipart-0.0.10-py3-none-any.whl
  51. BIN
      vendor/sqlalchemy-2.0.54-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
  52. BIN
      vendor/sqlmodel-0.0.42-py3-none-any.whl
  53. BIN
      vendor/starlette-0.41.3-py3-none-any.whl
  54. BIN
      vendor/typing_extensions-4.16.0-py3-none-any.whl
  55. BIN
      vendor/typing_inspection-0.4.4-py3-none-any.whl
  56. BIN
      vendor/uvicorn-0.32.0-py3-none-any.whl

+ 5 - 0
.dockerignore

@@ -0,0 +1,5 @@
+.git
+__pycache__
+nohup.out
+*.db
+.env

+ 7 - 0
.env

@@ -0,0 +1,7 @@
+# MySQL 连接配置(host 网络模式下 127.0.0.1 即本机 MySQL)
+DB_HOST=127.0.0.1
+DB_PORT=3306
+DB_USER=root
+DB_PASS=792199Zhao*
+DB_NAME=cuowuben
+APP_PORT=8000

+ 18 - 0
Dockerfile

@@ -0,0 +1,18 @@
+FROM python:3.13-slim
+
+WORKDIR /app
+
+# 离线安装:wheel 预先下载到 vendor/(pip download -r requirements.txt -d vendor/)
+COPY requirements.txt ./
+COPY vendor/ ./vendor/
+RUN pip install --no-cache-dir --no-index --find-links vendor -r requirements.txt
+
+COPY . .
+
+# host 网络模式下 127.0.0.1 即宿主机 MySQL
+ENV DB_HOST=127.0.0.1 \
+    DB_PORT=3306
+
+EXPOSE 8000
+
+CMD ["sh", "-c", "uvicorn app:app --host 0.0.0.0 --port ${APP_PORT:-8000}"]

+ 16 - 0
README.md

@@ -32,3 +32,19 @@ mysql -u root -p < setup_mysql.sql
 - 点击"科目管理"可收起/展开其下的科目子列表
 - 点击左上角箭头按钮可收起整个侧边栏
 - 收起状态通过 localStorage 记忆,刷新页面保持状态
+
+## Docker 一键运行
+
+前提:本机已运行 MySQL,并执行过 `setup_mysql.sql` 初始化 `cuowuben` 库。
+
+```bash
+cd /home/myprojector/manyi_review
+docker compose up -d --build
+```
+
+- 容器使用 host 网络模式,`DB_HOST=127.0.0.1` 直接连接**本机 MySQL**,无需端口映射
+- 数据库连接参数在 `.env` 中配置(DB_HOST / DB_PORT / DB_USER / DB_PASS / DB_NAME)
+- 服务端口由 `.env` 中 `APP_PORT` 控制,默认 8000
+- 依赖通过 `vendor/` 目录离线安装(容器网络不可达 pypi);升级依赖后执行:
+  `pip download -r requirements.txt -d vendor/`
+- 停止/查看:`docker compose down`、`docker logs -f manyi-review`

BIN
__pycache__/app.cpython-311.pyc


BIN
__pycache__/app.cpython-313.pyc


BIN
__pycache__/crud.cpython-313.pyc


BIN
__pycache__/models.cpython-313.pyc


+ 3 - 0
add_text_fields.sql

@@ -0,0 +1,3 @@
+ALTER TABLE error_image 
+ADD COLUMN question VARCHAR(500) NOT NULL DEFAULT '',
+ADD COLUMN answer VARCHAR(1000) NOT NULL DEFAULT '' AFTER original_name;

+ 74 - 7
app.py

@@ -4,7 +4,7 @@ from fastapi.templating import Jinja2Templates
 from fastapi.responses import HTMLResponse, RedirectResponse
 from sqlmodel import Session
 from pathlib import Path
-import uuid, os
+import uuid, os, calendar
 from datetime import datetime
 
 from database import engine, init_db, get_session
@@ -22,7 +22,7 @@ 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)
@@ -71,12 +71,10 @@ async def subject_detail(
     if not subject:
         raise HTTPException(404, "科目不存在")
     dates = crud.get_dates_for_subject(session, subject_id)
+    today = datetime.now().strftime("%Y-%m-%d")
     if not date:
-        date = dates[0] if dates else datetime.now().strftime("%Y-%m-%d")
+        date = today
     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:
@@ -87,7 +85,7 @@ async def subject_detail(
             next_date = dates[idx - 1]
     return templates.TemplateResponse("subject_detail.html", {
         "request": request, "subject": subject, "dates": dates,
-        "current_date": date, "images": images,
+        "current_date": date, "today": today, "images": images,
         "prev_date": prev_date, "next_date": next_date,
         "subjects": crud.get_all_subjects(session)
     })
@@ -115,6 +113,21 @@ async def upload_error_images(
     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("/subject/{subject_id}/text")
+async def add_error_text(
+    subject_id: int,
+    date: str = Form(...),
+    question: str = Form(...),
+    answer: str = Form(default=""),
+    session: Session = Depends(get_session)
+):
+    subject = session.get(Subject, subject_id)
+    if not subject:
+        raise HTTPException(404, "科目不存在")
+    crud.add_error_text(session, subject_id, date, question, answer)
+    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)):
@@ -150,6 +163,60 @@ async def timeline(request: Request, date: str | None = None, session: Session =
     })
 
 # ── 复习汇总 ────────────────────────────────────────────
+# ── 时间线 New(对比样式) ───────────────────────────
+@app.get("/timeline-new", response_class=HTMLResponse)
+async def timeline_new(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")
+
+    nodes = []
+    for d in all_dates:
+        items = []
+        for s in subjects:
+            imgs = crud.get_subject_images(session, s.id, d)
+            if imgs:
+                image_items = [img for img in imgs if img.filename]
+                text_items = [img for img in imgs if not img.filename]
+                items.append({"subject": s, "images": image_items, "texts": text_items})
+        if not items:
+            continue
+        nodes.append({
+            "date": d,
+            "active": d == date,
+            "total": sum(len(it["images"]) + len(it["texts"]) for it in items),
+            "image_count": sum(len(it["images"]) for it in items),
+            "text_count": sum(len(it["texts"]) for it in items),
+            "subject_count": len(items),
+            "preview": next((img for it in items for img in it["images"]), None),
+            "preview_text": next((txt for it in items for txt in it["texts"]), None),
+            "items": items,
+            "week": "周" + "一二三四五六日"[calendar.weekday(*[int(x) for x in d.split("-")])],
+        })
+
+    active_node = next((n for n in nodes if n["active"]), None)
+    total_records = sum(n["total"] for n in nodes)
+    total_images = sum(n["image_count"] for n in nodes)
+    total_texts = sum(n["text_count"] for n in nodes)
+
+    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_new.html", {
+        "request": request, "subjects": subjects, "all_dates": all_dates,
+        "current_date": date, "nodes": nodes,
+        "active_node": active_node, "total_records": total_records,
+        "total_images": total_images, "total_texts": total_texts,
+        "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)

+ 11 - 1
crud.py

@@ -40,6 +40,13 @@ def add_error_images(session: Session, subject_id: int, date: str, filenames: Li
         session.add(img)
     session.commit()
 
+def add_error_text(session: Session, subject_id: int, date: str, question: str, answer: str):
+    img = ErrorImage(subject_id=subject_id, date=date, question=question.strip(), answer=answer.strip())
+    session.add(img)
+    session.commit()
+    session.refresh(img)
+    return img
+
 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:
@@ -80,11 +87,14 @@ def get_review_data(session: Session) -> dict:
 
 def delete_image(session: Session, image_id: int):
     img = session.get(ErrorImage, image_id)
-    if img:
+    if img and img.filename:
         from pathlib import Path
         path = Path("static/uploads") / img.filename
         if path.exists():
             path.unlink()
         session.delete(img)
         session.commit()
+    elif img:
+        session.delete(img)
+        session.commit()
     return img

+ 10 - 0
docker-compose.yml

@@ -0,0 +1,10 @@
+services:
+  app:
+    build: .
+    image: manyi-review:latest
+    container_name: manyi-review
+    restart: unless-stopped
+    # host 网络模式:容器直接使用宿主机网络,可连接本机 MySQL
+    network_mode: host
+    env_file:
+      - .env

+ 4 - 2
models.py

@@ -17,8 +17,10 @@ class ErrorImage(SQLModel, table=True):
     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)
+    filename: str = Field(max_length=255, default="")
+    original_name: str = Field(max_length=255, default="")
+    question: str = Field(max_length=500, default="")
+    answer: str = Field(max_length=1000, default="")
     upload_time: datetime = Field(default_factory=datetime.now)
     subject: Subject = Relationship(back_populates="error_images")
 

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 2034 - 0
nohup.out


+ 6 - 0
requirements.txt

@@ -0,0 +1,6 @@
+fastapi==0.115.5
+uvicorn==0.32.0
+sqlmodel==0.0.42
+PyMySQL==1.1.2
+Jinja2==3.1.4
+python-multipart==0.0.10

BIN
static/uploads/00cd974662c444f6a0b64eeda73eff9a.jpg


BIN
static/uploads/14d44e8f70eb4e1f89dc306bd32b82b2.jpg


BIN
static/uploads/359ffa28167a4e719bf9ba1262756f7f.jpg


BIN
static/uploads/39677511d52e4ab98bbd92bc2d037115.jpg


BIN
static/uploads/41de06c32593445eb9bdc461d29f4d62.jpg


BIN
static/uploads/43d9870c9b944a97b328b828d391ee1f.jpg


BIN
static/uploads/44a329d2e0fa476180b007d96e2549e1.jpg


BIN
static/uploads/61d74c330fa74fc8a03e451ae51b9ec9.jpg


BIN
static/uploads/6f37bfeb56a74495883474425a940ee2.jpg


BIN
static/uploads/7099cb5a4e804367b386a33da3231fce.jpg


BIN
static/uploads/7f1c5b89c0ae4def99f8c18a1c894d01.jpg


BIN
static/uploads/9a5e6a553c574a3aa38047be09ea5e35.jpg


BIN
static/uploads/d1c6e5189c7f44b3981b5a77bdaed2d5.jpg


BIN
static/uploads/db65dd7c7d9c413eac55cecbe0090409.jpg


BIN
static/uploads/e71f2a0642064a84b82ecf89dece55ea.jpg


+ 24 - 12
templates/base.html

@@ -79,17 +79,17 @@
           <span class="sidebar-text text-sm font-medium">首页</span>
         </a>
 
-        <!-- 科目管理 + 子菜单 -->
+        <!-- 全部科目 -->
         <div class="mb-1">
-          <a href="javascript:void(0)" id="subjectToggleBtn" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-xl text-gray-500 hover:bg-gray-50 transition {% if '/subjects' in request.url.path or '/subject/' in request.url.path %}active{% endif %}">
-            <i class="fa-solid fa-folder w-5 text-center text-base flex-shrink-0"></i>
-            <span class="sidebar-text text-sm font-medium">科目管理</span>
+          <a href="javascript:void(0)" id="subjectToggleBtn" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-xl text-gray-500 hover:bg-gray-50 transition">
+            <i class="fa-solid fa-list-check w-5 text-center text-base flex-shrink-0"></i>
+            <span class="sidebar-text text-sm font-medium">全部科目</span>
             <i class="fa-solid fa-chevron-right text-[10px] text-gray-400 ml-auto subject-toggle" id="subjectToggleIcon"></i>
           </a>
           <div id="subjectSubmenu" class="sidebar-sub collapsed mt-1 ml-4 pl-3 border-l border-gray-100">
-            <div class="sidebar-section-label sidebar-text px-1">全部科目</div>
+            <div class="sidebar-section-label sidebar-text px-1">科目列表</div>
             {% for s in subjects %}
-            <a href="/subject/{{ s.id }}" class="subject-item {% if request.url.path == '/subject/' ~ s.id|string %}active{% endif %}" style="{% if '/subject/' ~ s.id|string ~ '?' in request.url.path %}margin-top:0{% endif %}">
+            <a href="/subject/{{ s.id }}" class="subject-item {% if request.url.path == '/subject/' ~ s.id|string %}active{% endif %}">
               <span class="w-2.5 h-2.5 rounded-full flex-shrink-0" style="background-color:{{ s.color }};"></span>
               <span class="sidebar-text truncate">{{ s.name }}</span>
               <span class="sidebar-text ml-auto text-[10px] text-gray-300">{{ s.error_images|length }}</span>
@@ -101,6 +101,24 @@
           </div>
         </div>
 
+        <!-- 科目管理 -->
+        <a href="/subjects" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-xl text-gray-500 hover:bg-gray-50 transition mb-1 {% if '/subjects' in request.url.path %}active{% endif %}">
+          <i class="fa-solid fa-folder w-5 text-center text-base flex-shrink-0"></i>
+          <span class="sidebar-text text-sm font-medium">科目管理</span>
+        </a>
+
+        <!-- 知识评价 -->
+        <a href="/evaluations" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-xl text-gray-500 hover:bg-gray-50 transition mb-1 {% if '/evaluations' in request.url.path %}active{% endif %}">
+          <i class="fa-solid fa-clipboard-check w-5 text-center text-base flex-shrink-0"></i>
+          <span class="sidebar-text text-sm font-medium">知识评价</span>
+        </a>
+
+        <!-- 时间线 New -->
+        <a href="/timeline-new" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-xl text-gray-500 hover:bg-gray-50 transition mb-1 {% if '/timeline-new' in request.url.path %}active{% endif %}">
+          <i class="fa-solid fa-timeline w-5 text-center text-base flex-shrink-0"></i>
+          <span class="sidebar-text text-sm font-medium">时间线 New</span>
+        </a>
+
         <!-- 时间线 -->
         <a href="/timeline" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-xl text-gray-500 hover:bg-gray-50 transition mb-1 {% if '/timeline' in request.url.path %}active{% endif %}">
           <i class="fa-solid fa-clock-rotate-left w-5 text-center text-base flex-shrink-0"></i>
@@ -112,12 +130,6 @@
           <i class="fa-solid fa-graduation-cap w-5 text-center text-base flex-shrink-0"></i>
           <span class="sidebar-text text-sm font-medium">复习汇总</span>
         </a>
-
-        <!-- 知识评价 -->
-        <a href="/evaluations" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-xl text-gray-500 hover:bg-gray-50 transition mb-1 {% if '/evaluations' in request.url.path %}active{% endif %}">
-          <i class="fa-solid fa-clipboard-check w-5 text-center text-base flex-shrink-0"></i>
-          <span class="sidebar-text text-sm font-medium">知识评价</span>
-        </a>
       </nav>
 
       <!-- Footer -->

+ 13 - 1
templates/evaluations.html

@@ -47,12 +47,24 @@
         <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">
+        {% set ext = ev.filename.split('.') | last | lower %}
+        {% if ext in ['jpg','jpeg','png','webp','gif','bmp'] %}
+        <div class="mt-3">
+          <div class="relative rounded-xl overflow-hidden bg-gray-100 cursor-pointer group max-w-md" onclick="openLightbox('/static/uploads/{{ ev.filename }}','{{ ev.title }}',['/static/uploads/{{ ev.filename }}'],0)">
+            <img src="/static/uploads/{{ ev.filename }}" class="w-full h-auto max-h-80 object-contain group-hover:scale-105 transition-transform duration-300" loading="lazy">
+            <div class="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition flex items-center justify-center">
+              <i class="fa-solid fa-magnifying-glass-plus text-white opacity-0 group-hover:opacity-100 text-xl transition"></i>
+            </div>
+          </div>
+        </div>
+        {% else %}
+        <div class="mt-3">
           <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 %}
+        {% 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>

+ 17 - 3
templates/index.html

@@ -71,16 +71,29 @@
           <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>
+        {% set t_imgs = [] %}
+        {% set t_txts = [] %}
+        {% for img in imgs %}{% if img.filename %}{% set x = t_imgs.append(img) %}{% else %}{% set x = t_txts.append(img) %}{% endif %}{% endfor %}
         <div class="p-4 grid grid-cols-2 gap-3">
-          {% for img in imgs[:4] %}
+          {% for img in t_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 %}
+          {% for txt in t_txts[:4 - t_imgs[:4]|length] if t_imgs[:4]|length < 4 %}
+          <div class="aspect-[3/4] rounded-xl overflow-hidden bg-violet-50 border border-violet-100 cursor-pointer hover:shadow-sm transition flex flex-col" onclick="toggleIndexAnswer(this, {{ subject_id }}, {{ loop.index0 }})">
+            <div class="p-3 flex-1">
+              <p class="text-xs font-medium text-gray-700 line-clamp-3 leading-relaxed">{{ txt.question[:40] }}</p>
+            </div>
+            <div class="index-answer hidden px-3 pb-3">
+              <p class="text-[11px] text-gray-500 leading-relaxed">{{ txt.answer }}</p>
+            </div>
+          </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>查看��整记录
+            <i class="fa-solid fa-eye"></i>查看完整记录
           </a>
         </div>
       </div>
@@ -135,7 +148,8 @@
 <script>
 {% for subject_id, imgs in today_images.items() %}
 const todayImg{{ subject_id }} = [
-  {% for img in imgs %}" /static/uploads/{{ img.filename }}",{% endfor %}
+  {% for img in imgs %}{% if img.filename %}"/static/uploads/{{ img.filename }}",{% endif %}
+  {% endfor %}
 ];
 {% endfor %}
 </script>

+ 33 - 4
templates/review.html

@@ -36,14 +36,18 @@
       {% 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 %}">
+        {% set day_imgs = [] %}
+        {% set day_txts = [] %}
+        {% for img in day_images %}{% if img.filename %}{% set x = day_imgs.append(img) %}{% else %}{% set x = day_txts.append(img) %}{% endif %}{% endfor %}
         <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>
+          <span class="text-xs text-gray-400">{{ day_imgs|length }} 张{% if day_txts %} · {{ day_txts|length }} 条文字{% endif %}</span>
         </div>
+        {% if day_imgs %}
         <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 }})">
+          {% for img in day_imgs %}
+          <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 }})">
             <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">
@@ -52,6 +56,26 @@
           </div>
           {% endfor %}
         </div>
+        {% endif %}
+        {% if day_txts %}
+        <div class="px-4 pb-3">
+          <div class="flex items-center gap-2 mb-2">
+            <span class="text-xs text-gray-400 flex items-center gap-1"><i class="fa-solid fa-keyboard"></i>{{ day_txts|length }} 条文字记录</span>
+          </div>
+          <div class="grid grid-cols-5 gap-3">
+            {% for txt in day_txts %}
+            <div class="rounded-xl overflow-hidden bg-violet-50 border border-violet-100 cursor-pointer hover:shadow-sm transition" onclick="toggleReviewAnswer(this)">
+              <div class="p-3">
+                <p class="text-xs font-medium text-gray-700 line-clamp-2 leading-relaxed">{{ txt.question[:40] }}</p>
+              </div>
+              <div class="review-answer hidden px-3 pb-3">
+                <p class="text-[11px] text-gray-500 leading-relaxed">{{ txt.answer }}</p>
+              </div>
+            </div>
+            {% endfor %}
+          </div>
+        </div>
+        {% endif %}
       </div>
       {% endfor %}
     </div>
@@ -74,8 +98,13 @@
 <script>
 {% for subject_id, data in review_data.items() %}
 const reviewLb{{ subject_id }} = [
-  {% for img in data.images %}" /static/uploads/{{ img.filename }}",{% endfor %}
+  {% for img in data.images %}{% if img.filename %}"/static/uploads/{{ img.filename }}",{% endif %}
+  {% endfor %}
 ];
 {% endfor %}
+function toggleReviewAnswer(el) {
+  var ans = el.querySelector('.review-answer');
+  if (ans) ans.classList.toggle('hidden');
+}
 </script>
 {% endblock %}

+ 129 - 32
templates/subject_detail.html

@@ -24,6 +24,11 @@
     </span>
     {% endif %}
 
+    {% if today not in dates %}
+    <a href="?date={{ today }}" class="date-chip flex-shrink-0 px-3 py-1.5 rounded-xl text-xs font-medium transition {% if current_date == today %}bg-primary text-white shadow-sm{% else %}bg-gray-50 text-gray-500 hover:bg-gray-100{% endif %}">
+      今日
+    </a>
+    {% 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 }}
@@ -49,53 +54,121 @@
   </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()">
+<!-- Input tabs -->
+<div class="bg-white rounded-2xl border border-gray-100 p-5 mb-6">
+  <div class="flex gap-2 mb-5">
+    <button id="tabImage" onclick="switchTab('image')" class="tab-btn flex-1 py-2.5 rounded-xl text-sm font-medium transition bg-primary text-white shadow-sm">
+      <i class="fa-solid fa-image mr-1.5"></i>上传错题图片
+    </button>
+    <button id="tabText" onclick="switchTab('text')" class="tab-btn flex-1 py-2.5 rounded-xl text-sm font-medium transition bg-gray-100 text-gray-500 hover:bg-gray-50">
+      <i class="fa-solid fa-keyboard mr-1.5"></i>文字录入错题
+    </button>
+  </div>
+
+  <!-- Image upload panel -->
+  <div id="panelImage">
+    <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>
+
+  <!-- Text input panel -->
+  <div id="panelText" class="hidden">
+    <form method="POST" action="/subject/{{ subject.id }}/text">
+      <input type="hidden" name="date" value="{{ current_date }}">
+      <div class="space-y-4">
+        <div>
+          <label class="block text-sm font-medium text-gray-600 mb-1.5">错题题目 <span class="text-red-400">*</span></label>
+          <textarea name="question" required rows="3" 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 resize-none"></textarea>
         </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>
+          <label class="block text-sm font-medium text-gray-600 mb-1.5">正确答案 / 解析</label>
+          <textarea name="answer" rows="2" 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 resize-none"></textarea>
+        </div>
+        <div class="flex justify-end">
+          <button type="submit" class="bg-primary text-white px-6 py-2.5 rounded-xl text-sm font-medium hover:bg-indigo-600 transition shadow-sm">
+            <i class="fa-solid fa-check mr-1.5"></i>保存错题
+          </button>
+        </div>
+      </div>
+    </form>
+  </div>
 </div>
 
-<!-- Image grid -->
-{% if images %}
+<!-- Content grid -->
+{% set text_errors = images | selectattr('filename', 'equalto', '') | list %}
+{% set img_errors = images | selectattr('filename') | list %}
+{% if img_errors or text_errors %}
 <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>
+  <h3 class="font-semibold text-gray-700 text-sm">{{ current_date }} 的错题({{ images|length }} 条)</h3>
+  <span class="text-xs text-gray-400">{% if img_errors %}{{ img_errors|length }} 张图片{% if text_errors %} · {{ text_errors|length }} 条文字{% endif %}{% else %}{{ text_errors|length }} 条文字记录{% endif %}</span>
 </div>
 <div class="grid grid-cols-4 gap-4">
-  {% for img in images %}
+  {% for img in img_errors %}
   <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="删除">
+    <div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition z-10">
+      <form method="POST" action="/image/{{ img.id }}/delete" onsubmit="return confirm('确定删除这条错题记录?')" onclick="event.stopPropagation()">
+        <button type="submit" class="w-8 h-8 rounded-full bg-white/90 flex items-center justify-center text-red-500 hover:bg-red-50 transition shadow-sm" title="删除">
           <i class="fa-solid fa-trash-can text-sm"></i>
         </button>
       </form>
     </div>
+    <div class="image-actions absolute inset-0 bg-black/30 opacity-0 transition flex items-center justify-center">
+      <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>
+    </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 %}
+  {% for txt in text_errors %}
+  <div class="image-card group relative rounded-2xl overflow-hidden bg-white shadow-sm border border-gray-100 cursor-pointer hover:shadow-md transition" onclick="toggleAnswer({{ txt.id }})">
+    <div class="p-4 flex flex-col h-full">
+      <div class="flex items-center gap-2 mb-2">
+        <span class="w-2 h-2 rounded-full bg-violet-400 flex-shrink-0"></span>
+        <span class="text-[10px] text-gray-400 flex-shrink-0">文字记录</span>
+        <div class="ml-auto flex gap-1 image-actions opacity-0 transition" onclick="event.stopPropagation()">
+          <form method="POST" action="/image/{{ txt.id }}/delete" onsubmit="return confirm('确定删除这条错题记录?')">
+            <button type="submit" class="w-7 h-7 rounded-full bg-gray-100 hover:bg-red-50 flex items-center justify-center text-gray-400 hover:text-red-500 transition" title="删除">
+              <i class="fa-solid fa-trash-can text-xs"></i>
+            </button>
+          </form>
+        </div>
+      </div>
+      <p class="text-sm text-gray-800 font-medium leading-relaxed flex-1 {{ 'line-clamp-4' if not txt.answer else 'line-clamp-3' }}">{{ txt.question }}</p>
+      {% if txt.answer %}
+      <div id="answer-{{ txt.id }}" class="hidden mt-3 pt-3 border-t border-gray-100">
+        <p class="text-xs text-gray-500 leading-relaxed">{{ txt.answer }}</p>
+      </div>
+      {% endif %}
+    </div>
+    <div class="px-4 py-2.5 bg-gray-50 border-t border-gray-100 flex items-center justify-between">
+      <span class="text-[10px] text-gray-400">{% if txt.answer %}展开解析 / 收起解析{% else %}查看题目{% endif %}</span>
+      {% if txt.answer %}
+      <span class="text-[10px] text-primary flex items-center gap-1"><i class="fa-solid fa-lightbulb"></i>有解析</span>
+      {% endif %}
+    </div>
+  </div>
+  {% endfor %}
 </div>
 {% else %}
 <div class="bg-white rounded-2xl border border-gray-100 p-12 text-center">
@@ -107,9 +180,33 @@
 
 {% block extra_js %}
 <script>
+function switchTab(tab) {
+  var pi = document.getElementById('panelImage');
+  var pt = document.getElementById('panelText');
+  var bi = document.getElementById('tabImage');
+  var bt = document.getElementById('tabText');
+  if (tab === 'text') {
+    pi.classList.add('hidden');
+    pt.classList.remove('hidden');
+    bi.classList.remove('bg-primary','text-white','shadow-sm');
+    bi.classList.add('bg-gray-100','text-gray-500','hover:bg-gray-50');
+    bt.classList.remove('bg-gray-100','text-gray-500','hover:bg-gray-50');
+    bt.classList.add('bg-primary','text-white','shadow-sm');
+  } else {
+    pt.classList.add('hidden');
+    pi.classList.remove('hidden');
+    bt.classList.remove('bg-primary','text-white','shadow-sm');
+    bt.classList.add('bg-gray-100','text-gray-500','hover:bg-gray-50');
+    bi.classList.remove('bg-gray-100','text-gray-500','hover:bg-gray-50');
+    bi.classList.add('bg-primary','text-white','shadow-sm');
+  }
+}
+function toggleAnswer(id) {
+  var el = document.getElementById('answer-' + id);
+  if (el) el.classList.toggle('hidden');
+}
 const lbAllImages = [
-  {% for img in images %}
-  "/static/uploads/{{ img.filename }}",
+  {% for img in images %}{% if img.filename %}"/static/uploads/{{ img.filename }}",{% endif %}
   {% endfor %}
 ];
 function updateFileLabel(){

+ 53 - 12
templates/timeline.html

@@ -40,23 +40,50 @@
 {% 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="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden" data-subject="{{ item.subject.id }}">
     <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>
+      <span class="text-xs text-gray-400 ml-auto" id="count-{{ item.subject.id }}">加载中...</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>
+    {% set txt_list = [] %}
+    {% set img_list = [] %}
+    {% for img in item.images %}{% if img.filename %}{% set x = img_list.append(img) %}{% else %}{% set x = txt_list.append(img) %}{% endif %}{% endfor %}
+    <div class="p-4">
+      {% if img_list %}
+      <div class="grid grid-cols-5 gap-3 mb-3">
+        {% for img in img_list %}
+        <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 }}',lbImg{{ 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>
-      {% endfor %}
+      {% endif %}
+      {% if txt_list %}
+      <div class="border-t border-gray-50 pt-3 mt-1">
+        <div class="text-xs text-gray-400 mb-2 flex items-center gap-1"><i class="fa-solid fa-keyboard"></i>{{ txt_list|length }} 条文字记录</div>
+        <div class="grid grid-cols-5 gap-3">
+          {% for txt in txt_list %}
+          <div class="rounded-xl overflow-hidden bg-violet-50 border border-violet-100 cursor-pointer hover:shadow-sm transition" onclick="toggleTimelineAnswer(this)">
+            <div class="p-3">
+              <p class="text-xs font-medium text-gray-700 line-clamp-2 leading-relaxed">{{ txt.question[:40] }}</p>
+            </div>
+            <div class="timeline-answer hidden px-3 pb-3">
+              <p class="text-[11px] text-gray-500 leading-relaxed">{{ txt.answer }}</p>
+            </div>
+          </div>
+          {% endfor %}
+        </div>
+      </div>
+      {% endif %}
+    </div>
+    <div class="px-5 py-2 bg-gray-50 border-t border-gray-50 flex items-center gap-3">
+      <span class="text-xs text-gray-400">{{ img_list|length }} 张错题{% if txt_list %} · {{ txt_list|length }} 条文字{% endif %}</span>
     </div>
   </div>
   {% endfor %}
@@ -73,9 +100,23 @@
 {% block extra_js %}
 <script>
 {% for item in timeline_data %}
-const lb{{ item.subject.id }} = [
-  {% for img in item.images %}" /static/uploads/{{ img.filename }}",{% endfor %}
+const lbImg{{ item.subject.id }} = [
+  {% for img in item.images %}{% if img.filename %}"/static/uploads/{{ img.filename }}",{% endif %}
+  {% endfor %}
 ];
 {% endfor %}
+document.querySelectorAll('[id^="count-"]').forEach(function(el) {
+  var id = el.id.replace('count-', '');
+  var items = document.querySelectorAll('#timeline-subject-' + id + ' .timeline-entry');
+  var imgCount = document.querySelectorAll('#timeline-subject-' + id + ' .img-entry').length;
+  var txtCount = document.querySelectorAll('#timeline-subject-' + id + ' .txt-entry').length;
+  if (txtCount > 0 && imgCount > 0) el.textContent = imgCount + ' 张错题 · ' + txtCount + ' 条文字';
+  else if (txtCount > 0) el.textContent = txtCount + ' 条文字';
+  else el.textContent = imgCount + ' 张错题';
+});
+function toggleTimelineAnswer(el) {
+  var answer = el.querySelector('.timeline-answer');
+  if (answer) answer.classList.toggle('hidden');
+}
 </script>
 {% endblock %}

+ 171 - 0
templates/timeline_new.html

@@ -0,0 +1,171 @@
+{% extends "base.html" %}
+{% block title %}时间线 New - 满意错题本{% endblock %}
+{% block page_title %}时间线 New{% endblock %}
+
+{% block extra_css %}
+<style>
+  .tl-node { position: relative; width: 7.5rem; flex-shrink: 0; display: flex; flex-direction: column; align-items: center; text-decoration: none; }
+  .tl-thumb { width: 4.5rem; height: 5rem; border-radius: 0.75rem; overflow: hidden; background: #f3f4f6; border: 1px solid #e5e7eb; margin-bottom: 0.65rem; position: relative; flex-shrink: 0; display: flex; align-items: center; justify-content: center; transition: box-shadow 0.15s, border-color 0.15s; }
+  .tl-thumb img { width: 100%; height: 100%; object-fit: cover; }
+  .tl-thumb-text { font-size: 10px; color: #6b7280; padding: 0.4rem 0.5rem; line-height: 1.4; text-align: center; word-break: break-word; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
+  .tl-badge { position: absolute; top: 4px; right: 4px; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 9999px; background: rgba(99,102,241,0.9); color: #fff; font-size: 9px; line-height: 16px; text-align: center; z-index: 2; }
+  .tl-dot { width: 14px; height: 14px; border-radius: 9999px; background: #fff; border: 3px solid #d1d5db; z-index: 1; flex-shrink: 0; transition: all 0.15s; }
+  .tl-line { position: absolute; left: 0; right: 0; top: calc(5rem + 0.65rem + 7px - 1px); height: 2px; background: #e5e7eb; pointer-events: none; }
+  .tl-date { margin-top: 0.5rem; }
+  .tl-node:hover .tl-dot { border-color: #6366f1; }
+  .tl-node:hover .tl-thumb { border-color: #c7d2fe; }
+  .tl-node.active .tl-dot { background: #6366f1; border-color: #6366f1; box-shadow: 0 0 0 4px rgba(99,102,241,0.15); }
+  .tl-node.active .tl-thumb { border-color: #6366f1; box-shadow: 0 4px 14px rgba(99,102,241,0.25); }
+  .tl-node.active .tl-week { color: #4f46e5; font-weight: 700; }
+</style>
+{% endblock %}
+
+{% block content %}
+<!-- 统计 + 跳转 -->
+<div class="bg-white rounded-2xl border border-gray-100 p-4 mb-4 flex flex-wrap items-center gap-3">
+  <div class="flex flex-wrap items-center gap-2 text-xs text-gray-500">
+    <span class="px-2.5 py-1 rounded-lg bg-gray-50 border border-gray-100"><b class="text-gray-700">{{ nodes|length }}</b> 天</span>
+    <span class="px-2.5 py-1 rounded-lg bg-gray-50 border border-gray-100"><b class="text-gray-700">{{ total_records }}</b> 条记录</span>
+    <span class="px-2.5 py-1 rounded-lg bg-gray-50 border border-gray-100"><b class="text-gray-700">{{ total_images }}</b> 张错题</span>
+    <span class="px-2.5 py-1 rounded-lg bg-gray-50 border border-gray-100"><b class="text-gray-700">{{ total_texts }}</b> 条文字</span>
+  </div>
+  <div class="ml-auto flex items-center gap-2">
+    <label class="text-xs text-gray-400">跳转日期</label>
+    <input type="date" value="{{ current_date }}" onchange="location.href='/timeline-new?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-600">
+    <a href="/timeline" class="text-xs text-gray-400 hover:text-primary px-2.5 py-1.5 rounded-lg hover:bg-gray-50 transition flex items-center gap-1"><i class="fa-solid fa-clock-rotate-left"></i>旧时间线</a>
+  </div>
+</div>
+
+{% if nodes %}
+<!-- 时间轴 -->
+<div class="bg-white rounded-2xl border border-gray-100 overflow-hidden mb-6">
+  <div class="px-5 pt-4 pb-2 flex items-center justify-between">
+    <h3 class="text-sm font-semibold text-gray-700">时间轴</h3>
+    <span class="text-xs text-gray-400">点击节点查看该日记录</span>
+  </div>
+  <div class="tl-scroll overflow-x-auto">
+    <div class="tl-track relative min-w-max px-8 py-5">
+      <div class="tl-line"></div>
+      <div class="relative flex gap-2">
+        {% for node in nodes|reverse %}
+        <a href="/timeline-new?date={{ node.date }}" class="tl-node {% if node.active %}active{% endif %}">
+          <div class="tl-thumb">
+            {% if node.preview %}
+            <img src="/static/uploads/{{ node.preview.filename }}" alt="{{ node.preview.original_name }}" loading="lazy">
+            {% elif node.preview_text %}
+            <span class="tl-thumb-text">{{ node.preview_text.question[:24] }}</span>
+            {% endif %}
+            {% if node.total %}
+            <span class="tl-badge">{{ node.total }}</span>
+            {% endif %}
+          </div>
+          <div class="tl-dot"></div>
+          <div class="tl-date">
+            <div class="text-xs font-medium text-gray-600">{{ node.date[5:] }}</div>
+            <div class="tl-week text-[10px] text-gray-400 mt-0.5">{{ node.week }}</div>
+          </div>
+        </a>
+        {% endfor %}
+      </div>
+    </div>
+  </div>
+</div>
+
+<!-- 当前节点详情 -->
+{% if active_node %}
+<section class="mb-6">
+  <div class="flex flex-wrap items-center gap-2 mb-4">
+    <i class="fa-solid fa-location-dot text-primary text-sm"></i>
+    <h3 class="text-sm font-semibold text-gray-700">{{ active_node.date }}({{ active_node.week }})记录</h3>
+    <span class="text-xs text-gray-400">共 {{ active_node.total }} 条</span>
+    <div class="ml-auto flex items-center gap-2 text-xs">
+      {% if prev_date %}<a href="/timeline-new?date={{ prev_date }}" class="px-2 py-1 rounded-lg bg-gray-50 text-gray-500 hover:bg-gray-100 transition">前一天</a>{% endif %}
+      {% if next_date %}<a href="/timeline-new?date={{ next_date }}" class="px-2 py-1 rounded-lg bg-gray-50 text-gray-500 hover:bg-gray-100 transition">后一天</a>{% endif %}
+    </div>
+  </div>
+  <div class="space-y-5">
+    {% for item in active_node['items'] %}
+    <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 }} 张错题 · {{ item.texts|length }} 条文字</span>
+        <a href="/subject/{{ item.subject.id }}?date={{ active_node.date }}" class="text-xs text-primary hover:underline ml-2 flex items-center gap-1 flex-shrink-0"><i class="fa-solid fa-arrow-right"></i>进入科目</a>
+      </div>
+      <div class="p-4">
+        {% if item.images %}
+        <div class="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 }}',lbImgNew{{ 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>
+        {% endif %}
+        {% if item.texts %}
+        <div class="{% if item.images %}border-t border-gray-50 pt-3 mt-3{% endif %}">
+          <div class="text-xs text-gray-400 mb-2 flex items-center gap-1"><i class="fa-solid fa-keyboard"></i>{{ item.texts|length }} 条文字记录</div>
+          <div class="grid grid-cols-5 gap-3">
+            {% for txt in item.texts %}
+            <div class="rounded-xl overflow-hidden bg-violet-50 border border-violet-100 cursor-pointer hover:shadow-sm transition" onclick="toggleTimelineAnswer(this)">
+              <div class="p-3">
+                <p class="text-xs font-medium text-gray-700 line-clamp-2 leading-relaxed">{{ txt.question[:40] }}</p>
+              </div>
+              <div class="timeline-answer hidden px-3 pb-3">
+                <p class="text-[11px] text-gray-500 leading-relaxed">{{ txt.answer }}</p>
+              </div>
+            </div>
+            {% endfor %}
+          </div>
+        </div>
+        {% endif %}
+      </div>
+    </div>
+    {% endfor %}
+  </div>
+</section>
+{% 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-calendar-xmark"></i></div>
+  <p class="text-gray-400 mb-2">该日期暂无错题记录</p>
+  <p class="text-sm text-gray-300">点击上方时间轴节点或跳转其他日期</p>
+</div>
+{% endif %}
+{% 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>
+{% if active_node %}
+{% for item in active_node['items'] %}
+var lbImgNew{{ item.subject.id }} = [
+  {% for img in item.images %}"/static/uploads/{{ img.filename }}",{% endfor %}
+];
+{% endfor %}
+{% endif %}
+function toggleTimelineAnswer(el) {
+  var answer = el.querySelector('.timeline-answer');
+  if (answer) answer.classList.toggle('hidden');
+}
+// 加载时将当前选中节点滚动到视野中央
+(function(){
+  var wrap = document.querySelector('.tl-scroll');
+  var active = document.querySelector('.tl-node.active');
+  if (wrap && active) {
+    var target = active.offsetLeft + active.offsetWidth / 2 - wrap.clientWidth / 2;
+    wrap.scrollTo({ left: Math.max(0, target), behavior: 'smooth' });
+  }
+})();
+</script>
+{% endblock %}

BIN
vendor/annotated_types-0.8.0-py3-none-any.whl


BIN
vendor/anyio-4.15.1-py3-none-any.whl


BIN
vendor/click-8.5.0-py3-none-any.whl


BIN
vendor/fastapi-0.115.5-py3-none-any.whl


BIN
vendor/greenlet-3.5.6-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl


BIN
vendor/h11-0.16.0-py3-none-any.whl


BIN
vendor/idna-3.20-py3-none-any.whl


BIN
vendor/jinja2-3.1.4-py3-none-any.whl


BIN
vendor/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl


BIN
vendor/pydantic-2.13.5-py3-none-any.whl


BIN
vendor/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl


BIN
vendor/pymysql-1.1.2-py3-none-any.whl


BIN
vendor/python_multipart-0.0.10-py3-none-any.whl


BIN
vendor/sqlalchemy-2.0.54-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl


BIN
vendor/sqlmodel-0.0.42-py3-none-any.whl


BIN
vendor/starlette-0.41.3-py3-none-any.whl


BIN
vendor/typing_extensions-4.16.0-py3-none-any.whl


BIN
vendor/typing_inspection-0.4.4-py3-none-any.whl


BIN
vendor/uvicorn-0.32.0-py3-none-any.whl


Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio