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:
parent
7746c0929f
commit
b32ef10458
|
|
@ -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
|
||||
|
|
@ -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
|
||||
70
README.md
70
README.md
|
|
@ -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 文档
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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,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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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()
|
||||
)
|
||||
|
|
@ -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")
|
||||
|
|
@ -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()
|
||||
)
|
||||
|
|
@ -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")
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
)
|
||||
|
|
@ -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}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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}
|
||||
|
|
@ -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]
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
Binary file not shown.
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
系统根据遗忘曲线安排复习时间:新学的知识如果不及时复习,
|
||||
会随时间快速遗忘。通过在关键时间点(如1天、2天、4天、7天后)
|
||||
进行复习,可以有效将短期记忆转化为长期记忆。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 {}
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
|
@ -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" },
|
||||
];
|
||||
|
|
@ -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;
|
||||
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Reference in New Issue