feat: QuizFrog 完整初始版本

- Next.js 前端:登录注册、错题库、收集、复习、试卷生成、设置
- FastAPI 后端:REST API、JWT 认证、SQLite 存储
- Kimi Vision API 图片 OCR 错题识别
- SM-2 艾宾浩斯间隔复习算法
- ReportLab A4 PDF 试卷生成
- AI 深度解析(可选,Kimi 纯文本模型)
This commit is contained in:
chen guanjun 2026-06-21 10:45:31 +08:00
parent 7746c0929f
commit b32ef10458
58 changed files with 6298 additions and 1 deletions

17
.env.example Normal file
View File

@ -0,0 +1,17 @@
# Database
DATABASE_URL=postgresql+asyncpg://postgres:1234567890@localhost:5432/edu_system
# Kimi API (get from https://platform.moonshot.cn/)
KIMI_API_KEY=
# JWT
JWT_SECRET=change-this-to-a-random-secret-key-in-production
JWT_ALGORITHM=HS256
JWT_EXPIRE_MINUTES=1440
# File Storage
UPLOAD_DIR=./storage/uploads
MAX_FILE_SIZE=10485760
# Frontend
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1

28
.gitignore vendored Normal file
View File

@ -0,0 +1,28 @@
# Dependencies
node_modules/
__pycache__/
*.pyc
.venv/
venv/
# Environment
.env
.env.local
# Build
.next/
out/
dist/
build/
# Storage
storage/uploads/*
!storage/uploads/.gitkeep
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db

View File

@ -1,2 +1,70 @@
# QuizFrog
# 错题蛙 - 小学生错题收集与记忆系统
针对小学生的错题收集、OCR识别、艾宾浩斯复习、A4试卷生成系统。
> 错题变聪明,越练越厉害!
## 技术栈
- **前端**: Next.js 14 + React 18 + Tailwind CSS
- **后端**: Python FastAPI + SQLAlchemy + Alembic
- **数据库**: PostgreSQL 16
- **OCR**: Kimi Vision API (Moonshot AI)
- **PDF**: ReportLab
## 快速开始
### 1. 环境准备
```bash
# 安装 PostgreSQL创建数据库
createdb edu_system
# 复制环境变量
cp .env.example .env
# 编辑 .env填入 KIMI_API_KEY
```
### 2. 后端启动
```bash
cd backend
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
# 初始化数据库表
alembic revision --autogenerate -m "initial"
alembic upgrade head
# 启动后端
uvicorn app.main:app --reload --port 8000
```
### 3. 前端启动
```bash
cd frontend
npm install
npm run dev
```
访问 http://localhost:3000
### 4. Docker 方式(可选)
```bash
cp .env.example .env
docker-compose up -d
```
## 功能
1. **收集错题** - 拍照/上传图片Kimi Vision自动识别题目、答案、错误原因
2. **智能复习** - 艾宾浩斯记忆曲线自动安排复习,翻转卡片式练习
3. **错题库** - 按科目/知识点筛选浏览
4. **试卷打印** - 一键生成A4 PDF试卷支持答案页
## API 文档
启动后端后访问 http://localhost:8000/docs 查看 Swagger 文档

40
backend/alembic.ini Normal file
View File

@ -0,0 +1,40 @@
# A generic, single database configuration.
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = postgresql+asyncpg://postgres:1234567890@localhost:5432/edu_system
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

61
backend/alembic/env.py Normal file
View File

@ -0,0 +1,61 @@
from logging.config import fileConfig
import sys
import os
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from alembic import context
# Add parent directory to path so we can import app
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from app.database import Base, engine
from app.models import User, Mistake, ReviewCard, ReviewLog, ReviewConfig, ExamPaper
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
from sqlalchemy.ext.asyncio import async_engine_from_config
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
import asyncio
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

0
backend/app/__init__.py Normal file
View File

19
backend/app/config.py Normal file
View File

@ -0,0 +1,19 @@
import os
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
DATABASE_URL: str = "sqlite+aiosqlite:///./edu_system.db"
KIMI_API_KEY: str = ""
JWT_SECRET: str = "change-this-to-a-random-secret-key"
JWT_ALGORITHM: str = "HS256"
JWT_EXPIRE_MINUTES: int = 1440 # 24 hours
UPLOAD_DIR: str = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "storage", "uploads")
MAX_FILE_SIZE: int = 10 * 1024 * 1024 # 10MB
class Config:
env_file = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env")
extra = "ignore"
settings = Settings()

33
backend/app/database.py Normal file
View File

@ -0,0 +1,33 @@
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy import event
from app.config import settings
engine_kwargs = {}
if "sqlite" in settings.DATABASE_URL:
engine_kwargs["connect_args"] = {"check_same_thread": False}
engine = create_async_engine(settings.DATABASE_URL, echo=False, **engine_kwargs)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
# Enable foreign keys for SQLite
if "sqlite" in settings.DATABASE_URL:
@event.listens_for(engine.sync_engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
async def get_db() -> AsyncSession:
async with async_session() as session:
try:
yield session
finally:
await session.close()

45
backend/app/main.py Normal file
View File

@ -0,0 +1,45 @@
from __future__ import annotations
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.config import settings
from app.routers import auth, mistakes, ocr, review, exam
app = FastAPI(
title="错题蛙 API",
description="错题收集、OCR识别、艾宾浩斯复习、A4试卷生成",
version="1.0.0",
)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Static files for uploaded images
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_DIR), name="uploads")
# Routers
app.include_router(auth.router, prefix="/api/v1")
app.include_router(mistakes.router, prefix="/api/v1")
app.include_router(ocr.router, prefix="/api/v1")
app.include_router(review.router, prefix="/api/v1")
app.include_router(exam.router, prefix="/api/v1")
@app.get("/")
async def root():
return {"message": "错题收集与记忆系统 API", "version": "1.0.0"}
@app.get("/health")
async def health():
return {"status": "ok"}

View File

@ -0,0 +1,6 @@
from app.models.user import User
from app.models.mistake import Mistake
from app.models.review import ReviewCard, ReviewLog, ReviewConfig
from app.models.exam import ExamPaper
__all__ = ["User", "Mistake", "ReviewCard", "ReviewLog", "ReviewConfig", "ExamPaper"]

View File

@ -0,0 +1,39 @@
import json
from datetime import datetime
from sqlalchemy import String, Integer, ForeignKey, DateTime, func, TypeDecorator, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class ArrayEncoded(TypeDecorator):
"""Array type stored as JSON text for SQLite compatibility."""
impl = Text
cache_ok = True
def process_bind_param(self, value, dialect):
if value is not None:
return json.dumps(value, ensure_ascii=False)
return value
def process_result_value(self, value, dialect):
if value is not None:
return json.loads(value)
return value
class ExamPaper(Base):
__tablename__ = "exam_papers"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(200), nullable=False)
subjects = mapped_column(ArrayEncoded, nullable=False)
mistake_ids = mapped_column(ArrayEncoded, nullable=False)
question_count: Mapped[int] = mapped_column(Integer, nullable=False)
pdf_url: Mapped[str] = mapped_column(String(500), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)

View File

@ -0,0 +1,61 @@
import json
from datetime import datetime
from sqlalchemy import String, Text, Integer, ForeignKey, DateTime, Boolean, Numeric, func, TypeDecorator
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class JSONEncoded(TypeDecorator):
"""JSON type that works with both SQLite and PostgreSQL."""
impl = Text
cache_ok = True
def process_bind_param(self, value, dialect):
if value is not None:
return json.dumps(value, ensure_ascii=False)
return value
def process_result_value(self, value, dialect):
if value is not None:
return json.loads(value)
return value
class Mistake(Base):
__tablename__ = "mistakes"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
subject: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
question_type: Mapped[str] = mapped_column(String(50), nullable=True)
grade_level: Mapped[str] = mapped_column(String(20), nullable=True)
question_text: Mapped[str] = mapped_column(Text, nullable=False)
correct_answer: Mapped[str] = mapped_column(Text, nullable=True)
student_answer: Mapped[str] = mapped_column(Text, nullable=True)
error_analysis: Mapped[str] = mapped_column(Text, nullable=True)
knowledge_point: Mapped[str] = mapped_column(String(200), nullable=True, index=True)
ai_analysis = mapped_column(JSONEncoded, nullable=True)
ai_analysis_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
image_url: Mapped[str] = mapped_column(String(500), nullable=True)
image_thumbnail: Mapped[str] = mapped_column(String(500), nullable=True)
ocr_raw_response = mapped_column(JSONEncoded, nullable=True)
ocr_confidence: Mapped[float] = mapped_column(Numeric(3, 2), nullable=True)
is_manually_edited: Mapped[bool] = mapped_column(Boolean, default=False)
status: Mapped[str] = mapped_column(String(20), default="active", index=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
user = relationship("User", back_populates="mistakes")
review_cards = relationship("ReviewCard", back_populates="mistake", cascade="all, delete-orphan")

View File

@ -0,0 +1,100 @@
import json
from datetime import date, datetime
from sqlalchemy import (
String, Integer, SmallInteger, ForeignKey, DateTime, Date, Numeric, UniqueConstraint, func,
TypeDecorator, Text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class IntegerArrayEncoded(TypeDecorator):
"""Integer array stored as JSON text for SQLite compatibility."""
impl = Text
cache_ok = True
def process_bind_param(self, value, dialect):
if value is not None:
return json.dumps(value)
return value
def process_result_value(self, value, dialect):
if value is not None:
return json.loads(value)
return value
class ReviewCard(Base):
__tablename__ = "review_cards"
__table_args__ = (
UniqueConstraint("user_id", "mistake_id", name="uq_review_card_user_mistake"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
mistake_id: Mapped[int] = mapped_column(Integer, ForeignKey("mistakes.id", ondelete="CASCADE"), nullable=False, index=True)
ease_factor: Mapped[float] = mapped_column(Numeric(4, 2), default=2.50)
interval_days: Mapped[int] = mapped_column(Integer, default=0)
repetitions: Mapped[int] = mapped_column(Integer, default=0)
next_review_date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
last_reviewed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
status: Mapped[str] = mapped_column(String(20), default="new", index=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
user = relationship("User", back_populates="review_cards")
mistake = relationship("Mistake", back_populates="review_cards")
logs = relationship("ReviewLog", back_populates="review_card", cascade="all, delete-orphan")
class ReviewLog(Base):
__tablename__ = "review_logs"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
review_card_id: Mapped[int] = mapped_column(Integer, ForeignKey("review_cards.id", ondelete="CASCADE"), nullable=False, index=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
mistake_id: Mapped[int] = mapped_column(Integer, ForeignKey("mistakes.id", ondelete="CASCADE"), nullable=False)
quality: Mapped[int] = mapped_column(SmallInteger, nullable=False)
review_duration_seconds: Mapped[int] = mapped_column(Integer, nullable=True)
prev_interval: Mapped[int] = mapped_column(Integer, nullable=True)
new_interval: Mapped[int] = mapped_column(Integer, nullable=True)
prev_ease_factor: Mapped[float] = mapped_column(Numeric(4, 2), nullable=True)
new_ease_factor: Mapped[float] = mapped_column(Numeric(4, 2), nullable=True)
reviewed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
review_card = relationship("ReviewCard", back_populates="logs")
class ReviewConfig(Base):
__tablename__ = "review_config"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False)
initial_intervals = mapped_column(IntegerArrayEncoded, default=[1, 2, 4, 7, 15, 30])
default_ease_factor: Mapped[float] = mapped_column(Numeric(4, 2), default=2.50)
min_ease_factor: Mapped[float] = mapped_column(Numeric(4, 2), default=1.30)
max_daily_reviews: Mapped[int] = mapped_column(Integer, default=20)
mastery_threshold: Mapped[int] = mapped_column(Integer, default=3)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)

View File

@ -0,0 +1,25 @@
from datetime import datetime, timezone
from sqlalchemy import String, DateTime, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
display_name: Mapped[str] = mapped_column(String(100), nullable=True)
grade: Mapped[str] = mapped_column(String(20), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
mistakes = relationship("Mistake", back_populates="user", cascade="all, delete-orphan")
review_cards = relationship("ReviewCard", back_populates="user", cascade="all, delete-orphan")

View File

View File

@ -0,0 +1,48 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.user import User
from app.schemas.user import UserCreate, UserLogin, TokenResponse
from app.services.auth_service import hash_password, verify_password, create_token
router = APIRouter(prefix="/auth", tags=["认证"])
@router.post("/register", response_model=TokenResponse)
async def register(data: UserCreate, db: AsyncSession = Depends(get_db)):
existing = await db.execute(select(User).where(User.username == data.username))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="用户名已存在")
user = User(
username=data.username,
password_hash=hash_password(data.password),
display_name=data.display_name,
grade=data.grade,
)
db.add(user)
await db.commit()
await db.refresh(user)
return TokenResponse(
user_id=user.id,
token=create_token(user.id),
display_name=user.display_name,
)
@router.post("/login", response_model=TokenResponse)
async def login(data: UserLogin, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.username == data.username))
user = result.scalar_one_or_none()
if not user or not verify_password(data.password, user.password_hash):
raise HTTPException(status_code=401, detail="用户名或密码错误")
return TokenResponse(
user_id=user.id,
token=create_token(user.id),
display_name=user.display_name,
)

View File

@ -0,0 +1,27 @@
from __future__ import annotations
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.user import User
from app.services.auth_service import decode_token
from sqlalchemy import select
security = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
) -> User:
user_id = decode_token(credentials.credentials)
if user_id is None:
raise HTTPException(status_code=401, detail="无效的认证令牌")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=401, detail="用户不存在")
return user

116
backend/app/routers/exam.py Normal file
View File

@ -0,0 +1,116 @@
from __future__ import annotations
import os
import uuid
from datetime import date
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.models.exam import ExamPaper
from app.models.mistake import Mistake
from app.models.user import User
from app.routers.dependencies import get_current_user
from app.schemas.exam import ExamGenerateRequest, ExamGenerateResponse
from app.services.pdf_service import generate_exam_pdf
router = APIRouter(prefix="/exam", tags=["试卷"])
@router.post("/generate", response_model=ExamGenerateResponse)
async def generate_exam(
data: ExamGenerateRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
# Query mistakes
query = select(Mistake).where(
Mistake.user_id == user.id,
Mistake.status == "active",
)
if data.subjects:
query = query.where(Mistake.subject.in_(data.subjects))
if data.knowledge_points:
query = query.where(Mistake.knowledge_point.in_(data.knowledge_points))
query = query.order_by(Mistake.created_at.desc()).limit(data.question_count)
result = await db.execute(query)
mistakes = result.scalars().all()
if not mistakes:
raise HTTPException(status_code=400, detail="没有符合条件的错题")
# Prepare mistake dicts for PDF
mistake_dicts = [
{
"question_text": m.question_text,
"question_type": m.question_type,
"correct_answer": m.correct_answer,
"student_answer": m.student_answer,
"error_analysis": m.error_analysis,
"knowledge_point": m.knowledge_point,
}
for m in mistakes
]
# Generate PDF
filename = f"exam_{uuid.uuid4().hex[:12]}.pdf"
user_dir = os.path.join(settings.UPLOAD_DIR, str(user.id))
os.makedirs(user_dir, exist_ok=True)
output_path = os.path.join(user_dir, filename)
generate_exam_pdf(
title=data.title,
student_name=user.display_name or user.username,
grade=user.grade or "",
mistakes=mistake_dicts,
include_answers=data.include_answers,
output_path=output_path,
)
# Save record
exam = ExamPaper(
user_id=user.id,
title=data.title,
subjects=data.subjects,
mistake_ids=[m.id for m in mistakes],
question_count=len(mistakes),
pdf_url=f"/uploads/{user.id}/{filename}",
)
db.add(exam)
await db.commit()
await db.refresh(exam)
return ExamGenerateResponse(
exam_id=exam.id,
pdf_url=f"/api/v1/exam/{exam.id}/download",
question_count=len(mistakes),
)
@router.get("/{exam_id}/download")
async def download_exam(
exam_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ExamPaper).where(ExamPaper.id == exam_id, ExamPaper.user_id == user.id)
)
exam = result.scalar_one_or_none()
if not exam:
raise HTTPException(status_code=404, detail="试卷不存在")
# Construct file path from pdf_url
file_path = os.path.join(settings.UPLOAD_DIR, str(user.id), os.path.basename(exam.pdf_url))
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="PDF文件不存在")
return FileResponse(
path=file_path,
media_type="application/pdf",
filename=f"{exam.title}.pdf",
)

View File

@ -0,0 +1,227 @@
from __future__ import annotations
import os
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Query
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.models.mistake import Mistake
from app.models.review import ReviewCard
from app.models.user import User
from app.routers.dependencies import get_current_user
from app.schemas.mistake import (
MistakeCreate, MistakeUpdate, MistakeResponse,
MistakeListResponse, MistakeStats, MistakeOCRResponse, OCRResult,
)
from app.utils.image_utils import preprocess_image
router = APIRouter(prefix="/mistakes", tags=["错题"])
@router.post("", response_model=MistakeResponse)
async def create_mistake(
data: MistakeCreate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
mistake = Mistake(
user_id=user.id,
**data.model_dump(),
)
db.add(mistake)
await db.commit()
await db.refresh(mistake)
# Auto-create review card
from datetime import date
card = ReviewCard(
user_id=user.id,
mistake_id=mistake.id,
next_review_date=date.today(),
status="new",
)
db.add(card)
await db.commit()
return mistake
@router.get("", response_model=MistakeListResponse)
async def list_mistakes(
subject: str | None = None,
status_filter: str | None = Query(None, alias="status"),
knowledge_point: str | None = None,
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = select(Mistake).where(Mistake.user_id == user.id)
count_query = select(func.count(Mistake.id)).where(Mistake.user_id == user.id)
if subject:
query = query.where(Mistake.subject == subject)
count_query = count_query.where(Mistake.subject == subject)
if status_filter:
query = query.where(Mistake.status == status_filter)
count_query = count_query.where(Mistake.status == status_filter)
if knowledge_point:
query = query.where(Mistake.knowledge_point == knowledge_point)
count_query = count_query.where(Mistake.knowledge_point == knowledge_point)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
query = query.order_by(Mistake.created_at.desc()).offset((page - 1) * per_page).limit(per_page)
result = await db.execute(query)
items = result.scalars().all()
return MistakeListResponse(items=items, total=total, page=page, per_page=per_page)
@router.get("/stats", response_model=MistakeStats)
async def get_stats(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
# Total count
total_q = await db.execute(
select(func.count(Mistake.id)).where(Mistake.user_id == user.id)
)
total = total_q.scalar() or 0
# By subject
subj_q = await db.execute(
select(Mistake.subject, func.count(Mistake.id))
.where(Mistake.user_id == user.id)
.group_by(Mistake.subject)
)
by_subject = dict(subj_q.all())
# By knowledge point
kp_q = await db.execute(
select(Mistake.knowledge_point, func.count(Mistake.id))
.where(Mistake.user_id == user.id, Mistake.knowledge_point.isnot(None))
.group_by(Mistake.knowledge_point)
)
by_kp = dict(kp_q.all())
# Mastered count
mastered_q = await db.execute(
select(func.count(ReviewCard.id))
.where(ReviewCard.user_id == user.id, ReviewCard.status == "mastered")
)
mastered = mastered_q.scalar() or 0
# Today's review count
from datetime import date
today_q = await db.execute(
select(func.count(ReviewCard.id))
.where(
ReviewCard.user_id == user.id,
ReviewCard.next_review_date <= date.today(),
ReviewCard.status.in_(["new", "learning", "review"]),
)
)
review_today = today_q.scalar() or 0
return MistakeStats(
total_mistakes=total,
by_subject=by_subject,
by_knowledge_point=by_kp,
mastered_count=mastered,
review_count_today=review_today,
)
@router.get("/{mistake_id}", response_model=MistakeResponse)
async def get_mistake(
mistake_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Mistake).where(Mistake.id == mistake_id, Mistake.user_id == user.id)
)
mistake = result.scalar_one_or_none()
if not mistake:
raise HTTPException(status_code=404, detail="错题不存在")
return mistake
@router.put("/{mistake_id}", response_model=MistakeResponse)
async def update_mistake(
mistake_id: int,
data: MistakeUpdate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Mistake).where(Mistake.id == mistake_id, Mistake.user_id == user.id)
)
mistake = result.scalar_one_or_none()
if not mistake:
raise HTTPException(status_code=404, detail="错题不存在")
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(mistake, field, value)
await db.commit()
await db.refresh(mistake)
return mistake
@router.delete("/{mistake_id}")
async def delete_mistake(
mistake_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Mistake).where(Mistake.id == mistake_id, Mistake.user_id == user.id)
)
mistake = result.scalar_one_or_none()
if not mistake:
raise HTTPException(status_code=404, detail="错题不存在")
await db.delete(mistake)
await db.commit()
return {"detail": "已删除"}
@router.post("/{mistake_id}/analyze")
async def analyze_mistake(
mistake_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Trigger AI deep analysis for a specific mistake (optional, costs API tokens)."""
result = await db.execute(
select(Mistake).where(Mistake.id == mistake_id, Mistake.user_id == user.id)
)
mistake = result.scalar_one_or_none()
if not mistake:
raise HTTPException(status_code=404, detail="错题不存在")
from datetime import datetime, timezone
from app.services.ai_analysis import generate_analysis
analysis = await generate_analysis(
question_text=mistake.question_text,
subject=mistake.subject,
correct_answer=mistake.correct_answer,
student_answer=mistake.student_answer,
error_analysis=mistake.error_analysis,
)
mistake.ai_analysis = analysis
mistake.ai_analysis_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(mistake)
return {"ai_analysis": analysis}

View File

@ -0,0 +1,86 @@
from __future__ import annotations
import os
import uuid
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.models.mistake import Mistake
from app.models.review import ReviewCard
from app.models.user import User
from app.routers.dependencies import get_current_user
from app.schemas.mistake import MistakeOCRResponse, MistakeResponse, OCRResult
from app.utils.image_utils import preprocess_image
from app.services.ocr_service import recognize_mistake
from datetime import date
router = APIRouter(prefix="/mistakes", tags=["OCR"])
@router.post("/ocr", response_model=MistakeOCRResponse)
async def ocr_mistake(
image: UploadFile = File(...),
subject: str | None = Form(None),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
# Read and preprocess image
raw_bytes = await image.read()
if len(raw_bytes) > settings.MAX_FILE_SIZE:
raise HTTPException(status_code=400, detail="图片大小超过10MB限制")
processed_bytes, media_type = await preprocess_image(raw_bytes, image.filename or "upload.jpg")
# Save original image
ext = "jpg"
filename = f"{uuid.uuid4().hex}.{ext}"
user_dir = os.path.join(settings.UPLOAD_DIR, str(user.id))
os.makedirs(user_dir, exist_ok=True)
filepath = os.path.join(user_dir, filename)
with open(filepath, "wb") as f:
f.write(processed_bytes)
image_url = f"/uploads/{user.id}/{filename}"
# Call Claude Vision API
ocr_result = await recognize_mistake(processed_bytes, media_type, subject or "")
# Create mistake record
mistake = Mistake(
user_id=user.id,
subject=ocr_result.get("subject") or subject or "数学",
question_type=ocr_result.get("question_type"),
question_text=ocr_result.get("question_text") or "识别失败,请手动输入",
correct_answer=ocr_result.get("correct_answer"),
student_answer=ocr_result.get("student_answer"),
error_analysis=ocr_result.get("error_analysis"),
knowledge_point=ocr_result.get("knowledge_point"),
image_url=image_url,
ocr_raw_response=ocr_result,
ocr_confidence=ocr_result.get("confidence", 0.5),
)
db.add(mistake)
await db.commit()
await db.refresh(mistake)
# Create review card
card = ReviewCard(
user_id=user.id,
mistake_id=mistake.id,
next_review_date=date.today(),
status="new",
)
db.add(card)
await db.commit()
confidence = ocr_result.get("confidence", 0.5)
return MistakeOCRResponse(
id=mistake.id,
ocr_result=OCRResult(**ocr_result),
needs_review=confidence < 0.8,
full_mistake=mistake,
)

View File

@ -0,0 +1,307 @@
from __future__ import annotations
from datetime import date, datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.mistake import Mistake
from app.models.review import ReviewCard, ReviewLog, ReviewConfig
from app.models.user import User
from app.routers.dependencies import get_current_user
from app.schemas.review import (
ReviewCardResponse, ReviewTodayResponse, ReviewAnswerRequest, ReviewAnswerResponse,
ReviewProgressResponse, ReviewConfigUpdate, ReviewConfigResponse, UpcomingDay,
)
from app.schemas.mistake import MistakeResponse
from app.services.spaced_repetition import calculate_next_review, ReviewState, ReviewConfig as SRConfig
router = APIRouter(prefix="/review", tags=["复习"])
def _get_sr_config(config_row: ReviewConfig | None) -> SRConfig:
if config_row:
return SRConfig(
initial_intervals=config_row.initial_intervals or [1, 2, 4, 7, 15, 30],
default_ease_factor=float(config_row.default_ease_factor or 2.5),
min_ease_factor=float(config_row.min_ease_factor or 1.3),
mastery_threshold=config_row.mastery_threshold or 3,
)
return SRConfig(
initial_intervals=[1, 2, 4, 7, 15, 30],
default_ease_factor=2.50,
min_ease_factor=1.30,
mastery_threshold=3,
)
@router.get("/today", response_model=ReviewTodayResponse)
async def get_today_reviews(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
today = date.today()
# Get user config for max daily reviews
config_q = await db.execute(select(ReviewConfig).where(ReviewConfig.user_id == user.id))
config_row = config_q.scalar_one_or_none()
max_reviews = config_row.max_daily_reviews if config_row else 20
# Count new and review cards due today
due_q = await db.execute(
select(ReviewCard)
.where(
ReviewCard.user_id == user.id,
ReviewCard.next_review_date <= today,
ReviewCard.status.in_(["new", "learning", "review"]),
)
.order_by(
ReviewCard.status.desc(), # "new" > "review" alphabetically, but we want review first
ReviewCard.next_review_date.asc(),
)
.limit(max_reviews)
)
cards = due_q.scalars().all()
# Count totals
total_q = await db.execute(
select(func.count(ReviewCard.id)).where(
ReviewCard.user_id == user.id,
ReviewCard.next_review_date <= today,
ReviewCard.status.in_(["new", "learning", "review"]),
)
)
due_count = total_q.scalar() or 0
new_count = sum(1 for c in cards if c.status == "new")
review_count = sum(1 for c in cards if c.status != "new")
# Build response with mistake data
card_responses = []
for card in cards:
mistake_q = await db.execute(select(Mistake).where(Mistake.id == card.mistake_id))
mistake = mistake_q.scalar_one()
card_responses.append(
ReviewCardResponse(
review_card_id=card.id,
mistake=MistakeResponse.model_validate(mistake),
review_number=card.repetitions,
interval_days=card.interval_days,
is_new=card.status == "new",
)
)
return ReviewTodayResponse(
due_count=due_count,
new_count=new_count,
review_count=review_count,
cards=card_responses,
)
@router.post("/{card_id}/answer", response_model=ReviewAnswerResponse)
async def submit_answer(
card_id: int,
data: ReviewAnswerRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ReviewCard).where(ReviewCard.id == card_id, ReviewCard.user_id == user.id)
)
card = result.scalar_one_or_none()
if not card:
raise HTTPException(status_code=404, detail="复习卡片不存在")
# Get config
config_q = await db.execute(select(ReviewConfig).where(ReviewConfig.user_id == user.id))
config_row = config_q.scalar_one_or_none()
sr_config = _get_sr_config(config_row)
# Calculate next review
state = ReviewState(
ease_factor=float(card.ease_factor),
interval_days=card.interval_days,
repetitions=card.repetitions,
next_review_date=card.next_review_date,
)
new_state = calculate_next_review(state, data.quality, sr_config)
# Log the review
log = ReviewLog(
review_card_id=card.id,
user_id=user.id,
mistake_id=card.mistake_id,
quality=data.quality,
review_duration_seconds=data.duration_seconds,
prev_interval=card.interval_days,
new_interval=new_state.interval_days,
prev_ease_factor=card.ease_factor,
new_ease_factor=new_state.ease_factor,
)
db.add(log)
# Update card
card.ease_factor = new_state.ease_factor
card.interval_days = new_state.interval_days
card.repetitions = new_state.repetitions
card.next_review_date = new_state.next_review_date
card.last_reviewed_at = datetime.now(timezone.utc)
card.status = "mastered" if (
new_state.repetitions >= sr_config.mastery_threshold
and new_state.interval_days >= 30
) else ("review" if new_state.repetitions > 0 else "learning")
await db.commit()
# Count remaining today
today = date.today()
remaining_q = await db.execute(
select(func.count(ReviewCard.id)).where(
ReviewCard.user_id == user.id,
ReviewCard.next_review_date <= today,
ReviewCard.status.in_(["new", "learning", "review"]),
ReviewCard.id != card.id,
)
)
remaining = remaining_q.scalar() or 0
return ReviewAnswerResponse(
next_review_date=new_state.next_review_date,
new_interval=new_state.interval_days,
new_ease_factor=new_state.ease_factor,
remaining_today=remaining,
)
@router.get("/progress", response_model=ReviewProgressResponse)
async def get_progress(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
today = date.today()
# Today's completed reviews
completed_q = await db.execute(
select(func.count(ReviewLog.id)).where(
ReviewLog.user_id == user.id,
func.date(ReviewLog.reviewed_at) == today,
)
)
today_completed = completed_q.scalar() or 0
# Today's total due
total_due_q = await db.execute(
select(func.count(ReviewCard.id)).where(
ReviewCard.user_id == user.id,
ReviewCard.next_review_date <= today,
ReviewCard.status.in_(["new", "learning", "review"]),
)
)
today_total = total_due_q.scalar() + today_completed
# Total reviews ever
total_reviews_q = await db.execute(
select(func.count(ReviewLog.id)).where(ReviewLog.user_id == user.id)
)
total_reviews = total_reviews_q.scalar() or 0
# Streak calculation (consecutive days with at least 1 review)
streak = 0
check_date = today
for _ in range(365):
day_q = await db.execute(
select(func.count(ReviewLog.id)).where(
ReviewLog.user_id == user.id,
func.date(ReviewLog.reviewed_at) == check_date,
)
)
if day_q.scalar() > 0:
streak += 1
check_date = check_date.fromordinal(check_date.toordinal() - 1)
else:
break
# Upcoming 7 days
upcoming = []
for i in range(7):
d = date.fromordinal(today.toordinal() + i)
cnt_q = await db.execute(
select(func.count(ReviewCard.id)).where(
ReviewCard.user_id == user.id,
ReviewCard.next_review_date == d,
ReviewCard.status.in_(["new", "learning", "review"]),
)
)
upcoming.append(UpcomingDay(date=d, count=cnt_q.scalar() or 0))
return ReviewProgressResponse(
today_total=today_total,
today_completed=today_completed,
streak_days=streak,
total_reviews=total_reviews,
upcoming=upcoming,
)
@router.get("/config", response_model=ReviewConfigResponse)
async def get_config(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(ReviewConfig).where(ReviewConfig.user_id == user.id))
config = result.scalar_one_or_none()
if not config:
return ReviewConfigResponse(
initial_intervals=[1, 2, 4, 7, 15, 30],
default_ease_factor=2.50,
min_ease_factor=1.30,
max_daily_reviews=20,
mastery_threshold=3,
)
return ReviewConfigResponse(
initial_intervals=config.initial_intervals or [1, 2, 4, 7, 15, 30],
default_ease_factor=float(config.default_ease_factor),
min_ease_factor=float(config.min_ease_factor),
max_daily_reviews=config.max_daily_reviews,
mastery_threshold=config.mastery_threshold,
)
@router.put("/config", response_model=ReviewConfigResponse)
async def update_config(
data: ReviewConfigUpdate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(ReviewConfig).where(ReviewConfig.user_id == user.id))
config = result.scalar_one_or_none()
if not config:
config = ReviewConfig(user_id=user.id)
db.add(config)
if data.initial_intervals is not None:
config.initial_intervals = data.initial_intervals
if data.default_ease_factor is not None:
config.default_ease_factor = data.default_ease_factor
if data.min_ease_factor is not None:
config.min_ease_factor = data.min_ease_factor
if data.max_daily_reviews is not None:
config.max_daily_reviews = data.max_daily_reviews
if data.mastery_threshold is not None:
config.mastery_threshold = data.mastery_threshold
await db.commit()
await db.refresh(config)
return ReviewConfigResponse(
initial_intervals=config.initial_intervals or [1, 2, 4, 7, 15, 30],
default_ease_factor=float(config.default_ease_factor),
min_ease_factor=float(config.min_ease_factor),
max_daily_reviews=config.max_daily_reviews,
mastery_threshold=config.mastery_threshold,
)

View File

@ -0,0 +1,15 @@
from app.schemas.user import UserCreate, UserLogin, UserResponse, TokenResponse
from app.schemas.mistake import MistakeCreate, MistakeUpdate, MistakeResponse, MistakeListResponse, MistakeStats
from app.schemas.review import (
ReviewCardResponse, ReviewTodayResponse, ReviewAnswerRequest, ReviewAnswerResponse,
ReviewProgressResponse, ReviewConfigUpdate, ReviewConfigResponse
)
from app.schemas.exam import ExamGenerateRequest, ExamGenerateResponse
__all__ = [
"UserCreate", "UserLogin", "UserResponse", "TokenResponse",
"MistakeCreate", "MistakeUpdate", "MistakeResponse", "MistakeListResponse", "MistakeStats",
"ReviewCardResponse", "ReviewTodayResponse", "ReviewAnswerRequest", "ReviewAnswerResponse",
"ReviewProgressResponse", "ReviewConfigUpdate", "ReviewConfigResponse",
"ExamGenerateRequest", "ExamGenerateResponse",
]

View File

@ -0,0 +1,19 @@
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel, Field
class ExamGenerateRequest(BaseModel):
title: str = Field(default="错题练习试卷")
subjects: list[str] = Field(default_factory=list)
question_count: int = Field(default=10, ge=1, le=50)
include_answers: bool = True
knowledge_points: Optional[list[str]] = None
randomize: bool = True
class ExamGenerateResponse(BaseModel):
exam_id: int
pdf_url: str
question_count: int

View File

@ -0,0 +1,86 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional, Any
from pydantic import BaseModel, Field
class MistakeCreate(BaseModel):
subject: str = Field(..., description="科目:数学/语文/英语")
question_type: Optional[str] = None
question_text: str = Field(..., description="题目内容")
correct_answer: Optional[str] = None
student_answer: Optional[str] = None
error_analysis: Optional[str] = None
knowledge_point: Optional[str] = None
grade_level: Optional[str] = None
class MistakeUpdate(BaseModel):
subject: Optional[str] = None
question_type: Optional[str] = None
question_text: Optional[str] = None
correct_answer: Optional[str] = None
student_answer: Optional[str] = None
error_analysis: Optional[str] = None
knowledge_point: Optional[str] = None
grade_level: Optional[str] = None
status: Optional[str] = None
is_manually_edited: Optional[bool] = None
class MistakeResponse(BaseModel):
id: int
user_id: int
subject: str
question_type: Optional[str]
grade_level: Optional[str]
question_text: str
correct_answer: Optional[str]
student_answer: Optional[str]
error_analysis: Optional[str]
knowledge_point: Optional[str]
image_url: Optional[str]
image_thumbnail: Optional[str]
ocr_confidence: Optional[float]
is_manually_edited: bool
ai_analysis: Optional[dict] = None
ai_analysis_at: Optional[datetime] = None
status: str
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class MistakeListResponse(BaseModel):
items: list[MistakeResponse]
total: int
page: int
per_page: int
class MistakeStats(BaseModel):
total_mistakes: int
by_subject: dict[str, int]
by_knowledge_point: dict[str, int]
mastered_count: int
review_count_today: int
class OCRResult(BaseModel):
subject: Optional[str] = None
question_type: Optional[str] = None
question_text: Optional[str] = None
student_answer: Optional[str] = None
correct_answer: Optional[str] = None
error_analysis: Optional[str] = None
knowledge_point: Optional[str] = None
confidence: float = 0.5
class MistakeOCRResponse(BaseModel):
id: int
ocr_result: OCRResult
needs_review: bool
full_mistake: MistakeResponse

View File

@ -0,0 +1,65 @@
from __future__ import annotations
from datetime import date, datetime
from typing import Optional
from pydantic import BaseModel, Field
from app.schemas.mistake import MistakeResponse
class ReviewCardResponse(BaseModel):
review_card_id: int
mistake: MistakeResponse
review_number: int
interval_days: int
is_new: bool
class ReviewTodayResponse(BaseModel):
due_count: int
new_count: int
review_count: int
cards: list[ReviewCardResponse]
class ReviewAnswerRequest(BaseModel):
quality: int = Field(..., ge=1, le=5, description="1=完全不会 5=非常熟练")
duration_seconds: Optional[int] = None
class ReviewAnswerResponse(BaseModel):
next_review_date: date
new_interval: int
new_ease_factor: float
remaining_today: int
class UpcomingDay(BaseModel):
date: date
count: int
class ReviewProgressResponse(BaseModel):
today_total: int
today_completed: int
streak_days: int
total_reviews: int
upcoming: list[UpcomingDay]
class ReviewConfigUpdate(BaseModel):
initial_intervals: Optional[list[int]] = None
default_ease_factor: Optional[float] = None
min_ease_factor: Optional[float] = None
max_daily_reviews: Optional[int] = None
mastery_threshold: Optional[int] = None
class ReviewConfigResponse(BaseModel):
initial_intervals: list[int]
default_ease_factor: float
min_ease_factor: float
max_daily_reviews: int
mastery_threshold: int
model_config = {"from_attributes": True}

View File

@ -0,0 +1,32 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class UserCreate(BaseModel):
username: str = Field(..., min_length=2, max_length=50)
password: str = Field(..., min_length=4, max_length=100)
display_name: Optional[str] = None
grade: Optional[str] = None
class UserLogin(BaseModel):
username: str
password: str
class UserResponse(BaseModel):
id: int
username: str
display_name: Optional[str]
grade: Optional[str]
created_at: datetime
model_config = {"from_attributes": True}
class TokenResponse(BaseModel):
user_id: int
token: str
display_name: Optional[str]

View File

View File

@ -0,0 +1,96 @@
from __future__ import annotations
import json
import re
from typing import Optional
import httpx
from app.config import settings
ANALYSIS_PROMPT = """你是一位经验丰富的小学老师,正在为学生讲解一道错题。
请用简单易懂的语言分三个部分讲解
1. **解题思路**一步步讲解正确的解题方法
2. **知识点讲解**这道题涉及的核心知识点用通俗的话解释
3. **举一反三**给出一道类似但不同的练习题帮助巩固
注意
- 语言要适合小学生理解避免过于专业的术语
- 数学题要写清楚每一步的计算过程
- 如果是语文/英语题要解释清楚规则和用法
请返回以下JSON格式不要包含其他文字
{{
"solution_steps": "一步步的解题思路...",
"knowledge_explanation": "知识点讲解...",
"similar_question": "一道类似练习题...",
"similar_answer": "类似题的答案..."
}}"""
def _extract_json(text: str) -> dict:
try:
return json.loads(text)
except json.JSONDecodeError:
pass
match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
if match:
return json.loads(match.group(1))
match = re.search(r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}", text, re.DOTALL)
if match:
return json.loads(match.group(0))
raise ValueError(f"无法解析JSON: {text[:300]}")
KIMI_API_BASE = "https://api.moonshot.cn/v1"
KIMI_MODEL = "moonshot-v1-8k" # 纯文本模型,更便宜
async def generate_analysis(
question_text: str,
subject: str,
correct_answer: Optional[str] = None,
student_answer: Optional[str] = None,
error_analysis: Optional[str] = None,
) -> dict:
"""Call Kimi to generate deep analysis for a mistake."""
if not settings.KIMI_API_KEY:
return {
"solution_steps": "AI解析未配置请在设置中填入Kimi API Key",
"knowledge_explanation": "",
"similar_question": "",
"similar_answer": "",
}
user_content = f"科目:{subject}\n题目:{question_text}"
if correct_answer:
user_content += f"\n正确答案:{correct_answer}"
if student_answer:
user_content += f"\n学生答案:{student_answer}"
if error_analysis:
user_content += f"\n错误类型:{error_analysis}"
payload = {
"model": KIMI_MODEL,
"max_tokens": 2048,
"messages": [
{"role": "system", "content": ANALYSIS_PROMPT},
{"role": "user", "content": user_content},
],
}
async with httpx.AsyncClient(timeout=60.0) as client:
resp = await client.post(
f"{KIMI_API_BASE}/chat/completions",
headers={
"Authorization": f"Bearer {settings.KIMI_API_KEY}",
"Content-Type": "application/json",
},
json=payload,
)
resp.raise_for_status()
data = resp.json()
response_text = data["choices"][0]["message"]["content"]
return _extract_json(response_text)

View File

@ -0,0 +1,45 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import jwt, JWTError
from passlib.context import CryptContext
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.user import User
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_token(user_id: int) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.JWT_EXPIRE_MINUTES)
return jwt.encode(
{"sub": str(user_id), "exp": expire},
settings.JWT_SECRET,
algorithm=settings.JWT_ALGORITHM,
)
def decode_token(token: str) -> Optional[int]:
try:
payload = jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
return int(payload["sub"])
except (JWTError, KeyError, ValueError):
return None
async def authenticate_user(db: AsyncSession, username: str, password: str) -> Optional[User]:
result = await db.execute(select(User).where(User.username == username))
user = result.scalar_one_or_none()
if user and verify_password(password, user.password_hash):
return user
return None

View File

@ -0,0 +1,123 @@
import base64
import json
import re
import httpx
from app.config import settings
SYSTEM_PROMPT = """你是一个专门为小学生错题识别设计的AI助手。
你需要分析上传的错题图片提取以下信息
1. **题目内容**完整准确地提取题目文字
2. **题型**判断题目类型
- 数学填空题选择题计算题应用题判断题
- 语文拼音题字词题句子题阅读理解作文
- 英语单词题语法题翻译题阅读理解
3. **学生答案**如果能看到学生写的错误答案
4. **正确答案**如果能判断出正确答案
5. **可能的错误原因**根据错误类型推断
6. **知识点**归类到具体知识点
注意事项
- 手写体和印刷体都要能识别
- 数学公式要准确提取使用标准数学符号
- 如果图片模糊或不确定confidence设为较低值
- 必须返回严格的JSON格式不要包含任何其他文字"""
USER_PROMPT_TEMPLATE = """请分析这张错题图片。
{subject_hint}
请返回以下JSON格式不要包含任何其他文字
{{
"subject": "数学/语文/英语",
"question_type": "题型",
"question_text": "完整题目内容",
"student_answer": "学生写的错误答案(如果能看到)",
"correct_answer": "正确答案(如果能判断)",
"error_analysis": "错误原因分析",
"knowledge_point": "知识点",
"confidence": 0.85
}}"""
def _extract_json(text: str) -> dict:
"""Extract JSON from model response, handling markdown code blocks."""
try:
return json.loads(text)
except json.JSONDecodeError:
pass
match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
if match:
return json.loads(match.group(1))
match = re.search(r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}", text, re.DOTALL)
if match:
return json.loads(match.group(0))
raise ValueError(f"无法从响应中解析JSON: {text[:300]}")
KIMI_API_BASE = "https://api.moonshot.cn/v1"
KIMI_MODEL = "moonshot-v1-8k-vision-preview"
async def recognize_mistake(
image_bytes: bytes,
media_type: str,
subject_hint: str = "",
) -> dict:
"""Call Kimi Vision API to recognize a mistake from an image."""
if not settings.KIMI_API_KEY:
return {
"subject": subject_hint or "数学",
"question_type": "填空题",
"question_text": "(OCR未配置API密钥请手动输入题目)",
"student_answer": None,
"correct_answer": None,
"error_analysis": None,
"knowledge_point": None,
"confidence": 0.0,
}
image_b64 = base64.b64encode(image_bytes).decode("utf-8")
subject_line = f"已知科目:{subject_hint}" if subject_hint else "请自动识别科目"
payload = {
"model": KIMI_MODEL,
"max_tokens": 2048,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {
"url": f"data:{media_type};base64,{image_b64}",
},
},
{
"type": "text",
"text": USER_PROMPT_TEMPLATE.format(subject_hint=subject_line),
},
],
},
],
}
async with httpx.AsyncClient(timeout=60.0) as client:
resp = await client.post(
f"{KIMI_API_BASE}/chat/completions",
headers={
"Authorization": f"Bearer {settings.KIMI_API_KEY}",
"Content-Type": "application/json",
},
json=payload,
)
resp.raise_for_status()
data = resp.json()
response_text = data["choices"][0]["message"]["content"]
return _extract_json(response_text)

View File

@ -0,0 +1,159 @@
from __future__ import annotations
import os
import uuid
from datetime import date
from pathlib import Path
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.enums import TA_LEFT, TA_CENTER
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, HRFlowable, PageBreak,
)
from reportlab.lib import colors
# Try to register Chinese fonts
_FONT_DIR = os.path.join(os.path.dirname(__file__), "..", "static", "fonts")
_FONTS_REGISTERED = False
def _register_fonts():
global _FONTS_REGISTERED
if _FONTS_REGISTERED:
return
regular = os.path.join(_FONT_DIR, "NotoSansSC-Regular.ttf")
bold = os.path.join(_FONT_DIR, "NotoSansSC-Bold.ttf")
if os.path.exists(regular):
pdfmetrics.registerFont(TTFont("NotoSansSC", regular))
pdfmetrics.registerFont(TTFont("NotoSansSC-Bold", bold))
else:
# Fallback to system fonts on Windows
win_fonts = {
"regular": "C:/Windows/Fonts/msyh.ttc",
"bold": "C:/Windows/Fonts/msyhbd.ttc",
}
if os.path.exists(win_fonts["regular"]):
pdfmetrics.registerFont(TTFont("NotoSansSC", win_fonts["regular"], subfontIndex=0))
pdfmetrics.registerFont(TTFont("NotoSansSC-Bold", win_fonts["bold"], subfontIndex=0))
else:
# Last resort: use Helvetica (won't render Chinese)
pass
_FONTS_REGISTERED = True
# Page dimensions
PAGE_W, PAGE_H = A4
MARGIN = 20 * mm
# Styles
def _get_styles() -> dict[str, ParagraphStyle]:
_register_fonts()
font = "NotoSansSC"
font_bold = "NotoSansSC-Bold"
return {
"title": ParagraphStyle(
"Title", fontName=font_bold, fontSize=18, alignment=TA_CENTER, spaceAfter=4 * mm
),
"subtitle": ParagraphStyle(
"Subtitle", fontName=font, fontSize=10, alignment=TA_CENTER, textColor=colors.gray, spaceAfter=6 * mm
),
"section": ParagraphStyle(
"Section", fontName=font_bold, fontSize=14, spaceBefore=8 * mm, spaceAfter=4 * mm
),
"question": ParagraphStyle(
"Question", fontName=font, fontSize=12, leading=20, spaceBefore=3 * mm, spaceAfter=2 * mm
),
"answer": ParagraphStyle(
"Answer", fontName=font, fontSize=11, leading=18, spaceBefore=1.5 * mm
),
"answer_title": ParagraphStyle(
"AnswerTitle", fontName=font_bold, fontSize=16, alignment=TA_CENTER, spaceAfter=4 * mm
),
}
def _group_by_type(mistakes: list[dict]) -> dict[str, list[dict]]:
groups: dict[str, list[dict]] = {}
for m in mistakes:
qtype = m.get("question_type") or "其他"
groups.setdefault(qtype, []).append(m)
return groups
def generate_exam_pdf(
title: str,
student_name: str,
grade: str,
mistakes: list[dict],
include_answers: bool,
output_path: str,
) -> str:
"""Generate an A4 PDF exam paper from a list of mistakes."""
_register_fonts()
styles = _get_styles()
doc = SimpleDocTemplate(
output_path,
pagesize=A4,
leftMargin=MARGIN,
rightMargin=MARGIN,
topMargin=MARGIN,
bottomMargin=MARGIN,
)
story = []
# Header
story.append(Paragraph(title, styles["title"]))
story.append(
Paragraph(
f"{student_name} · {grade} · {date.today().strftime('%Y年%m月%d')}",
styles["subtitle"],
)
)
story.append(HRFlowable(width="100%", thickness=0.5, color=colors.grey))
story.append(Spacer(1, 4 * mm))
# Questions grouped by type
grouped = _group_by_type(mistakes)
section_labels = ["", "", "", "", "", "", "", "", "", ""]
q_num = 1
for idx, (qtype, questions) in enumerate(grouped.items()):
label = section_labels[idx] if idx < len(section_labels) else str(idx + 1)
story.append(Paragraph(f"{label}{qtype}(共{len(questions)}题)", styles["section"]))
for q in questions:
text = q.get("question_text", "")
student_ans = q.get("student_answer", "")
line = f"{q_num}. {text}"
if student_ans:
line += f" (你的答案:{student_ans}"
story.append(Paragraph(line, styles["question"]))
story.append(Spacer(1, 2 * mm))
q_num += 1
# Answer key
if include_answers and mistakes:
story.append(PageBreak())
story.append(Paragraph("参考答案", styles["answer_title"]))
story.append(HRFlowable(width="100%", thickness=0.5, color=colors.grey))
story.append(Spacer(1, 4 * mm))
for i, m in enumerate(mistakes, 1):
ans = m.get("correct_answer") or ""
analysis = m.get("error_analysis") or ""
line = f"{i}. {ans}"
if analysis:
line += f" {analysis}"
story.append(Paragraph(line, styles["answer"]))
doc.build(story)
return output_path

View File

@ -0,0 +1,89 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, timedelta
@dataclass
class ReviewState:
ease_factor: float
interval_days: int
repetitions: int
next_review_date: date
@dataclass
class ReviewConfig:
initial_intervals: list[int] # default [1, 2, 4, 7, 15, 30]
default_ease_factor: float # 2.50
min_ease_factor: float # 1.30
mastery_threshold: int # 3
DEFAULT_CONFIG = ReviewConfig(
initial_intervals=[1, 2, 4, 7, 15, 30],
default_ease_factor=2.50,
min_ease_factor=1.30,
mastery_threshold=3,
)
def calculate_next_review(
state: ReviewState,
quality: int, # 1-5
config: ReviewConfig = DEFAULT_CONFIG,
) -> ReviewState:
"""
SM-2 variant for primary school students.
quality:
1 = 完全忘记
2 = 想起来很费劲
3 = 想了一会儿才答对
4 = 比较顺利
5 = 非常熟练
"""
new_ease_factor = state.ease_factor
new_interval = state.interval_days
new_repetitions = state.repetitions
if quality >= 3:
# Correct answer
if new_repetitions < len(config.initial_intervals):
new_interval = config.initial_intervals[new_repetitions]
else:
new_interval = round(state.interval_days * state.ease_factor)
new_repetitions += 1
else:
# Wrong answer — reset
new_repetitions = 0
new_interval = config.initial_intervals[0]
# Update ease factor using SM-2 formula
ef_delta = 0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02)
new_ease_factor = state.ease_factor + ef_delta
new_ease_factor = max(config.min_ease_factor, new_ease_factor)
next_date = date.today() + timedelta(days=new_interval)
# Determine status
if (
quality >= 3
and new_repetitions >= config.mastery_threshold
and new_interval >= 30
):
status = "mastered"
elif new_repetitions > 0:
status = "review"
else:
status = "learning"
return ReviewState(
ease_factor=round(new_ease_factor, 2),
interval_days=new_interval,
repetitions=new_repetitions,
next_review_date=next_date,
)
def is_mastered(repetitions: int, interval_days: int, config: ReviewConfig = DEFAULT_CONFIG) -> bool:
return repetitions >= config.mastery_threshold and interval_days >= 30

View File

@ -0,0 +1,24 @@
from __future__ import annotations
import io
from PIL import Image
MAX_WIDTH = 1920
MAX_HEIGHT = 1920
JPEG_QUALITY = 85
async def preprocess_image(image_bytes: bytes, filename: str) -> tuple[bytes, str]:
"""Compress and normalize uploaded images."""
img = Image.open(io.BytesIO(image_bytes))
# Convert to RGB (remove alpha channel)
if img.mode in ("RGBA", "P", "LA"):
img = img.convert("RGB")
# Resize proportionally if too large
if img.width > MAX_WIDTH or img.height > MAX_HEIGHT:
img.thumbnail((MAX_WIDTH, MAX_HEIGHT), Image.LANCZOS)
output = io.BytesIO()
img.save(output, format="JPEG", quality=JPEG_QUALITY)
return output.getvalue(), "image/jpeg"

BIN
backend/edu_system.db Normal file

Binary file not shown.

20
backend/init_db.py Normal file
View File

@ -0,0 +1,20 @@
"""Create all database tables."""
import asyncio
import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
from app.database import engine, Base
from app.models import User, Mistake, ReviewCard, ReviewLog, ReviewConfig, ExamPaper
async def main():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
print("Database tables created successfully!")
await engine.dispose()
if __name__ == "__main__":
asyncio.run(main())

13
backend/requirements.txt Normal file
View File

@ -0,0 +1,13 @@
fastapi>=0.110.0
uvicorn[standard]>=0.27.0
sqlalchemy[asyncio]>=2.0
alembic>=1.13.0
aiosqlite>=0.20.0
httpx>=0.27.0
pillow>=10.0.0
reportlab>=4.1.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
python-multipart>=0.0.6
pydantic>=2.5
pydantic-settings>=2.1

31
docker-compose.yml Normal file
View File

@ -0,0 +1,31 @@
services:
db:
image: postgres:16
environment:
POSTGRES_DB: edu_system
POSTGRES_USER: postgres
POSTGRES_PASSWORD: 1234567890
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
backend:
build: ./backend
ports:
- "8000:8000"
depends_on:
- db
env_file: .env
volumes:
- ./storage/uploads:/app/storage/uploads
frontend:
build: ./frontend
ports:
- "3000:3000"
depends_on:
- backend
volumes:
pgdata:

5
frontend/next-env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

17
frontend/next.config.js Normal file
View File

@ -0,0 +1,17 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
async rewrites() {
return [
{
source: '/api/:path*',
destination: 'http://192.168.0.102:8000/api/:path*',
},
{
source: '/uploads/:path*',
destination: 'http://192.168.0.102:8000/uploads/:path*',
},
];
},
};
module.exports = nextConfig;

2145
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
frontend/package.json Normal file
View File

@ -0,0 +1,32 @@
{
"name": "quizfrog",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^14.2.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"@tanstack/react-query": "^5.0.0",
"axios": "^1.7.0",
"react-dropzone": "^14.2.0",
"framer-motion": "^11.0.0",
"react-hot-toast": "^2.4.0",
"date-fns": "^3.6.0",
"zustand": "^4.5.0"
},
"devDependencies": {
"typescript": "^5.4.0",
"@types/node": "^20.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"tailwindcss": "^3.4.0",
"postcss": "^8.4.0",
"autoprefixer": "^10.4.0"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -0,0 +1,368 @@
"use client";
import { useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useDropzone } from "react-dropzone";
import toast from "react-hot-toast";
import api from "@/lib/api";
import { useAuthStore } from "@/lib/store";
import { SUBJECTS } from "@/types";
import type { MistakeOCRResponse } from "@/types";
export default function CollectPage() {
const router = useRouter();
const isLoggedIn = useAuthStore((s) => s.isLoggedIn);
const [subject, setSubject] = useState<string>("");
const [uploading, setUploading] = useState(false);
const [result, setResult] = useState<MistakeOCRResponse | null>(null);
const [editing, setEditing] = useState(false);
const [editData, setEditData] = useState({
question_text: "",
correct_answer: "",
student_answer: "",
error_analysis: "",
knowledge_point: "",
});
const [mode, setMode] = useState<"ocr" | "manual">("ocr");
const onDrop = useCallback(
async (acceptedFiles: File[]) => {
if (!isLoggedIn) {
router.push("/login");
return;
}
const file = acceptedFiles[0];
if (!file) return;
setUploading(true);
const formData = new FormData();
formData.append("image", file);
if (subject) formData.append("subject", subject);
try {
const res = await api.post<MistakeOCRResponse>("/mistakes/ocr", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
setResult(res.data);
setEditData({
question_text: res.data.ocr_result.question_text || "",
correct_answer: res.data.ocr_result.correct_answer || "",
student_answer: res.data.ocr_result.student_answer || "",
error_analysis: res.data.ocr_result.error_analysis || "",
knowledge_point: res.data.ocr_result.knowledge_point || "",
});
toast.success("识别完成!");
} catch (err: any) {
toast.error(err.response?.data?.detail || "识别失败");
} finally {
setUploading(false);
}
},
[subject, isLoggedIn]
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: { "image/*": [".jpg", ".jpeg", ".png", ".webp"] },
maxFiles: 1,
maxSize: 10 * 1024 * 1024,
});
const handleManualSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await api.post("/mistakes", {
subject: subject || "数学",
...editData,
});
toast.success("错题已保存!");
setEditData({
question_text: "",
correct_answer: "",
student_answer: "",
error_analysis: "",
knowledge_point: "",
});
} catch (err: any) {
toast.error(err.response?.data?.detail || "保存失败");
}
};
const handleSaveEdit = async () => {
if (!result) return;
try {
await api.put(`/mistakes/${result.id}`, {
...editData,
is_manually_edited: true,
});
toast.success("已更新!");
setResult(null);
setEditing(false);
} catch (err: any) {
toast.error(err.response?.data?.detail || "更新失败");
}
};
if (!isLoggedIn) {
router.push("/login");
return null;
}
return (
<div className="max-w-lg mx-auto px-4 pt-6">
<h1 className="text-2xl font-bold text-slate-800 mb-6"></h1>
{/* Mode Toggle */}
<div className="flex gap-2 mb-4">
<button
onClick={() => setMode("ocr")}
className={`flex-1 py-2.5 rounded-xl font-medium transition-all ${
mode === "ocr" ? "bg-blue-500 text-white" : "bg-slate-100 text-slate-500"
}`}
>
📷
</button>
<button
onClick={() => setMode("manual")}
className={`flex-1 py-2.5 rounded-xl font-medium transition-all ${
mode === "manual" ? "bg-blue-500 text-white" : "bg-slate-100 text-slate-500"
}`}
>
</button>
</div>
{/* Subject Selector */}
<div className="flex gap-2 mb-4">
{SUBJECTS.map((s) => (
<button
key={s}
onClick={() => setSubject(s === subject ? "" : s)}
className={`px-4 py-2 rounded-xl font-medium transition-all ${
subject === s
? s === "数学"
? "bg-blue-500 text-white"
: s === "语文"
? "bg-red-500 text-white"
: "bg-green-500 text-white"
: "bg-slate-100 text-slate-500"
}`}
>
{s}
</button>
))}
</div>
{mode === "ocr" ? (
<>
{/* Upload Zone */}
{!result && (
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-2xl p-8 text-center cursor-pointer transition-all ${
isDragActive
? "border-blue-400 bg-blue-50"
: "border-slate-300 bg-white hover:border-blue-300"
}`}
>
<input {...getInputProps()} />
{uploading ? (
<div>
<div className="animate-spin text-4xl mb-3"></div>
<p className="text-slate-600 font-medium">AI识别中...</p>
</div>
) : (
<div>
<div className="text-5xl mb-3">📸</div>
<p className="text-slate-600 font-medium text-lg">
{isDragActive ? "松开上传" : "点击或拖拽上传错题图片"}
</p>
<p className="text-slate-400 text-sm mt-1"> JPG, PNG, WebP (10MB)</p>
</div>
)}
</div>
)}
{/* OCR Result */}
{result && (
<div className="bg-white rounded-2xl shadow-sm border border-slate-100 p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="font-bold text-slate-700 text-lg"></h2>
{result.needs_review && (
<span className="text-xs bg-yellow-100 text-yellow-700 px-2 py-1 rounded-full">
</span>
)}
</div>
{result.full_mistake.image_url && (
<img
src={result.full_mistake.image_url}
alt="错题原图"
className="w-full rounded-xl mb-4 max-h-60 object-contain bg-slate-50"
/>
)}
{editing ? (
<div className="space-y-3">
<div>
<label className="text-sm text-slate-500"></label>
<textarea
value={editData.question_text}
onChange={(e) => setEditData({ ...editData, question_text: e.target.value })}
className="w-full p-3 rounded-xl border border-slate-200 text-base"
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm text-slate-500"></label>
<input
value={editData.correct_answer}
onChange={(e) => setEditData({ ...editData, correct_answer: e.target.value })}
className="w-full p-3 rounded-xl border border-slate-200"
/>
</div>
<div>
<label className="text-sm text-slate-500"></label>
<input
value={editData.student_answer}
onChange={(e) => setEditData({ ...editData, student_answer: e.target.value })}
className="w-full p-3 rounded-xl border border-slate-200"
/>
</div>
</div>
<div>
<label className="text-sm text-slate-500"></label>
<input
value={editData.error_analysis}
onChange={(e) => setEditData({ ...editData, error_analysis: e.target.value })}
className="w-full p-3 rounded-xl border border-slate-200"
/>
</div>
<div>
<label className="text-sm text-slate-500"></label>
<input
value={editData.knowledge_point}
onChange={(e) => setEditData({ ...editData, knowledge_point: e.target.value })}
className="w-full p-3 rounded-xl border border-slate-200"
/>
</div>
<div className="flex gap-2 pt-2">
<button
onClick={handleSaveEdit}
className="flex-1 py-3 bg-blue-500 text-white rounded-xl font-medium"
>
</button>
<button
onClick={() => setEditing(false)}
className="flex-1 py-3 bg-slate-100 text-slate-600 rounded-xl font-medium"
>
</button>
</div>
</div>
) : (
<div className="space-y-3">
<InfoRow label="科目" value={result.ocr_result.subject || "未知"} />
<InfoRow label="题型" value={result.ocr_result.question_type || "未知"} />
<InfoRow label="题目" value={result.ocr_result.question_text || "—"} />
<InfoRow label="正确答案" value={result.ocr_result.correct_answer || "—"} />
<InfoRow label="你的答案" value={result.ocr_result.student_answer || "—"} />
<InfoRow label="错误原因" value={result.ocr_result.error_analysis || "—"} />
<InfoRow label="知识点" value={result.ocr_result.knowledge_point || "—"} />
<div className="flex gap-2 pt-3">
<button
onClick={() => setEditing(true)}
className="flex-1 py-3 bg-slate-100 text-slate-600 rounded-xl font-medium"
>
</button>
<button
onClick={() => {
setResult(null);
toast.success("已保存到错题库!");
}}
className="flex-1 py-3 bg-blue-500 text-white rounded-xl font-medium"
>
</button>
</div>
</div>
)}
</div>
)}
</>
) : (
/* Manual Mode */
<form onSubmit={handleManualSubmit} className="bg-white rounded-2xl shadow-sm border border-slate-100 p-5 space-y-3">
<div>
<label className="text-sm text-slate-500"> *</label>
<textarea
value={editData.question_text}
onChange={(e) => setEditData({ ...editData, question_text: e.target.value })}
required
className="w-full p-3 rounded-xl border border-slate-200 text-base"
rows={3}
placeholder="输入题目内容..."
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm text-slate-500"></label>
<input
value={editData.correct_answer}
onChange={(e) => setEditData({ ...editData, correct_answer: e.target.value })}
className="w-full p-3 rounded-xl border border-slate-200"
placeholder="正确答案"
/>
</div>
<div>
<label className="text-sm text-slate-500"></label>
<input
value={editData.student_answer}
onChange={(e) => setEditData({ ...editData, student_answer: e.target.value })}
className="w-full p-3 rounded-xl border border-slate-200"
placeholder="错误答案"
/>
</div>
</div>
<div>
<label className="text-sm text-slate-500"></label>
<input
value={editData.error_analysis}
onChange={(e) => setEditData({ ...editData, error_analysis: e.target.value })}
className="w-full p-3 rounded-xl border border-slate-200"
placeholder="为什么会做错?"
/>
</div>
<div>
<label className="text-sm text-slate-500"></label>
<input
value={editData.knowledge_point}
onChange={(e) => setEditData({ ...editData, knowledge_point: e.target.value })}
className="w-full p-3 rounded-xl border border-slate-200"
placeholder="例如:两位数乘法"
/>
</div>
<button
type="submit"
className="w-full py-3.5 bg-blue-500 text-white rounded-xl font-medium text-lg hover:bg-blue-600 active:scale-95 transition-all"
>
</button>
</form>
)}
</div>
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div>
<span className="text-xs text-slate-400">{label}</span>
<p className="text-slate-700">{value}</p>
</div>
);
}

View File

@ -0,0 +1,163 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import api from "@/lib/api";
import { useAuthStore } from "@/lib/store";
import { SUBJECTS } from "@/types";
export default function ExamPage() {
const router = useRouter();
const isLoggedIn = useAuthStore((s) => s.isLoggedIn);
const [title, setTitle] = useState("错题练习试卷");
const [selectedSubjects, setSelectedSubjects] = useState<string[]>([]);
const [questionCount, setQuestionCount] = useState(10);
const [includeAnswers, setIncludeAnswers] = useState(true);
const [generating, setGenerating] = useState(false);
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
useEffect(() => {
if (!isLoggedIn) router.push("/login");
}, [isLoggedIn]);
const toggleSubject = (s: string) => {
setSelectedSubjects((prev) =>
prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]
);
};
const handleGenerate = async () => {
setGenerating(true);
try {
const res = await api.post("/exam/generate", {
title,
subjects: selectedSubjects,
question_count: questionCount,
include_answers,
randomize: true,
});
setPdfUrl(res.data.pdf_url);
toast.success("试卷生成成功!");
} catch (err: any) {
toast.error(err.response?.data?.detail || "生成失败");
} finally {
setGenerating(false);
}
};
const handleDownload = () => {
if (!pdfUrl) return;
// Construct download URL through proxy
const downloadUrl = pdfUrl.replace("/api/v1", "");
window.open(downloadUrl, "_blank");
};
if (!isLoggedIn) return null;
return (
<div className="max-w-lg mx-auto px-4 pt-6">
<h1 className="text-2xl font-bold text-slate-800 mb-6"></h1>
<div className="bg-white rounded-2xl shadow-sm border border-slate-100 p-5 space-y-5">
{/* Title */}
<div>
<label className="block text-sm font-medium text-slate-600 mb-1"></label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-slate-200 focus:border-blue-400 outline-none text-lg"
/>
</div>
{/* Subjects */}
<div>
<label className="block text-sm font-medium text-slate-600 mb-2"></label>
<div className="flex gap-2">
{SUBJECTS.map((s) => {
const selected = selectedSubjects.includes(s);
const colors: Record<string, { active: string; inactive: string }> = {
: { active: "bg-blue-500 text-white", inactive: "bg-blue-50 text-blue-600" },
: { active: "bg-red-500 text-white", inactive: "bg-red-50 text-red-600" },
: { active: "bg-green-500 text-white", inactive: "bg-green-50 text-green-600" },
};
return (
<button
key={s}
onClick={() => toggleSubject(s)}
className={`flex-1 py-2.5 rounded-xl font-medium transition-all ${
selected ? colors[s].active : colors[s].inactive
}`}
>
{s}
</button>
);
})}
</div>
<p className="text-xs text-slate-400 mt-1"></p>
</div>
{/* Question Count */}
<div>
<label className="block text-sm font-medium text-slate-600 mb-2">
{questionCount}
</label>
<input
type="range"
min={1}
max={30}
value={questionCount}
onChange={(e) => setQuestionCount(Number(e.target.value))}
className="w-full accent-blue-500"
/>
<div className="flex justify-between text-xs text-slate-400">
<span>1</span>
<span>30</span>
</div>
</div>
{/* Include Answers */}
<label className="flex items-center gap-3 cursor-pointer">
<div
onClick={() => setIncludeAnswers(!includeAnswers)}
className={`w-12 h-7 rounded-full transition-all flex items-center ${
includeAnswers ? "bg-blue-500" : "bg-slate-300"
}`}
>
<div
className={`w-5 h-5 bg-white rounded-full shadow transition-transform ${
includeAnswers ? "translate-x-6" : "translate-x-1"
}`}
/>
</div>
<span className="text-slate-700 font-medium"></span>
</label>
{/* Generate Button */}
<button
onClick={handleGenerate}
disabled={generating}
className="w-full py-3.5 bg-blue-500 text-white rounded-xl font-medium text-lg hover:bg-blue-600 active:scale-95 transition-all disabled:opacity-50"
>
{generating ? "生成中..." : "生成试卷"}
</button>
</div>
{/* Download Section */}
{pdfUrl && (
<div className="mt-6 bg-green-50 border border-green-200 rounded-2xl p-5 text-center">
<div className="text-4xl mb-2">📄</div>
<h3 className="font-bold text-green-800 mb-2"></h3>
<p className="text-green-600 text-sm mb-4">A4试卷</p>
<button
onClick={handleDownload}
className="w-full py-3.5 bg-green-500 text-white rounded-xl font-medium text-lg hover:bg-green-600 active:scale-95 transition-all"
>
PDF
</button>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,42 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--primary: #3b82f6;
--primary-light: #dbeafe;
}
body {
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif;
font-size: 16px;
background: #f8fafc;
color: #1e293b;
}
/* Card flip animation for review */
.perspective-1000 {
perspective: 1000px;
}
.backface-hidden {
backface-visibility: hidden;
}
.rotate-y-180 {
transform: rotateY(180deg);
}
.flip-card-inner {
transition: transform 0.6s;
transform-style: preserve-3d;
}
.flip-card-inner.flipped {
transform: rotateY(180deg);
}
/* Bottom nav safe area */
.pb-nav {
padding-bottom: 5.5rem;
}

View File

@ -0,0 +1,32 @@
"use client";
import type { Metadata } from "next";
import { useEffect } from "react";
import { Toaster } from "react-hot-toast";
import Navbar from "@/components/layout/Navbar";
import { useAuthStore } from "@/lib/store";
import "./globals.css";
export default function RootLayout({ children }: { children: React.ReactNode }) {
const loadFromStorage = useAuthStore((s) => s.loadFromStorage);
const isLoggedIn = useAuthStore((s) => s.isLoggedIn);
useEffect(() => {
loadFromStorage();
}, [loadFromStorage]);
return (
<html lang="zh-CN">
<body className="min-h-screen bg-slate-50">
<Toaster
position="top-center"
toastOptions={{
style: { fontSize: "1rem", borderRadius: "12px" },
}}
/>
<main className="pb-nav">{children}</main>
{isLoggedIn && <Navbar />}
</body>
</html>
);
}

View File

@ -0,0 +1,132 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import api from "@/lib/api";
import { useAuthStore } from "@/lib/store";
import type { TokenResponse } from "@/types";
export default function LoginPage() {
const router = useRouter();
const login = useAuthStore((s) => s.login);
const [mode, setMode] = useState<"login" | "register">("login");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [displayName, setDisplayName] = useState("");
const [grade, setGrade] = useState("三年级");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const url = mode === "login" ? "/auth/login" : "/auth/register";
const payload =
mode === "login"
? { username, password }
: { username, password, display_name: displayName, grade };
const res = await api.post<TokenResponse>(url, payload);
login(res.data);
toast.success(mode === "login" ? "登录成功!" : "注册成功!");
router.push("/");
} catch (err: any) {
toast.error(err.response?.data?.detail || "操作失败");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-blue-50 to-white p-4">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-slate-800 mb-2"></h1>
<p className="text-slate-500"> · · </p>
</div>
<div className="bg-white rounded-2xl shadow-lg p-6">
<div className="flex gap-2 mb-6">
<button
onClick={() => setMode("login")}
className={`flex-1 py-2.5 rounded-xl font-medium transition-all ${
mode === "login" ? "bg-blue-500 text-white" : "bg-slate-100 text-slate-500"
}`}
>
</button>
<button
onClick={() => setMode("register")}
className={`flex-1 py-2.5 rounded-xl font-medium transition-all ${
mode === "register" ? "bg-blue-500 text-white" : "bg-slate-100 text-slate-500"
}`}
>
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-600 mb-1"></label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="w-full px-4 py-3 rounded-xl border border-slate-200 focus:border-blue-400 focus:ring-2 focus:ring-blue-100 outline-none text-lg"
placeholder="输入用户名"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 mb-1"></label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-4 py-3 rounded-xl border border-slate-200 focus:border-blue-400 focus:ring-2 focus:ring-blue-100 outline-none text-lg"
placeholder="输入密码"
/>
</div>
{mode === "register" && (
<>
<div>
<label className="block text-sm font-medium text-slate-600 mb-1"></label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-slate-200 focus:border-blue-400 focus:ring-2 focus:ring-blue-100 outline-none text-lg"
placeholder="例如:小明"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 mb-1"></label>
<select
value={grade}
onChange={(e) => setGrade(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-slate-200 focus:border-blue-400 outline-none text-lg bg-white"
>
{["一年级", "二年级", "三年级", "四年级", "五年级", "六年级"].map((g) => (
<option key={g} value={g}>{g}</option>
))}
</select>
</div>
</>
)}
<button
type="submit"
disabled={loading}
className="w-full py-3.5 bg-blue-500 text-white rounded-xl font-medium text-lg hover:bg-blue-600 active:scale-95 transition-all disabled:opacity-50"
>
{loading ? "处理中..." : mode === "login" ? "登录" : "注册"}
</button>
</form>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,260 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import api from "@/lib/api";
import { useAuthStore } from "@/lib/store";
import { SUBJECTS, SUBJECT_COLORS } from "@/types";
import type { Mistake, MistakeListResponse, AIAnalysis } from "@/types";
export default function MistakesPage() {
const router = useRouter();
const isLoggedIn = useAuthStore((s) => s.isLoggedIn);
const [mistakes, setMistakes] = useState<Mistake[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [subject, setSubject] = useState("");
const [loading, setLoading] = useState(true);
const [expandedId, setExpandedId] = useState<number | null>(null);
const [analyzingId, setAnalyzingId] = useState<number | null>(null);
useEffect(() => {
if (!isLoggedIn) {
router.push("/login");
return;
}
loadMistakes();
}, [isLoggedIn, page, subject]);
const loadMistakes = async () => {
setLoading(true);
try {
const params: any = { page, per_page: 20 };
if (subject) params.subject = subject;
const res = await api.get<MistakeListResponse>("/mistakes", { params });
setMistakes(res.data.items);
setTotal(res.data.total);
} catch {
toast.error("加载错题失败");
} finally {
setLoading(false);
}
};
const handleDelete = async (id: number) => {
if (!confirm("确定要删除这道错题吗?")) return;
try {
await api.delete(`/mistakes/${id}`);
toast.success("已删除");
loadMistakes();
} catch {
toast.error("删除失败");
}
};
const handleAnalyze = async (mistakeId: number) => {
setAnalyzingId(mistakeId);
try {
const res = await api.post(`/mistakes/${mistakeId}/analyze`);
setMistakes((prev) =>
prev.map((m) =>
m.id === mistakeId
? { ...m, ai_analysis: res.data.ai_analysis, ai_analysis_at: new Date().toISOString() }
: m
)
);
toast.success("AI解析完成");
} catch (err: any) {
toast.error(err.response?.data?.detail || "AI解析失败");
} finally {
setAnalyzingId(null);
}
};
if (!isLoggedIn) return null;
const totalPages = Math.ceil(total / 20);
return (
<div className="max-w-lg mx-auto px-4 pt-6">
<h1 className="text-2xl font-bold text-slate-800 mb-4"></h1>
{/* Filters */}
<div className="flex gap-2 mb-4 overflow-x-auto pb-1">
<button
onClick={() => { setSubject(""); setPage(1); }}
className={`px-4 py-2 rounded-xl font-medium text-sm whitespace-nowrap transition-all ${
!subject ? "bg-slate-800 text-white" : "bg-slate-100 text-slate-500"
}`}
>
</button>
{SUBJECTS.map((s) => (
<button
key={s}
onClick={() => { setSubject(s === subject ? "" : s); setPage(1); }}
className={`px-4 py-2 rounded-xl font-medium text-sm whitespace-nowrap transition-all ${
subject === s
? s === "数学"
? "bg-blue-500 text-white"
: s === "语文"
? "bg-red-500 text-white"
: "bg-green-500 text-white"
: "bg-slate-100 text-slate-500"
}`}
>
{s}
</button>
))}
</div>
{/* List */}
{loading ? (
<div className="text-center py-12">
<div className="animate-spin text-3xl"></div>
</div>
) : mistakes.length === 0 ? (
<div className="text-center py-16">
<div className="text-5xl mb-3">📚</div>
<p className="text-slate-500"></p>
</div>
) : (
<div className="space-y-3">
{mistakes.map((m) => {
const colors = SUBJECT_COLORS[m.subject] || { bg: "bg-slate-50", text: "text-slate-700", border: "border-slate-300" };
const isExpanded = expandedId === m.id;
return (
<div key={m.id} className={`bg-white rounded-2xl shadow-sm border ${colors.border} overflow-hidden`}>
<div
className="p-4 cursor-pointer"
onClick={() => setExpandedId(isExpanded ? null : m.id)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${colors.bg} ${colors.text}`}>
{m.subject}
</span>
{m.question_type && (
<span className="text-xs text-slate-400">{m.question_type}</span>
)}
{m.status === "mastered" && (
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full"></span>
)}
</div>
<p className="text-slate-700 font-medium line-clamp-2">{m.question_text}</p>
{m.knowledge_point && (
<span className="text-xs text-slate-400 mt-1 inline-block">#{m.knowledge_point}</span>
)}
</div>
{m.image_url && (
<img
src={m.image_url}
alt=""
className="w-16 h-16 object-cover rounded-lg flex-shrink-0"
/>
)}
</div>
</div>
{isExpanded && (
<div className="px-4 pb-4 border-t border-slate-100 pt-3 space-y-2">
{m.student_answer && (
<div>
<span className="text-xs text-red-400"></span>
<p className="text-red-600">{m.student_answer}</p>
</div>
)}
{m.correct_answer && (
<div>
<span className="text-xs text-green-500"></span>
<p className="text-green-700">{m.correct_answer}</p>
</div>
)}
{m.error_analysis && (
<div>
<span className="text-xs text-orange-500"></span>
<p className="text-orange-700">{m.error_analysis}</p>
</div>
)}
<div className="flex gap-2 pt-2">
{!m.ai_analysis ? (
<button
onClick={(e) => { e.stopPropagation(); handleAnalyze(m.id); }}
disabled={analyzingId === m.id}
className="px-4 py-2 bg-purple-50 text-purple-600 rounded-xl text-sm font-medium disabled:opacity-50"
>
{analyzingId === m.id ? "AI解析中..." : "AI深度解析"}
</button>
) : null}
<button
onClick={() => handleDelete(m.id)}
className="px-4 py-2 bg-red-50 text-red-600 rounded-xl text-sm font-medium"
>
</button>
</div>
{/* AI Analysis Display */}
{m.ai_analysis && (
<div className="mt-3 p-4 bg-purple-50 rounded-xl space-y-3">
<h4 className="font-bold text-purple-700 text-sm flex items-center gap-1">
AI解析
</h4>
{m.ai_analysis.solution_steps && (
<div>
<span className="text-xs text-purple-500 font-medium"></span>
<p className="text-sm text-slate-700 mt-1 whitespace-pre-wrap">{m.ai_analysis.solution_steps}</p>
</div>
)}
{m.ai_analysis.knowledge_explanation && (
<div>
<span className="text-xs text-purple-500 font-medium"></span>
<p className="text-sm text-slate-700 mt-1 whitespace-pre-wrap">{m.ai_analysis.knowledge_explanation}</p>
</div>
)}
{m.ai_analysis.similar_question && (
<div className="pt-2 border-t border-purple-100">
<span className="text-xs text-purple-500 font-medium"></span>
<p className="text-sm text-slate-700 mt-1">{m.ai_analysis.similar_question}</p>
{m.ai_analysis.similar_answer && (
<p className="text-sm text-green-600 mt-1">{m.ai_analysis.similar_answer}</p>
)}
</div>
)}
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center gap-2 mt-6 pb-4">
<button
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page === 1}
className="px-4 py-2 bg-slate-100 rounded-xl text-sm disabled:opacity-50"
>
</button>
<span className="px-4 py-2 text-sm text-slate-500">
{page} / {totalPages}
</span>
<button
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page === totalPages}
className="px-4 py-2 bg-slate-100 rounded-xl text-sm disabled:opacity-50"
>
</button>
</div>
)}
</div>
);
}

170
frontend/src/app/page.tsx Normal file
View File

@ -0,0 +1,170 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useAuthStore } from "@/lib/store";
import api from "@/lib/api";
import type { MistakeStats, ReviewProgressResponse } from "@/types";
export default function DashboardPage() {
const router = useRouter();
const isLoggedIn = useAuthStore((s) => s.isLoggedIn);
const displayName = useAuthStore((s) => s.displayName);
const [stats, setStats] = useState<MistakeStats | null>(null);
const [progress, setProgress] = useState<ReviewProgressResponse | null>(null);
useEffect(() => {
if (!isLoggedIn) {
router.push("/login");
return;
}
loadData();
}, [isLoggedIn]);
const loadData = async () => {
try {
const [statsRes, progressRes] = await Promise.all([
api.get<MistakeStats>("/mistakes/stats"),
api.get<ReviewProgressResponse>("/review/progress"),
]);
setStats(statsRes.data);
setProgress(progressRes.data);
} catch {}
};
if (!isLoggedIn) return null;
return (
<div className="max-w-lg mx-auto px-4 pt-6">
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-slate-800">
{displayName ? `${displayName}的错题蛙` : "我的错题蛙"}
</h1>
<p className="text-slate-500 mt-1"></p>
</div>
{/* Today's Review Card */}
<Link href="/review">
<div className="bg-gradient-to-r from-blue-500 to-blue-600 rounded-2xl p-5 text-white mb-4 shadow-lg active:scale-[0.98] transition-transform">
<div className="flex items-center justify-between">
<div>
<p className="text-blue-100 text-sm"></p>
<p className="text-4xl font-bold mt-1">
{progress?.today_total ?? 0}
<span className="text-lg font-normal text-blue-200"> </span>
</p>
<p className="text-blue-200 text-sm mt-1">
{progress?.today_completed ?? 0}
</p>
</div>
<div className="text-5xl">📝</div>
</div>
{progress && progress.today_total > 0 && (
<div className="mt-3 bg-blue-400/30 rounded-full h-2">
<div
className="bg-white rounded-full h-2 transition-all"
style={{
width: `${Math.round(
(progress.today_completed / progress.today_total) * 100
)}%`,
}}
/>
</div>
)}
</div>
</Link>
{/* Quick Actions */}
<div className="grid grid-cols-2 gap-3 mb-6">
<Link href="/collect">
<div className="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 active:scale-95 transition-transform">
<span className="text-3xl">📷</span>
<p className="font-medium text-slate-700 mt-2"></p>
<p className="text-xs text-slate-400 mt-0.5"></p>
</div>
</Link>
<Link href="/exam">
<div className="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 active:scale-95 transition-transform">
<span className="text-3xl">🖨</span>
<p className="font-medium text-slate-700 mt-2"></p>
<p className="text-xs text-slate-400 mt-0.5">A4打印练习</p>
</div>
</Link>
</div>
{/* Stats */}
<div className="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 mb-4">
<h2 className="font-bold text-slate-700 mb-3"></h2>
<div className="grid grid-cols-3 gap-3 text-center">
<div>
<p className="text-2xl font-bold text-blue-600">{stats?.total_mistakes ?? 0}</p>
<p className="text-xs text-slate-400"></p>
</div>
<div>
<p className="text-2xl font-bold text-green-600">{stats?.mastered_count ?? 0}</p>
<p className="text-xs text-slate-400"></p>
</div>
<div>
<p className="text-2xl font-bold text-orange-500">{progress?.streak_days ?? 0}</p>
<p className="text-xs text-slate-400"></p>
</div>
</div>
</div>
{/* Subject Stats */}
{stats && Object.keys(stats.by_subject).length > 0 && (
<div className="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 mb-6">
<h2 className="font-bold text-slate-700 mb-3"></h2>
<div className="space-y-2">
{Object.entries(stats.by_subject).map(([subject, count]) => {
const colors: Record<string, string> = {
: "bg-blue-500",
: "bg-red-500",
: "bg-green-500",
};
const maxCount = Math.max(...Object.values(stats.by_subject));
return (
<div key={subject} className="flex items-center gap-3">
<span className="w-10 text-sm font-medium">{subject}</span>
<div className="flex-1 bg-slate-100 rounded-full h-4">
<div
className={`${colors[subject] || "bg-slate-500"} rounded-full h-4 transition-all`}
style={{ width: `${(count / maxCount) * 100}%` }}
/>
</div>
<span className="w-8 text-right text-sm text-slate-500">{count}</span>
</div>
);
})}
</div>
</div>
)}
{/* Upcoming Reviews */}
{progress && progress.upcoming.length > 0 && (
<div className="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 mb-6">
<h2 className="font-bold text-slate-700 mb-3"></h2>
<div className="flex gap-2 overflow-x-auto pb-1">
{progress.upcoming.map((day) => {
const d = new Date(day.date);
const isToday = day.date === new Date().toISOString().split("T")[0];
return (
<div
key={day.date}
className={`flex-shrink-0 w-16 text-center py-2 rounded-xl ${
isToday ? "bg-blue-500 text-white" : "bg-slate-50 text-slate-600"
}`}
>
<p className="text-xs">{isToday ? "今天" : `${d.getMonth() + 1}/${d.getDate()}`}</p>
<p className="text-lg font-bold">{day.count}</p>
</div>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,284 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import api from "@/lib/api";
import { useAuthStore } from "@/lib/store";
import type { ReviewCardResponse, ReviewTodayResponse } from "@/types";
import { REVIEW_QUALITY_LABELS } from "@/types";
export default function ReviewPage() {
const router = useRouter();
const isLoggedIn = useAuthStore((s) => s.isLoggedIn);
const [data, setData] = useState<ReviewTodayResponse | null>(null);
const [currentIndex, setCurrentIndex] = useState(0);
const [isFlipped, setIsFlipped] = useState(false);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [completed, setCompleted] = useState(false);
const [analyzing, setAnalyzing] = useState(false);
useEffect(() => {
if (!isLoggedIn) {
router.push("/login");
return;
}
loadToday();
}, [isLoggedIn]);
const loadToday = async () => {
try {
const res = await api.get<ReviewTodayResponse>("/review/today");
setData(res.data);
if (res.data.cards.length === 0) {
setCompleted(true);
}
} catch {
toast.error("加载复习数据失败");
} finally {
setLoading(false);
}
};
const handleRate = async (quality: number) => {
if (!data || submitting) return;
const card = data.cards[currentIndex];
setSubmitting(true);
try {
await api.post(`/review/${card.review_card_id}/answer`, {
quality,
duration_seconds: null,
});
if (currentIndex + 1 >= data.cards.length) {
setCompleted(true);
toast.success("今天的复习全部完成!");
} else {
setCurrentIndex(currentIndex + 1);
setIsFlipped(false);
}
} catch (err: any) {
toast.error(err.response?.data?.detail || "提交失败");
} finally {
setSubmitting(false);
}
};
const handleAnalyze = async () => {
if (!data || analyzing) return;
const card = data.cards[currentIndex];
setAnalyzing(true);
try {
const res = await api.post(`/mistakes/${card.mistake.id}/analyze`);
const updatedMistake = { ...card.mistake, ai_analysis: res.data.ai_analysis, ai_analysis_at: new Date().toISOString() };
setData((prev) => {
if (!prev) return prev;
const newCards = [...prev.cards];
newCards[currentIndex] = { ...newCards[currentIndex], mistake: updatedMistake };
return { ...prev, cards: newCards };
});
toast.success("AI解析完成");
} catch (err: any) {
toast.error(err.response?.data?.detail || "AI解析失败");
} finally {
setAnalyzing(false);
}
};
if (!isLoggedIn) return null;
if (loading) {
return (
<div className="max-w-lg mx-auto px-4 pt-6 flex items-center justify-center min-h-[60vh]">
<div className="text-center">
<div className="animate-spin text-4xl mb-3"></div>
<p className="text-slate-500">...</p>
</div>
</div>
);
}
if (completed || !data || data.cards.length === 0) {
return (
<div className="max-w-lg mx-auto px-4 pt-6">
<div className="text-center py-16">
<div className="text-6xl mb-4">🎉</div>
<h2 className="text-2xl font-bold text-slate-800 mb-2">
{completed ? "太棒了!全部完成!" : "今天没有需要复习的题目"}
</h2>
<p className="text-slate-500 mb-6">
{completed ? "坚持复习,知识记得更牢固!" : "快去收集更多错题吧"}
</p>
<button
onClick={() => router.push("/")}
className="px-6 py-3 bg-blue-500 text-white rounded-xl font-medium"
>
</button>
</div>
</div>
);
}
const card = data.cards[currentIndex];
const mistake = card.mistake;
const progress = ((currentIndex) / data.cards.length) * 100;
return (
<div className="max-w-lg mx-auto px-4 pt-6">
{/* Progress */}
<div className="mb-4">
<div className="flex items-center justify-between mb-2">
<h1 className="text-xl font-bold text-slate-800"></h1>
<span className="text-sm text-slate-500">
{currentIndex + 1} / {data.cards.length}
</span>
</div>
<div className="bg-slate-100 rounded-full h-2">
<div
className="bg-blue-500 rounded-full h-2 transition-all"
style={{ width: `${progress}%` }}
/>
</div>
</div>
{/* Flip Card */}
<div className="perspective-1000 mb-6">
<div
className={`flip-card-inner relative ${isFlipped ? "flipped" : ""}`}
style={{ minHeight: "300px" }}
>
{/* Front - Question */}
<div
className={`absolute inset-0 backface-hidden bg-white rounded-2xl shadow-lg border border-slate-100 p-6 flex flex-col ${
isFlipped ? "invisible" : ""
}`}
>
<div className="flex items-center gap-2 mb-4">
<span
className={`px-3 py-1 rounded-full text-sm font-medium ${
mistake.subject === "数学"
? "bg-blue-100 text-blue-700"
: mistake.subject === "语文"
? "bg-red-100 text-red-700"
: "bg-green-100 text-green-700"
}`}
>
{mistake.subject}
</span>
{mistake.question_type && (
<span className="px-3 py-1 rounded-full text-sm bg-slate-100 text-slate-600">
{mistake.question_type}
</span>
)}
{card.is_new && (
<span className="px-3 py-1 rounded-full text-sm bg-yellow-100 text-yellow-700">
</span>
)}
</div>
<div className="flex-1 flex items-center justify-center">
<p className="text-xl text-slate-800 leading-relaxed text-center">
{mistake.question_text}
</p>
</div>
{mistake.image_url && (
<img
src={mistake.image_url}
alt="原题图片"
className="w-full max-h-32 object-contain rounded-xl mt-3 bg-slate-50"
/>
)}
<button
onClick={() => setIsFlipped(true)}
className="mt-4 w-full py-3.5 bg-blue-500 text-white rounded-xl font-medium text-lg active:scale-95 transition-transform"
>
</button>
</div>
{/* Back - Answer */}
<div
className={`absolute inset-0 backface-hidden rotate-y-180 bg-white rounded-2xl shadow-lg border border-slate-100 p-6 flex flex-col ${
!isFlipped ? "invisible" : ""
}`}
>
<h3 className="font-bold text-slate-700 mb-4 text-lg"></h3>
<div className="flex-1 space-y-3">
<div>
<span className="text-sm text-slate-400"></span>
<p className="text-slate-700">{mistake.question_text}</p>
</div>
{mistake.student_answer && (
<div>
<span className="text-sm text-red-400"></span>
<p className="text-red-600">{mistake.student_answer}</p>
</div>
)}
{mistake.correct_answer && (
<div>
<span className="text-sm text-green-500"></span>
<p className="text-green-700 font-medium">{mistake.correct_answer}</p>
</div>
)}
{mistake.error_analysis && (
<div>
<span className="text-sm text-orange-500"></span>
<p className="text-orange-700">{mistake.error_analysis}</p>
</div>
)}
{/* AI Analysis */}
{mistake.ai_analysis ? (
<div className="mt-3 p-3 bg-purple-50 rounded-xl space-y-2">
<span className="text-xs text-purple-500 font-bold">AI深度解析</span>
{mistake.ai_analysis.solution_steps && (
<p className="text-sm text-slate-700 whitespace-pre-wrap">{mistake.ai_analysis.solution_steps}</p>
)}
{mistake.ai_analysis.knowledge_explanation && (
<div>
<span className="text-xs text-purple-400"></span>
<p className="text-sm text-slate-700">{mistake.ai_analysis.knowledge_explanation}</p>
</div>
)}
</div>
) : (
<button
onClick={handleAnalyze}
disabled={analyzing}
className="mt-2 w-full py-2 bg-purple-50 text-purple-600 rounded-xl text-sm font-medium disabled:opacity-50"
>
{analyzing ? "AI解析中..." : "查看AI深度解析"}
</button>
)}
</div>
{/* Rating Buttons */}
<div className="mt-4">
<p className="text-sm text-slate-500 mb-2 text-center"></p>
<div className="grid grid-cols-5 gap-2">
{REVIEW_QUALITY_LABELS.map((q) => (
<button
key={q.value}
onClick={() => handleRate(q.value)}
disabled={submitting}
className={`flex flex-col items-center py-2.5 rounded-xl text-white font-medium active:scale-90 transition-all disabled:opacity-50 ${q.color}`}
>
<span className="text-xl">{q.emoji}</span>
<span className="text-xs mt-0.5">{q.label}</span>
</button>
))}
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,142 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import api from "@/lib/api";
import { useAuthStore } from "@/lib/store";
import type { ReviewConfigResponse } from "@/types";
export default function SettingsPage() {
const router = useRouter();
const isLoggedIn = useAuthStore((s) => s.isLoggedIn);
const logout = useAuthStore((s) => s.logout);
const displayName = useAuthStore((s) => s.displayName);
const [config, setConfig] = useState<ReviewConfigResponse | null>(null);
const [intervals, setIntervals] = useState("1, 2, 4, 7, 15, 30");
const [maxDaily, setMaxDaily] = useState(20);
const [mastery, setMastery] = useState(3);
useEffect(() => {
if (!isLoggedIn) {
router.push("/login");
return;
}
loadConfig();
}, [isLoggedIn]);
const loadConfig = async () => {
try {
const res = await api.get<ReviewConfigResponse>("/review/config");
setConfig(res.data);
setIntervals(res.data.initial_intervals.join(", "));
setMaxDaily(res.data.max_daily_reviews);
setMastery(res.data.mastery_threshold);
} catch {}
};
const handleSave = async () => {
try {
const parsed = intervals.split(",").map((s) => parseInt(s.trim())).filter((n) => !isNaN(n) && n > 0);
await api.put("/review/config", {
initial_intervals: parsed,
max_daily_reviews: maxDaily,
mastery_threshold: mastery,
});
toast.success("设置已保存!");
} catch (err: any) {
toast.error(err.response?.data?.detail || "保存失败");
}
};
const handleLogout = () => {
logout();
router.push("/login");
};
if (!isLoggedIn) return null;
return (
<div className="max-w-lg mx-auto px-4 pt-6">
<h1 className="text-2xl font-bold text-slate-800 mb-6"></h1>
{/* Profile */}
<div className="bg-white rounded-2xl shadow-sm border border-slate-100 p-5 mb-4">
<h2 className="font-bold text-slate-700 mb-3"></h2>
<p className="text-slate-600">{displayName || "未设置"}</p>
<button
onClick={handleLogout}
className="mt-4 w-full py-3 bg-red-50 text-red-600 rounded-xl font-medium"
>
退
</button>
</div>
{/* Review Config */}
<div className="bg-white rounded-2xl shadow-sm border border-slate-100 p-5 mb-4">
<h2 className="font-bold text-slate-700 mb-3"></h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">
</label>
<input
type="text"
value={intervals}
onChange={(e) => setIntervals(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-slate-200 focus:border-blue-400 outline-none"
placeholder="1, 2, 4, 7, 15, 30"
/>
<p className="text-xs text-slate-400 mt-1">1, 2, 4, 7, 15, 30</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">
{maxDaily}
</label>
<input
type="range"
min={5}
max={50}
value={maxDaily}
onChange={(e) => setMaxDaily(Number(e.target.value))}
className="w-full accent-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">
{mastery}
</label>
<input
type="range"
min={2}
max={6}
value={mastery}
onChange={(e) => setMastery(Number(e.target.value))}
className="w-full accent-blue-500"
/>
</div>
<button
onClick={handleSave}
className="w-full py-3 bg-blue-500 text-white rounded-xl font-medium hover:bg-blue-600 active:scale-95 transition-all"
>
</button>
</div>
</div>
{/* Info */}
<div className="bg-slate-50 rounded-2xl p-5 mb-6">
<h3 className="font-bold text-slate-600 mb-2">线</h3>
<p className="text-sm text-slate-500 leading-relaxed">
线
1247
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,40 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
const NAV_ITEMS = [
{ href: "/", label: "首页", icon: "🏠" },
{ href: "/collect", label: "收集", icon: "📷" },
{ href: "/review", label: "复习", icon: "📝" },
{ href: "/mistakes", label: "错题库", icon: "📚" },
{ href: "/exam", label: "打印", icon: "🖨️" },
];
export default function Navbar() {
const pathname = usePathname();
return (
<nav className="fixed bottom-0 left-0 right-0 bg-white border-t border-slate-200 z-50 shadow-lg">
<div className="flex justify-around items-center max-w-lg mx-auto h-16">
{NAV_ITEMS.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={`flex flex-col items-center justify-center gap-0.5 px-3 py-1 rounded-xl transition-all ${
isActive
? "text-blue-600 bg-blue-50 scale-105"
: "text-slate-400 hover:text-slate-600"
}`}
>
<span className="text-2xl">{item.icon}</span>
<span className="text-xs font-medium">{item.label}</span>
</Link>
);
})}
</div>
</nav>
);
}

34
frontend/src/lib/api.ts Normal file
View File

@ -0,0 +1,34 @@
import axios from "axios";
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "/api/v1";
const api = axios.create({
baseURL: API_BASE,
headers: { "Content-Type": "application/json" },
});
// Add auth token to requests
api.interceptors.request.use((config) => {
if (typeof window !== "undefined") {
const token = localStorage.getItem("token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
});
// Handle 401 responses
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401 && typeof window !== "undefined") {
localStorage.removeItem("token");
localStorage.removeItem("user");
window.location.href = "/login";
}
return Promise.reject(error);
}
);
export default api;

52
frontend/src/lib/store.ts Normal file
View File

@ -0,0 +1,52 @@
import { create } from "zustand";
import type { TokenResponse } from "@/types";
interface AuthState {
token: string | null;
userId: number | null;
displayName: string | null;
isLoggedIn: boolean;
login: (data: TokenResponse) => void;
logout: () => void;
loadFromStorage: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
token: null,
userId: null,
displayName: null,
isLoggedIn: false,
login: (data) => {
localStorage.setItem("token", data.token);
localStorage.setItem("user", JSON.stringify(data));
set({
token: data.token,
userId: data.user_id,
displayName: data.display_name,
isLoggedIn: true,
});
},
logout: () => {
localStorage.removeItem("token");
localStorage.removeItem("user");
set({ token: null, userId: null, displayName: null, isLoggedIn: false });
},
loadFromStorage: () => {
if (typeof window === "undefined") return;
const stored = localStorage.getItem("user");
if (stored) {
try {
const data: TokenResponse = JSON.parse(stored);
set({
token: data.token,
userId: data.user_id,
displayName: data.display_name,
isLoggedIn: true,
});
} catch {}
}
},
}));

135
frontend/src/types/index.ts Normal file
View File

@ -0,0 +1,135 @@
export interface User {
id: number;
username: string;
display_name: string | null;
grade: string | null;
created_at: string;
}
export interface TokenResponse {
user_id: number;
token: string;
display_name: string | null;
}
export interface AIAnalysis {
solution_steps: string;
knowledge_explanation: string;
similar_question: string;
similar_answer: string;
}
export interface Mistake {
id: number;
user_id: number;
subject: string;
question_type: string | null;
grade_level: string | null;
question_text: string;
correct_answer: string | null;
student_answer: string | null;
error_analysis: string | null;
knowledge_point: string | null;
image_url: string | null;
image_thumbnail: string | null;
ocr_confidence: number | null;
is_manually_edited: boolean;
ai_analysis: AIAnalysis | null;
ai_analysis_at: string | null;
status: string;
created_at: string;
updated_at: string;
}
export interface MistakeListResponse {
items: Mistake[];
total: number;
page: number;
per_page: number;
}
export interface MistakeStats {
total_mistakes: number;
by_subject: Record<string, number>;
by_knowledge_point: Record<string, number>;
mastered_count: number;
review_count_today: number;
}
export interface OCRResult {
subject: string | null;
question_type: string | null;
question_text: string | null;
student_answer: string | null;
correct_answer: string | null;
error_analysis: string | null;
knowledge_point: string | null;
confidence: number;
}
export interface MistakeOCRResponse {
id: number;
ocr_result: OCRResult;
needs_review: boolean;
full_mistake: Mistake;
}
export interface ReviewCardResponse {
review_card_id: number;
mistake: Mistake;
review_number: number;
interval_days: number;
is_new: boolean;
}
export interface ReviewTodayResponse {
due_count: number;
new_count: number;
review_count: number;
cards: ReviewCardResponse[];
}
export interface ReviewAnswerResponse {
next_review_date: string;
new_interval: number;
new_ease_factor: number;
remaining_today: number;
}
export interface ReviewProgressResponse {
today_total: number;
today_completed: number;
streak_days: number;
total_reviews: number;
upcoming: { date: string; count: number }[];
}
export interface ReviewConfigResponse {
initial_intervals: number[];
default_ease_factor: number;
min_ease_factor: number;
max_daily_reviews: number;
mastery_threshold: number;
}
export const SUBJECTS = ["数学", "语文", "英语"] as const;
export const SUBJECT_COLORS: Record<string, { bg: string; text: string; border: string }> = {
: { bg: "bg-blue-50", text: "text-blue-700", border: "border-blue-300" },
: { bg: "bg-red-50", text: "text-red-700", border: "border-red-300" },
: { bg: "bg-green-50", text: "text-green-700", border: "border-green-300" },
};
export const QUESTION_TYPES: Record<string, string[]> = {
: ["填空题", "选择题", "计算题", "应用题", "判断题"],
: ["拼音题", "字词题", "句子题", "阅读理解", "作文"],
: ["单词题", "语法题", "翻译题", "阅读理解"],
};
export const REVIEW_QUALITY_LABELS = [
{ value: 1, label: "完全不会", emoji: "😣", color: "bg-red-500" },
{ value: 2, label: "想了很久", emoji: "😟", color: "bg-orange-500" },
{ value: 3, label: "想了一会", emoji: "😐", color: "bg-yellow-500" },
{ value: 4, label: "比较顺利", emoji: "😊", color: "bg-green-500" },
{ value: 5, label: "非常熟练", emoji: "😄", color: "bg-emerald-500" },
];

View File

@ -0,0 +1,24 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],
theme: {
extend: {
colors: {
math: { light: "#dbeafe", DEFAULT: "#3b82f6", dark: "#1d4ed8" },
chinese: { light: "#fee2e2", DEFAULT: "#ef4444", dark: "#b91c1c" },
english: { light: "#dcfce7", DEFAULT: "#22c55e", dark: "#15803d" },
},
fontSize: {
"child-sm": ["1rem", { lineHeight: "1.5" }],
"child-base": ["1.125rem", { lineHeight: "1.6" }],
"child-lg": ["1.25rem", { lineHeight: "1.6" }],
"child-xl": ["1.5rem", { lineHeight: "1.4" }],
"child-2xl": ["1.875rem", { lineHeight: "1.3" }],
},
},
},
plugins: [],
};
export default config;

20
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}