From b32ef104584d65a9d94b172bc0c875fa74f1962a Mon Sep 17 00:00:00 2001 From: chen guanjun Date: Sun, 21 Jun 2026 10:45:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20QuizFrog=20=E5=AE=8C=E6=95=B4=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Next.js 前端:登录注册、错题库、收集、复习、试卷生成、设置 - FastAPI 后端:REST API、JWT 认证、SQLite 存储 - Kimi Vision API 图片 OCR 错题识别 - SM-2 艾宾浩斯间隔复习算法 - ReportLab A4 PDF 试卷生成 - AI 深度解析(可选,Kimi 纯文本模型) --- .env.example | 17 + .gitignore | 28 + README.md | 70 +- backend/alembic.ini | 40 + backend/alembic/env.py | 61 + backend/alembic/script.py.mako | 24 + backend/app/__init__.py | 0 backend/app/config.py | 19 + backend/app/database.py | 33 + backend/app/main.py | 45 + backend/app/models/__init__.py | 6 + backend/app/models/exam.py | 39 + backend/app/models/mistake.py | 61 + backend/app/models/review.py | 100 + backend/app/models/user.py | 25 + backend/app/routers/__init__.py | 0 backend/app/routers/auth.py | 48 + backend/app/routers/dependencies.py | 27 + backend/app/routers/exam.py | 116 ++ backend/app/routers/mistakes.py | 227 +++ backend/app/routers/ocr.py | 86 + backend/app/routers/review.py | 307 +++ backend/app/schemas/__init__.py | 15 + backend/app/schemas/exam.py | 19 + backend/app/schemas/mistake.py | 86 + backend/app/schemas/review.py | 65 + backend/app/schemas/user.py | 32 + backend/app/services/__init__.py | 0 backend/app/services/ai_analysis.py | 96 + backend/app/services/auth_service.py | 45 + backend/app/services/ocr_service.py | 123 ++ backend/app/services/pdf_service.py | 159 ++ backend/app/services/spaced_repetition.py | 89 + backend/app/utils/image_utils.py | 24 + backend/edu_system.db | Bin 0 -> 94208 bytes backend/init_db.py | 20 + backend/requirements.txt | 13 + docker-compose.yml | 31 + frontend/next-env.d.ts | 5 + frontend/next.config.js | 17 + frontend/package-lock.json | 2145 +++++++++++++++++++++ frontend/package.json | 32 + frontend/postcss.config.js | 6 + frontend/src/app/collect/page.tsx | 368 ++++ frontend/src/app/exam/page.tsx | 163 ++ frontend/src/app/globals.css | 42 + frontend/src/app/layout.tsx | 32 + frontend/src/app/login/page.tsx | 132 ++ frontend/src/app/mistakes/page.tsx | 260 +++ frontend/src/app/page.tsx | 170 ++ frontend/src/app/review/page.tsx | 284 +++ frontend/src/app/settings/page.tsx | 142 ++ frontend/src/components/layout/Navbar.tsx | 40 + frontend/src/lib/api.ts | 34 + frontend/src/lib/store.ts | 52 + frontend/src/types/index.ts | 135 ++ frontend/tailwind.config.ts | 24 + frontend/tsconfig.json | 20 + 58 files changed, 6298 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/app/__init__.py create mode 100644 backend/app/config.py create mode 100644 backend/app/database.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/exam.py create mode 100644 backend/app/models/mistake.py create mode 100644 backend/app/models/review.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/auth.py create mode 100644 backend/app/routers/dependencies.py create mode 100644 backend/app/routers/exam.py create mode 100644 backend/app/routers/mistakes.py create mode 100644 backend/app/routers/ocr.py create mode 100644 backend/app/routers/review.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/exam.py create mode 100644 backend/app/schemas/mistake.py create mode 100644 backend/app/schemas/review.py create mode 100644 backend/app/schemas/user.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/ai_analysis.py create mode 100644 backend/app/services/auth_service.py create mode 100644 backend/app/services/ocr_service.py create mode 100644 backend/app/services/pdf_service.py create mode 100644 backend/app/services/spaced_repetition.py create mode 100644 backend/app/utils/image_utils.py create mode 100644 backend/edu_system.db create mode 100644 backend/init_db.py create mode 100644 backend/requirements.txt create mode 100644 docker-compose.yml create mode 100644 frontend/next-env.d.ts create mode 100644 frontend/next.config.js create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/app/collect/page.tsx create mode 100644 frontend/src/app/exam/page.tsx create mode 100644 frontend/src/app/globals.css create mode 100644 frontend/src/app/layout.tsx create mode 100644 frontend/src/app/login/page.tsx create mode 100644 frontend/src/app/mistakes/page.tsx create mode 100644 frontend/src/app/page.tsx create mode 100644 frontend/src/app/review/page.tsx create mode 100644 frontend/src/app/settings/page.tsx create mode 100644 frontend/src/components/layout/Navbar.tsx create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/store.ts create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/tailwind.config.ts create mode 100644 frontend/tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..89ce915 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1e6229c --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 6d2508b..ae96af4 100644 --- a/README.md +++ b/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 文档 diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..b801539 --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..677e4fc --- /dev/null +++ b/backend/alembic/env.py @@ -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() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..590f5b3 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..fcb81f4 --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..d7612ac --- /dev/null +++ b/backend/app/database.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..fb6f9d7 --- /dev/null +++ b/backend/app/main.py @@ -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"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..1a90029 --- /dev/null +++ b/backend/app/models/__init__.py @@ -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"] diff --git a/backend/app/models/exam.py b/backend/app/models/exam.py new file mode 100644 index 0000000..87a5843 --- /dev/null +++ b/backend/app/models/exam.py @@ -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() + ) diff --git a/backend/app/models/mistake.py b/backend/app/models/mistake.py new file mode 100644 index 0000000..8a93cab --- /dev/null +++ b/backend/app/models/mistake.py @@ -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") diff --git a/backend/app/models/review.py b/backend/app/models/review.py new file mode 100644 index 0000000..81e174e --- /dev/null +++ b/backend/app/models/review.py @@ -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() + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..1033343 --- /dev/null +++ b/backend/app/models/user.py @@ -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") diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..30a44a3 --- /dev/null +++ b/backend/app/routers/auth.py @@ -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, + ) diff --git a/backend/app/routers/dependencies.py b/backend/app/routers/dependencies.py new file mode 100644 index 0000000..be3b6b4 --- /dev/null +++ b/backend/app/routers/dependencies.py @@ -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 diff --git a/backend/app/routers/exam.py b/backend/app/routers/exam.py new file mode 100644 index 0000000..9e6f65d --- /dev/null +++ b/backend/app/routers/exam.py @@ -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", + ) diff --git a/backend/app/routers/mistakes.py b/backend/app/routers/mistakes.py new file mode 100644 index 0000000..c418a03 --- /dev/null +++ b/backend/app/routers/mistakes.py @@ -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} diff --git a/backend/app/routers/ocr.py b/backend/app/routers/ocr.py new file mode 100644 index 0000000..58b1fee --- /dev/null +++ b/backend/app/routers/ocr.py @@ -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, + ) diff --git a/backend/app/routers/review.py b/backend/app/routers/review.py new file mode 100644 index 0000000..ceba8ca --- /dev/null +++ b/backend/app/routers/review.py @@ -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, + ) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..7c75155 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -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", +] diff --git a/backend/app/schemas/exam.py b/backend/app/schemas/exam.py new file mode 100644 index 0000000..db66378 --- /dev/null +++ b/backend/app/schemas/exam.py @@ -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 diff --git a/backend/app/schemas/mistake.py b/backend/app/schemas/mistake.py new file mode 100644 index 0000000..1d3b9a2 --- /dev/null +++ b/backend/app/schemas/mistake.py @@ -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 diff --git a/backend/app/schemas/review.py b/backend/app/schemas/review.py new file mode 100644 index 0000000..17f54d5 --- /dev/null +++ b/backend/app/schemas/review.py @@ -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} diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..b516551 --- /dev/null +++ b/backend/app/schemas/user.py @@ -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] diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/ai_analysis.py b/backend/app/services/ai_analysis.py new file mode 100644 index 0000000..608fc73 --- /dev/null +++ b/backend/app/services/ai_analysis.py @@ -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) diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000..88f9631 --- /dev/null +++ b/backend/app/services/auth_service.py @@ -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 diff --git a/backend/app/services/ocr_service.py b/backend/app/services/ocr_service.py new file mode 100644 index 0000000..4717613 --- /dev/null +++ b/backend/app/services/ocr_service.py @@ -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) diff --git a/backend/app/services/pdf_service.py b/backend/app/services/pdf_service.py new file mode 100644 index 0000000..dc2fef2 --- /dev/null +++ b/backend/app/services/pdf_service.py @@ -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 diff --git a/backend/app/services/spaced_repetition.py b/backend/app/services/spaced_repetition.py new file mode 100644 index 0000000..84432b1 --- /dev/null +++ b/backend/app/services/spaced_repetition.py @@ -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 diff --git a/backend/app/utils/image_utils.py b/backend/app/utils/image_utils.py new file mode 100644 index 0000000..2bcae47 --- /dev/null +++ b/backend/app/utils/image_utils.py @@ -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" diff --git a/backend/edu_system.db b/backend/edu_system.db new file mode 100644 index 0000000000000000000000000000000000000000..9d3f56470ce04580f08f43570bfefe545d4834ab GIT binary patch literal 94208 zcmeI5-ES1v8OCR9yz5y5Yaq!8MHYhy!mB`7uQ3Fos9DwnSghBuUK0pKqw&tMJ$S#E znYF#C+SCas4eG~56E#uQi&j;8Q6)rbr6!S@=BAN)+5aFHK*&XJqBc@QE_%*sgug&iQ)J`JMND=FB;^nLL}-Eh5cSjk0PpTshh-5bS>D%wpDxJR%kcuW+dk0QSm6X z*VECSX{lD-44Ug-)tpnl-I|l0Slk*F)703L9gUTBT5O)Qj4!^bLBCGBQ9mzn?ZD=! zxOXpqeZW$uOQ9JI4sMHUtB^jPm0M;iCg>WS=7M}u&P%z8f|Q%eX7@`m!9(0B&+kIV zszf!3GNTr)szI9@m-Azp!9)9{R3c0R?nsLnN#WfU@95zU(p~mDnhlSQTarADqD~;Q~A7{ zD=4g~$wGSkOvq}z#^Px`HP7V7#?$!=(rNjEG^lF{*3HaBZnBV1(|M5USG<5_A*|R_ zdEWgF`cwp`ZhAz1&B` zc~2mm$&y-DYHE%4*}!cWGLYN^mv|!{i;8>p@YjePRDm*qkA0z# z3S>xw>xwAq<|QKeuGr#>W_|iHDO#pfkk8W)4TPyb6&*6K)QQQ$M=4h86{};6)wCI< zZj_p)N0Ld~UDogYoqRaxJg$ST+4*9LtNp!Eao;}vO(i6A)yj-M>$!=2A%tcnl(o)z z(JNGcs785W>{G z_qK|Om*Ev;O5&yYGV)3!A`T7k^|t1n+m2Tux?=0@?J3l_la{#DvqINho$wlutlYRG zDB-TVwG#`QqGD!%U*6f)_xVb7u|%|4qSUBpTb`Ys745WC2-|DPOk5l7j*0^V{Pl}= zoO`W%-#58doMnucy3W9M$J*4r;TFYhN~hJR`NBNnh~uoGYD6iK1>y@QT|O{b@axg# zb-P>_tA;^^Tv03LA~EcIXDL$GNTn^A7)I5gWz|x{)P3n`;no~O%GGYYtkN0kr0PzN zHCHcBSE$A`YkI0*EgFiUE>h_+Yjo8`njK^rMHWA_n?=%G)eW;%9UFjdDrL1&S4&j4 zh(_=0nsj_(A}gnJp}W;%vgEqI+ttOche*%ij-V^+8* zixxY@Q59A@@%ktq6-AN1I%~&>oo&B$g`&f)w(44hwHNS!@angwn&x7aE;8p-bFNt` zH8K(|rs-y_q&AdLTSKlN+F|0?z2Ak!1G*3<@&g}!%f{Vx~)0e!;@1V8`;KmY_l z00ck)1V8`;KmY_b5`pFJE`i&%+_Q7n@bFjf{p8Lse{<{F2hXIYpBYL$GoPEu9)4Lq zKeu$IJl{A!d1UTLcA8`lo*NrEdit_)>d1w2_2-RKi|5bH&8PCmXQxgs9Zb;{=-16p zetPTUzkK=0A5+QH;RDIT2U0^)GIcC@_*inNL(*~=FK`1+FP3z*O1H*m-A<6w+~P@H zK2$BeJf)cvWG-1Qo*@TMnU}K{v_p;Q%azpV^sBF)H4L@*+|q?w?L=mD;?{@1xc%fZ%O7u=9UD{$q}Q;ROO900JNY0w4eaAOHd&00JNY0wC~^2>giO!3jHe zal3bH8+w-6=-oLyeEXdbZ@vGAJ0JY^%MX6_)$iU-jYvz=iiA|KD)&Z#KdJF$NF-0T2KI5C8!X009sH z0T2KI5C8$6fY7~DaDK=D&;LFZd;$Rw009sH0T2KI5C8!X009sHfsI9gJ^wquCEz^& zf5*kY+gKySXg~l2KmY_l00ck)1V8`;KmY_l00bNY-Q9w~{{A1=4y0uTTJ z5C8!X009sH0T2KI5C8!X*!To+{=f0(7$XD$5C8!X009sH0T2KI5C8!X00DOX&&U7D z(J#C}00ck)1V8`;KmY_l00ck)1V8`;9xVbV`OTalM05|7$M(_2bcJ^b!O>00ck)1V8`;KmY_l00ck)1VCUd1h#bV7W_X1$o~F6 z*T0r}C<6i@00JNY0w4eaAOHd&00JNY0wD0X64)Z#{eIs&eFL#iW7Dzj z==;KFk*9lZbba2l&^69~!PmH(wBSQ{EeqSC;`m;E`G8*0$U`tdn-uQ$9Pw=|=}U@1 z7IdD5Z-{8lfMMw!nB2T@W@&&P?TsFq!#Fg7l;5xTQ_E}hSu zO6Lbt$#DOQ2BA?AswIueC(={df|Qxc=jB{MVNFdI(&J}BR_iquPwT09COvD z%NL|UT}!ZTW+rlzg?yUMgH*rb1uP3;#hxN}E~Kg4*x4yr8g%DvzvNGn-OP!JygYU? z$C~!aB&582LZ$=B$deMQXWB-Y%Z)$^<_4g+eNjAq}o8qNtmfh~&Fsi!Yk>>C2>OnNmSM zPeU{irv6lP$h=Y~CJP^>SglvAjxko#W|X>7YMLHNCT(|FzxQ|Y;h^)l4!UONizTl1 z_eRBi`}jANkjzypGy1IOCiaC8nw3!2I_E{NQ2n7A<%yL8nT4fBX4HDgQr0GRWxcZ2 zlIjvwM7`8-+bC-)VPzDxYV;k*Ik|P8jq~pxFaaxuDi7p3!9>1W`JMb+1B^@N_DYBv{|Cm zsAyZBot_o#v{VS&YspMp8|{vY0|Wf^i*}rQt$W`$xmKKIjF-C3z;?&l)V<*r#cfKb z)u{QxJmQGstf6W|DUk)@3nyJZFj?^H(dBi!To1pBC97D?0ZoRD18S13!PL4HKFHcvf#x!ess$VS{ilHu2=`m|`)kT^e zWEn*kKeU@g(p=RIvsE1%fNm;fwNh70RJVvm@9Ua$d}1Oir*omZ)nl^cy1(1i#jc2W zP~>0pqQ_oY*zXJT7O$5-z6cX04<)lu%nkt{>WA;@7?3g~fxr zC&d!%{67{w!^Pj;d~@@kHmCaj(Ki+QXYY&Af6+v|KmY_l00ck)1lC00wI`zDNxDI^ z+gtRw58tKgtQp+c@pnO4j@bI#uyQT777FA_gWl6U+X$mOWtVr*4d37nQJ}E*pcY7a zYBkGj@kHBfGwG%$t_j=i9iq1`1otD@fC3hFgw|t$j8z_3+*0=D-h- z$J5y?+fEGbBe|xvx}n-TMkb-leXY6qz}EORTBf-X;%z3e4Epz!I;PtdR@@))YOY$q z>DB|=OARjU!>~i!QGu+LHZxi8gRObK5!?@T7va=9nJYblS%%WH{sr z(op>UmSD0p=P%p-235=yG4nEj)0n;@!dnN%wWg z!*+r3^Z#pF+My5#fB*=900@8p2!H?xfB*=900=y`1n~U-*iIcf4gw$m0w4eaAOHd& P00JNY0w4eaYbNkN3n)uD literal 0 HcmV?d00001 diff --git a/backend/init_db.py b/backend/init_db.py new file mode 100644 index 0000000..a29f213 --- /dev/null +++ b/backend/init_db.py @@ -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()) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..c946234 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..480e129 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts new file mode 100644 index 0000000..40c3d68 --- /dev/null +++ b/frontend/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/frontend/next.config.js b/frontend/next.config.js new file mode 100644 index 0000000..31b7286 --- /dev/null +++ b/frontend/next.config.js @@ -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; diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..219c00b --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2145 @@ +{ + "name": "edu-system-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "edu-system-frontend", + "version": "1.0.0", + "dependencies": { + "@tanstack/react-query": "^5.0.0", + "axios": "^1.7.0", + "date-fns": "^3.6.0", + "framer-motion": "^11.0.0", + "next": "^14.2.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "react-dropzone": "^14.2.0", + "react-hot-toast": "^2.4.0", + "zustand": "^4.5.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "autoprefixer": "^10.4.0", + "postcss": "^8.4.0", + "tailwindcss": "^3.4.0", + "typescript": "^5.4.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@next/env": { + "version": "14.2.35", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.35.tgz", + "integrity": "sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz", + "integrity": "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz", + "integrity": "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz", + "integrity": "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz", + "integrity": "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz", + "integrity": "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz", + "integrity": "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz", + "integrity": "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz", + "integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz", + "integrity": "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, + "node_modules/@swc/helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", + "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", + "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", + "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", + "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.38", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", + "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.376", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.376.tgz", + "integrity": "sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-selector": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "license": "MIT", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/framer-motion": { + "version": "11.18.2", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz", + "integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==", + "license": "MIT", + "dependencies": { + "motion-dom": "^11.18.1", + "motion-utils": "^11.18.1", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/goober": { + "version": "2.1.19", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.19.tgz", + "integrity": "sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/motion-dom": { + "version": "11.18.1", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz", + "integrity": "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==", + "license": "MIT", + "dependencies": { + "motion-utils": "^11.18.1" + } + }, + "node_modules/motion-utils": { + "version": "11.18.1", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.18.1.tgz", + "integrity": "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.14", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.14.tgz", + "integrity": "sha512-U9kYi5bpVMEI31yC8iw4bJJp0avcHXA0W8/wNfLfnvJYzihQo2ZRPYPvpAAd570HAcCBjCTN7vnr+v4StKl1IQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "14.2.35", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz", + "integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==", + "license": "MIT", + "dependencies": { + "@next/env": "14.2.35", + "@swc/helpers": "0.5.5", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001579", + "graceful-fs": "^4.2.11", + "postcss": "8.4.31", + "styled-jsx": "5.1.1" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=18.17.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "14.2.33", + "@next/swc-darwin-x64": "14.2.33", + "@next/swc-linux-arm64-gnu": "14.2.33", + "@next/swc-linux-arm64-musl": "14.2.33", + "@next/swc-linux-x64-gnu": "14.2.33", + "@next/swc-linux-x64-musl": "14.2.33", + "@next/swc-win32-arm64-msvc": "14.2.33", + "@next/swc-win32-ia32-msvc": "14.2.33", + "@next/swc-win32-x64-msvc": "14.2.33" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-releases": { + "version": "2.0.48", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", + "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-dropzone": { + "version": "14.4.1", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.4.1.tgz", + "integrity": "sha512-QDuV76v3uKbHiH34SpwifZ+gOLi1+RdsCO1kl5vxMT4wW8R82+sthjvBw4th3NHF/XX6FBsqDYZVNN+pnhaw0g==", + "license": "MIT", + "dependencies": { + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, + "node_modules/react-hot-toast": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", + "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", + "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..c92ad9d --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/app/collect/page.tsx b/frontend/src/app/collect/page.tsx new file mode 100644 index 0000000..204125f --- /dev/null +++ b/frontend/src/app/collect/page.tsx @@ -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(""); + const [uploading, setUploading] = useState(false); + const [result, setResult] = useState(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("/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 ( +
+

收集错题

+ + {/* Mode Toggle */} +
+ + +
+ + {/* Subject Selector */} +
+ {SUBJECTS.map((s) => ( + + ))} +
+ + {mode === "ocr" ? ( + <> + {/* Upload Zone */} + {!result && ( +
+ + {uploading ? ( +
+
+

AI识别中...

+
+ ) : ( +
+
📸
+

+ {isDragActive ? "松开上传" : "点击或拖拽上传错题图片"} +

+

支持 JPG, PNG, WebP (最大10MB)

+
+ )} +
+ )} + + {/* OCR Result */} + {result && ( +
+
+

识别结果

+ {result.needs_review && ( + + 需要确认 + + )} +
+ + {result.full_mistake.image_url && ( + 错题原图 + )} + + {editing ? ( +
+
+ +