4 Commit-ok 90759fb84f ... ca54846386

Szerző SHA1 Üzenet Dátum
  sequoia00 ca54846386 update 1 hónapja
  sequoia00 3c5b11d5c1 feat: MySQL数据库支持完整实现 1 hónapja
  sequoia00 f2c639aa5a feat: 添加MySQL数据库支持及setup_mysql.sql建库脚本 1 hónapja
  sequoia00 980c31ad08 feat: 侧边栏增加科目子目录导航与折叠功能 1 hónapja

+ 34 - 0
README.md

@@ -0,0 +1,34 @@
+# 满意错题本
+
+## 启动服务
+
+```bash
+cd /home/myprojector/manyi_review
+python -m uvicorn app:app --host 0.0.0.0 --port 8000
+```
+
+访问 http://localhost:8000
+
+## MySQL 数据库初始化
+
+首次使用或重置数据库时运行:
+
+```bash
+mysql -u root -p < setup_mysql.sql
+```
+
+或通过 MySQL 命令行手动执行 `setup_mysql.sql` 文件内容。
+
+## 功能说明
+
+- **首页**:统计概览 + 今日错题预览
+- **科目管理**:创建/删除科目,点击"科目管理"可在侧边栏展开子目录直接跳转各科
+- **时间线**:按日期浏览所有科目的错题图片
+- **复习汇总**:期中/期末一键汇总所有错题,按日期排序
+- **知识评价**:记录老师定期评价结果,支持附件上传
+
+## 侧边栏使用说明
+
+- 点击"科目管理"可收起/展开其下的科目子列表
+- 点击左上角箭头按钮可收起整个侧边栏
+- 收起状态通过 localStorage 记忆,刷新页面保持状态

BIN
__pycache__/app.cpython-313.pyc


BIN
__pycache__/database.cpython-313.pyc


+ 2 - 1
app.py

@@ -88,7 +88,8 @@ async def subject_detail(
     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
+        "prev_date": prev_date, "next_date": next_date,
+        "subjects": crud.get_all_subjects(session)
     })
 
 # ── 上传错题图片 ────────────────────────────────────────

+ 12 - 2
database.py

@@ -1,9 +1,19 @@
 from sqlmodel import SQLModel, create_engine, Session
 from pathlib import Path
+import os
 
-DATABASE_URL = "sqlite:///./错题本.db"
+MYSQL_USER = os.getenv("DB_USER", "root")
+MYSQL_PASS = os.getenv("DB_PASS", "792199Zhao*")
+MYSQL_HOST = os.getenv("DB_HOST", "localhost")
+MYSQL_PORT = os.getenv("DB_PORT", "3306")
+MYSQL_DB = os.getenv("DB_NAME", "cuowuben")
 
-engine = create_engine(DATABASE_URL, echo=False, connect_args={"check_same_thread": False})
+DATABASE_URL = (
+    f"mysql+pymysql://{MYSQL_USER}:{MYSQL_PASS}@{MYSQL_HOST}:{MYSQL_PORT}/{MYSQL_DB}"
+    "?charset=utf8mb4"
+)
+
+engine = create_engine(DATABASE_URL, echo=False)
 
 def init_db():
     SQLModel.metadata.create_all(engine)

+ 122 - 0
nohup.out

@@ -0,0 +1,122 @@
+INFO:     Started server process [2057198]
+INFO:     Waiting for application startup.
+INFO:     Application startup complete.
+INFO:     Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
+INFO:     223.80.102.178:52947 - "GET / HTTP/1.1" 200 OK
+INFO:     223.80.102.178:52948 - "GET / HTTP/1.1" 200 OK
+INFO:     223.80.102.178:52947 - "GET /subject/1 HTTP/1.1" 200 OK
+INFO:     223.80.102.178:52950 - "GET /subject/1 HTTP/1.1" 200 OK
+INFO:     223.80.102.178:52947 - "GET /static/uploads/66ff343c2260439e9748805c4272dd53.jpg HTTP/1.1" 304 Not Modified
+INFO:     223.80.102.178:52947 - "GET /static/uploads/c6fea31d3f9842ec915f1527848a6f94.jpg HTTP/1.1" 304 Not Modified
+INFO:     223.80.102.178:52947 - "POST /image/2/delete HTTP/1.1" 303 See Other
+INFO:     223.80.102.178:52947 - "GET /subject/1?date=2025-09-11 HTTP/1.1" 200 OK
+INFO:     223.80.102.178:52957 - "GET /subject/1?date=2025%2D09%2D11 HTTP/1.1" 200 OK
+INFO:     223.80.102.178:52947 - "POST /image/5/delete HTTP/1.1" 303 See Other
+INFO:     223.80.102.178:52947 - "GET /subject/1?date=2025-09-11 HTTP/1.1" 200 OK
+INFO:     223.80.102.178:52947 - "GET /static/uploads/e363f6903a6d4c64a780d5cf356dc50e.jpg HTTP/1.1" 304 Not Modified
+INFO:     223.80.102.178:52951 - "GET /static/uploads/4b1722e66ce643f4b32d92fa1e89856e.jpg HTTP/1.1" 304 Not Modified
+INFO:     223.80.102.178:52951 - "GET /subject/2 HTTP/1.1" 200 OK
+INFO:     223.80.102.178:52961 - "GET /subject/2 HTTP/1.1" 200 OK
+INFO:     223.80.102.178:52947 - "GET /static/uploads/42b2762f50d542c08a30b333e6244ea4.jpg HTTP/1.1" 304 Not Modified
+INFO:     223.80.102.178:52951 - "GET /static/uploads/8eaa8e7040bd49d291c45e3edabff622.jpg HTTP/1.1" 304 Not Modified
+INFO:     223.80.102.178:52951 - "POST /image/3/delete HTTP/1.1" 303 See Other
+INFO:     223.80.102.178:52951 - "GET /subject/2?date=2025-09-11 HTTP/1.1" 200 OK
+INFO:     223.80.102.178:52970 - "GET /subject/2?date=2025%2D09%2D11 HTTP/1.1" 200 OK
+INFO:     223.80.102.178:52951 - "POST /image/6/delete HTTP/1.1" 303 See Other
+INFO:     223.80.102.178:52951 - "GET /subject/2?date=2025-09-11 HTTP/1.1" 200 OK
+INFO:     223.80.102.178:52951 - "GET /subject/1 HTTP/1.1" 200 OK
+INFO:     223.80.102.178:52974 - "GET /subject/1 HTTP/1.1" 200 OK
+INFO:     223.80.102.178:52951 - "POST /image/1/delete HTTP/1.1" 303 See Other
+INFO:     223.80.102.178:52951 - "GET /subject/1?date=2025-09-10 HTTP/1.1" 200 OK
+INFO:     223.80.102.178:52977 - "GET /subject/1?date=2025%2D09%2D10 HTTP/1.1" 200 OK
+INFO:     223.80.102.178:52951 - "GET /static/uploads/4b1722e66ce643f4b32d92fa1e89856e.jpg HTTP/1.1" 304 Not Modified
+INFO:     223.80.102.178:52951 - "POST /image/4/delete HTTP/1.1" 303 See Other
+INFO:     223.80.102.178:52951 - "GET /subject/1?date=2025-09-10 HTTP/1.1" 200 OK
+INFO:     223.80.102.178:52951 - "GET /subject/3 HTTP/1.1" 200 OK
+INFO:     223.80.102.178:52983 - "GET /subject/3 HTTP/1.1" 200 OK
+INFO:     223.80.102.178:52951 - "GET /subject/2 HTTP/1.1" 200 OK
+INFO:     223.80.102.178:52987 - "GET /subject/2 HTTP/1.1" 200 OK
+INFO:     223.80.102.178:52951 - "GET /subject/1 HTTP/1.1" 200 OK
+INFO:     223.80.102.178:52989 - "GET /subject/1 HTTP/1.1" 200 OK
+INFO:     223.80.102.178:52951 - "GET /subject/3 HTTP/1.1" 200 OK
+INFO:     223.80.102.178:52991 - "GET /subject/3 HTTP/1.1" 200 OK
+INFO:     223.80.102.178:52951 - "GET / HTTP/1.1" 200 OK
+INFO:     223.80.102.178:52993 - "GET / HTTP/1.1" 200 OK
+INFO:     223.80.102.178:52951 - "GET /subjects HTTP/1.1" 200 OK
+INFO:     223.80.102.178:52995 - "GET /subjects HTTP/1.1" 200 OK
+INFO:     223.80.102.178:52951 - "POST /subjects/3/delete HTTP/1.1" 303 See Other
+INFO:     223.80.102.178:52951 - "GET /subjects HTTP/1.1" 200 OK
+INFO:     115.238.186.72:53226 - "GET /static/uploads/e363f6903a6d4c64a780d5cf356dc50e.jpg HTTP/1.1" 404 Not Found
+INFO:     223.80.102.178:53008 - "POST /subjects/create HTTP/1.1" 303 See Other
+INFO:     223.80.102.178:53008 - "GET /subject/4 HTTP/1.1" 200 OK
+INFO:     223.80.102.178:53009 - "GET /subject/4 HTTP/1.1" 200 OK
+INFO:     223.80.102.178:53008 - "GET / HTTP/1.1" 200 OK
+INFO:     223.80.102.178:53011 - "GET / HTTP/1.1" 200 OK
+INFO:     153.119.114.144:47184 - "GET / HTTP/1.1" 200 OK
+INFO:     153.119.114.144:47060 - "GET /evaluations HTTP/1.1" 200 OK
+INFO:     153.119.114.144:47060 - "POST /evaluation/1/delete HTTP/1.1" 303 See Other
+INFO:     153.119.114.144:47060 - "GET /evaluations?subject_id=1 HTTP/1.1" 200 OK
+INFO:     153.119.114.144:47060 - "POST /evaluation/2/delete HTTP/1.1" 303 See Other
+INFO:     153.119.114.144:47060 - "GET /evaluations?subject_id=1 HTTP/1.1" 200 OK
+INFO:     153.119.114.144:47054 - "POST /evaluations/add HTTP/1.1" 303 See Other
+INFO:     153.119.114.144:47054 - "GET /evaluations?subject_id=1 HTTP/1.1" 200 OK
+INFO:     153.119.114.144:47126 - "GET /static/uploads/eval_56fae51507aa4db89424a9ad51fdd8e4.jpg HTTP/1.1" 200 OK
+INFO:     153.119.114.144:47126 - "GET / HTTP/1.1" 200 OK
+INFO:     160.119.66.27:32940 - "GET / HTTP/1.1" 200 OK
+INFO:     176.65.134.35:57056 - "GET / HTTP/1.1" 200 OK
+WARNING:  Invalid HTTP request received.
+INFO:     85.217.149.61:56816 - "GET / HTTP/1.1" 200 OK
+INFO:     85.217.149.61:56816 - "GET /favicon.ico HTTP/1.1" 404 Not Found
+INFO:     115.238.186.72:45658 - "GET /favicon.ico HTTP/1.1" 404 Not Found
+INFO:     167.94.146.60:61138 - "GET / HTTP/1.1" 200 OK
+WARNING:  Invalid HTTP request received.
+INFO:     167.94.146.60:60162 - "GET / HTTP/1.1" 200 OK
+WARNING:  Invalid HTTP request received.
+INFO:     167.94.146.60:60182 - "GET /favicon.ico HTTP/1.1" 404 Not Found
+WARNING:  Invalid HTTP request received.
+INFO:     167.94.146.60:60272 - "GET /sitemap.xml HTTP/1.1" 404 Not Found
+INFO:     170.106.117.80:38066 - "GET /v1/models HTTP/1.1" 404 Not Found
+WARNING:  Invalid HTTP request received.
+INFO:     47.97.231.15:57822 - "GET / HTTP/1.0" 200 OK
+INFO:     47.97.231.15:57960 - "POST /sdk HTTP/1.1" 404 Not Found
+INFO:     47.97.231.15:57948 - "GET /nmaplowercheck1789156217 HTTP/1.1" 404 Not Found
+INFO:     47.97.231.15:57956 - "GET / HTTP/1.0" 200 OK
+INFO:     47.97.231.15:57972 - "GET /HNAP1 HTTP/1.1" 404 Not Found
+INFO:     47.97.231.15:57978 - "GET /evox/about HTTP/1.1" 404 Not Found
+WARNING:  Invalid HTTP request received.
+WARNING:  Invalid HTTP request received.
+INFO:     47.97.231.15:58612 - "GET / HTTP/1.0" 200 OK
+INFO:     47.97.231.15:58620 - "GET / HTTP/1.1" 200 OK
+WARNING:  Invalid HTTP request received.
+INFO:     5.226.140.90:16076 - "GET / HTTP/1.1" 200 OK
+INFO:     89.37.172.158:1992 - "GET /favicon.ico HTTP/1.1" 404 Not Found
+INFO:     188.240.59.44:1540 - "GET /mcp/ HTTP/1.1" 404 Not Found
+INFO:     195.140.214.19:7086 - "GET / HTTP/1.1" 200 OK
+INFO:     193.32.209.246:2338 - "GET /mcp HTTP/1.1" 404 Not Found
+INFO:     185.216.145.183:4314 - "GET /api/mcp HTTP/1.1" 404 Not Found
+INFO:     185.216.145.188:13286 - "GET /sse HTTP/1.1" 404 Not Found
+INFO:     94.183.174.95:52624 - "GET / HTTP/1.1" 200 OK
+INFO:     47.95.198.175:36886 - "GET / HTTP/1.1" 200 OK
+INFO:     47.95.198.175:36896 - "GET /favicon.ico HTTP/1.1" 404 Not Found
+INFO:     176.65.149.236:56523 - "GET / HTTP/1.1" 200 OK
+INFO:     198.235.24.60:60256 - "GET / HTTP/1.1" 200 OK
+WARNING:  Invalid HTTP request received.
+WARNING:  Invalid HTTP request received.
+INFO:     199.45.154.117:42192 - "GET / HTTP/1.1" 200 OK
+WARNING:  Invalid HTTP request received.
+INFO:     199.45.154.117:51782 - "GET /favicon.ico HTTP/1.1" 404 Not Found
+WARNING:  Invalid HTTP request received.
+INFO:     199.45.154.117:41766 - "GET /sitemap.xml HTTP/1.1" 404 Not Found
+INFO:     114.66.56.106:40602 - "GET / HTTP/1.1" 200 OK
+INFO:     65.49.1.182:50146 - "GET / HTTP/1.1" 200 OK
+INFO:     65.49.1.184:41901 - "GET /favicon.ico HTTP/1.1" 404 Not Found
+WARNING:  Invalid HTTP request received.
+WARNING:  Invalid HTTP request received.
+INFO:     219.146.255.202:59757 - "GET /review HTTP/1.1" 200 OK
+INFO:     219.146.255.202:59758 - "GET /review HTTP/1.1" 200 OK
+INFO:     219.146.255.202:59783 - "GET / HTTP/1.1" 200 OK
+INFO:     219.146.255.202:59784 - "GET / HTTP/1.1" 200 OK
+INFO:     219.146.255.202:59783 - "GET /subjects HTTP/1.1" 200 OK
+INFO:     219.146.255.202:59786 - "GET /subjects HTTP/1.1" 200 OK
+INFO:     219.146.255.202:59783 - "GET / HTTP/1.1" 200 OK
+INFO:     219.146.255.202:59791 - "GET / HTTP/1.1" 200 OK

+ 33 - 0
setup_mysql.sql

@@ -0,0 +1,33 @@
+CREATE DATABASE IF NOT EXISTS cuowuben DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;
+USE cuowuben;
+
+CREATE TABLE IF NOT EXISTS subject (
+    id INT AUTO_INCREMENT PRIMARY KEY,
+    name VARCHAR(100) NOT NULL,
+    color VARCHAR(20) NOT NULL DEFAULT '#6366f1',
+    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS error_image (
+    id INT AUTO_INCREMENT PRIMARY KEY,
+    subject_id INT NOT NULL,
+    date VARCHAR(20) NOT NULL,
+    filename VARCHAR(255) NOT NULL,
+    original_name VARCHAR(255) NOT NULL,
+    upload_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_error_image_date (date),
+    FOREIGN KEY (subject_id) REFERENCES subject(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS evaluation (
+    id INT AUTO_INCREMENT PRIMARY KEY,
+    subject_id INT NOT NULL,
+    date VARCHAR(20) NOT NULL,
+    title VARCHAR(200) NOT NULL,
+    content TEXT,
+    filename VARCHAR(255),
+    upload_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    FOREIGN KEY (subject_id) REFERENCES subject(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+SELECT 'Database and tables created successfully' AS status;

+ 1 - 1
start.sh

@@ -1 +1 @@
-python -m uvicorn app:app --host 0.0.0.0 --port 8000
+nohup python -m uvicorn app:app --host 0.0.0.0 --port 8000 &

BIN
static/uploads/eval_56fae51507aa4db89424a9ad51fdd8e4.jpg


+ 135 - 28
templates/base.html

@@ -33,48 +33,101 @@
     #lightbox img { max-height: 85vh; max-width: 90vw; object-fit: contain; }
     .date-chip { transition: all 0.15s; }
     .date-chip:hover { transform: translateY(-1px); }
+    /* Sidebar collapse */
+    .sidebar { transition: width 0.25s ease; }
+    .sidebar.collapsed { width: 5rem; }
+    .sidebar.collapsed .sidebar-text { display: none; }
+    .sidebar.collapsed .sidebar-sub { display: none; }
+    .sidebar.collapsed .brand-text { display: none; }
+    .sidebar.collapsed .collapse-btn i { transform: rotate(180deg); }
+    .sidebar.collapsed main { margin-left: 5rem !important; }
+    .sidebar-sub { overflow: hidden; transition: max-height 0.25s ease, opacity 0.2s; max-height: 500px; opacity: 1; }
+    .sidebar-sub.collapsed { max-height: 0 !important; opacity: 0 !important; }
+    .subject-toggle { transition: transform 0.2s; display: inline-block; }
+    .subject-toggle.open { transform: rotate(90deg); }
+    .subject-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.75rem; border-radius: 0.5rem; font-size: 0.8rem; color: #6b7280; text-decoration: none; transition: all 0.15s; cursor: pointer; white-space: nowrap; }
+    .subject-item:hover { background: #f3f4f6; color: #374151; }
+    .subject-item.active { background: rgba(99,102,241,0.10); color: #6366f1; }
+    .sidebar-section-label { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.08em; color: #9ca3af; padding: 0.5rem 1rem 0.25rem; font-weight: 600; }
+    .sidebar.collapsed .sidebar-section-label { display: none; }
   </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>
+    <aside class="sidebar w-56 bg-white shadow-lg fixed h-full z-20 flex flex-col border-r border-gray-100">
+      <!-- Header -->
+      <div class="px-4 py-4 flex items-center gap-3">
+        <div class="w-9 h-9 rounded-xl bg-primary flex items-center justify-center text-white text-base flex-shrink-0">
+          <i class="fa-solid fa-book-open"></i>
+        </div>
+        <div class="brand-text overflow-hidden">
+          <h1 class="font-bold text-gray-800 text-sm leading-tight">满意错题本</h1>
+          <p class="text-[10px] text-gray-400 mt-0.5">在线复习助手</p>
         </div>
+        <button id="collapseBtn" class="collapse-btn ml-auto w-7 h-7 rounded-lg flex items-center justify-center text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition flex-shrink-0" title="收起/展开">
+          <i class="fa-solid fa-angles-left text-xs"></i>
+        </button>
       </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>
+
+      <!-- Nav -->
+      <nav class="flex-1 py-2 px-3 overflow-y-auto">
+        <!-- 首页 -->
+        <a href="/" 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 request.url.path == '/' %}active{% endif %}">
+          <i class="fa-solid fa-house w-5 text-center text-base flex-shrink-0"></i>
+          <span class="sidebar-text 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>
+
+        <!-- 科目管理 + 子菜单 -->
+        <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>
+            <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>
+            {% 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 %}">
+              <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>
+            </a>
+            {% endfor %}
+            {% if not subjects %}
+            <div class="sidebar-text text-xs text-gray-300 px-3 py-2">暂无科目</div>
+            {% endif %}
+          </div>
+        </div>
+
+        <!-- 时间线 -->
+        <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>
+          <span class="sidebar-text 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 href="/review" 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 '/review' in request.url.path %}active{% endif %}">
+          <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-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 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>
-      <div class="p-4 border-t border-gray-100">
-        <p class="text-[10px] text-gray-300 text-center">© 2025 满意错题本</p>
+
+      <!-- Footer -->
+      <div class="px-4 py-3 border-t border-gray-100">
+        <p class="sidebar-text text-[10px] text-gray-300 text-center">© 2025 满意错题本</p>
       </div>
     </aside>
 
     <!-- Main content -->
-    <main class="flex-1 ml-56">
+    <main class="flex-1 ml-56 transition-margin">
       <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>
@@ -103,6 +156,62 @@
   <script>
     document.getElementById('current-date').textContent = new Date().toLocaleDateString('zh-CN', {year:'numeric',month:'long',day:'numeric',weekday:'long'});
 
+    // Sidebar collapse
+    (function(){
+      var sidebar = document.querySelector('.sidebar');
+      var main = document.querySelector('main');
+      var btn = document.getElementById('collapseBtn');
+      var icon = btn ? btn.querySelector('i') : null;
+      var collapsed = localStorage.getItem('sidebarCollapsed') === 'true';
+      if(collapsed){
+        sidebar.classList.add('collapsed');
+        main.style.marginLeft = '5rem';
+        if(icon) icon.className = 'fa-solid fa-angles-right text-xs';
+      }
+      if(btn){
+        btn.addEventListener('click', function(){
+          sidebar.classList.toggle('collapsed');
+          if(sidebar.classList.contains('collapsed')){
+            main.style.marginLeft = '5rem';
+            if(icon) icon.className = 'fa-solid fa-angles-right text-xs';
+            localStorage.setItem('sidebarCollapsed', 'true');
+          } else {
+            main.style.marginLeft = '14rem';
+            if(icon) icon.className = 'fa-solid fa-angles-left text-xs';
+            localStorage.setItem('sidebarCollapsed', 'false');
+          }
+        });
+      }
+    })();
+
+    // Subject submenu toggle (collapsed by default)
+    (function(){
+      var submenu = document.getElementById('subjectSubmenu');
+      var icon = document.getElementById('subjectToggleIcon');
+      var btn = document.getElementById('subjectToggleBtn');
+      var saved = localStorage.getItem('subjectMenuOpen');
+      var isOpen = saved === 'true';
+      if(isOpen){
+        submenu.classList.remove('collapsed');
+        if(icon) icon.classList.add('open');
+      }
+      if(btn){
+        btn.addEventListener('click', function(e){
+          e.preventDefault();
+          e.stopPropagation();
+          isOpen = !isOpen;
+          if(isOpen){
+            submenu.classList.remove('collapsed');
+            if(icon) icon.classList.add('open');
+          } else {
+            submenu.classList.add('collapsed');
+            if(icon) icon.classList.remove('open');
+          }
+          localStorage.setItem('subjectMenuOpen', String(isOpen));
+        });
+      }
+    })();
+
     // Lightbox
     let lbImages = [];
     let lbIndex = 0;
@@ -112,14 +221,12 @@
       updateLightbox();
       const lb = document.getElementById('lightbox');
       lb.style.display='flex';
-      
       document.body.style.overflow = 'hidden';
     }
     function closeLightbox(e) {
       if (e && e.target.tagName === 'IMG') return;
       const lb = document.getElementById('lightbox');
       lb.style.display='none';
-      
       document.body.style.overflow = '';
     }
     function navigateLightbox(dir, e) {
@@ -130,7 +237,7 @@
     }
     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-caption').textContent = lbImages[lbIndex + 1] ? `${lbIndex+1}/${lbImages.length}` : '';
       document.getElementById('lb-prev').classList.toggle('hidden', lbImages.length <= 1);
       document.getElementById('lb-next').classList.toggle('hidden', lbImages.length <= 1);
     }