Initial commit: secondary school grade archive system.

Add FastAPI/React app with Docker deployment, Ubuntu one-click install, and docs for junior/senior high score tracking and mistake bank.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-28 11:18:58 +08:00
commit e329d3398a
76 changed files with 8506 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
# 复制为 .env 后修改(一键部署脚本会自动生成)
# 部署目录默认:/opt/secondary-school-grade-archive
# Web 对外端口(默认 23566
WEB_PORT=23566
# 生产环境务必修改
SECRET_KEY=请替换为随机字符串
POSTGRES_USER=postgres
POSTGRES_PASSWORD=请替换为强密码
POSTGRES_DB=student_archive
# 允许跨域的前端地址(部署完成后改为实际访问地址)
# 若通过反向代理访问,请自行在反向代理层处理,此处填写直连地址即可
CORS_ORIGINS=http://127.0.0.1:23566,http://localhost:23566
# Ollama(错题 AI 解法,可选)
# Docker 容器内通过 host.docker.internal 访问宿主机 Ollama
OLLAMA_BASE_URL=http://host.docker.internal:11434
OLLAMA_MODEL=qwen2.5:7b
# 成绩波动高亮阈值(0.08 = 8%)
FLUCTUATION_THRESHOLD=0.08
+8
View File
@@ -0,0 +1,8 @@
uploads/
backups/
__pycache__/
*.pyc
.env
!.env.example
node_modules/
dist/
+39
View File
@@ -0,0 +1,39 @@
# 版权与作者信息
## 软件名称
- 中文:中学成绩档案系统(初中 / 高中)
- 英文:Secondary School Grade Archive
## 著作权人
| 项目 | 内容 |
|------|------|
| 作者 | **马建军** |
| 微信 | **dekun03** |
| 手机 | **18364911125** |
## 权利声明
本仓库([secondary-school-grade-archive](https://git.bz121.com/dekun/secondary-school-grade-archive))中的全部源代码、部署脚本、文档及界面资源,著作权归 **马建军** 所有。
- 个人学习、研究可在保留版权信息的前提下使用;
- **商业使用、二次分发、闭源修改后对外提供服务等,须事先取得著作权人书面授权**;
- 禁止删除或修改软件内、文档中的版权与联系方式。
完整法律文本见项目根目录 [LICENSE](./LICENSE) 文件。
## 第三方开源组件
本系统基于多种开源技术构建,包括但不限于:
- FastAPI、SQLAlchemy、PaddleOCR、React、Ant Design、ECharts、PostgreSQL、Nginx 等
上述组件分别适用其各自的开源许可证,与本项目的版权声明相互独立。
## 联系授权
如需获取商业授权、定制开发或技术支持,请联系:
- 微信:**dekun03**
- 手机:**18364911125**
+20
View File
@@ -0,0 +1,20 @@
中学成绩档案系统 (Secondary School Grade Archive)
Copyright (c) 2024-2026 马建军 (Ma Jianjun). All rights reserved.
本软件及其源代码、文档、界面设计及相关资料的著作权均归 马建军 所有。
未经著作权人书面授权,任何单位或个人不得:
1. 复制、修改、传播或公开发布本软件源代码;
2. 将本软件用于商业用途或向第三方提供托管/销售;
3. 移除或篡改本软件中的版权标识与作者信息。
授权使用、商业合作或技术支持,请联系:
作者:马建军
微信:dekun03
手机:18364911125
本软件按「现状」提供,不提供任何明示或暗示的担保。使用本软件所产生的
任何直接或间接损失,著作权人不承担法律责任。
第三方组件(如 React、FastAPI、PostgreSQL、PaddleOCR 等)各自适用其
原项目许可证,与本版权声明无关。
+3
View File
@@ -0,0 +1,3 @@
# 中学成绩档案系统
# Copyright (c) 马建军. All rights reserved.
# 微信: dekun03 手机: 18364911125
+116
View File
@@ -0,0 +1,116 @@
# 中学成绩档案(初中 / 高中)
Secondary School Grade Archive — 多用户 Web 系统:成绩录入、占比趋势分析、错题 OCR + 本地 AI 解法。
**版权所有 © 马建军** · 微信 **dekun03** · 手机 **18364911125**
> 完整版权说明见 [COPYRIGHT.md](./COPYRIGHT.md) · 许可证见 [LICENSE](./LICENSE)
**代码仓库:** [https://git.bz121.com/dekun/secondary-school-grade-archive.git](https://git.bz121.com/dekun/secondary-school-grade-archive.git)
---
## 文档索引
| 文档 | 说明 |
|------|------|
| [docs/DEPLOY.md](./docs/DEPLOY.md) | **Ubuntu 一键 Docker 部署**/opt、端口 23566 |
| [docs/USAGE.md](./docs/USAGE.md) | **用户使用说明** |
| [COPYRIGHT.md](./COPYRIGHT.md) | 版权与授权 |
| [LICENSE](./LICENSE) | 许可证全文 |
---
## 功能概览
- 用户注册/登录,数据按账号隔离
- 学生管理(**初中 / 高中**学段、年级、班级)
- 成绩录入:周考 / 月考 / 期末(总分、得分、占比)
- 分科曲线:上升绿、下降红、大幅波动高亮
- 错题库:上传图片 → PaddleOCR → Ollama 生成解法
- 成绩 CSV 导出、备份脚本
---
## Ubuntu 一键部署(生产环境)
**要求:** root 用户 · Ubuntu · Docker · 目录 `/opt/secondary-school-grade-archive` · 端口 **23566**
```bash
git clone https://git.bz121.com/dekun/secondary-school-grade-archive.git /opt/secondary-school-grade-archive
cd /opt/secondary-school-grade-archive
chmod +x deploy/*.sh
bash deploy/install.sh
```
部署完成后访问:`http://<服务器IP>:23566`
脚本会自动:检测系统环境 → 安装 Docker(若缺失)→ 生成 `.env` → 构建并启动服务。
详细说明、运维命令、故障排查见 **[docs/DEPLOY.md](./docs/DEPLOY.md)**。
> **反向代理(HTTPS/域名)不包含在本项目中**,请自行配置 Nginx/Caddy 等,参见部署文档第 7 节。
---
## 本地开发
### Docker Compose(默认端口 23566
```bash
cp .env.example .env
docker compose --env-file .env up --build
```
- 前端:http://localhost:23566
- API 健康检查:http://localhost:23566/api/health
### 分步开发
```bash
# 仅数据库
docker compose up db -d
# 后端
cd backend && pip install -r requirements.txt && cp .env.example .env
uvicorn app.main:app --reload --port 8000
# 前端
cd frontend && npm install && npm run dev
```
### Ollama(错题 AI,可选)
```bash
ollama pull qwen2.5:7b
ollama serve
```
---
## 运维快捷命令
```bash
cd /opt/secondary-school-grade-archive
docker compose ps # 状态
bash deploy/update.sh # 更新
bash deploy/backup.sh # 备份
bash deploy/uninstall.sh # 停止服务
```
---
## 环境变量
见 [.env.example](./.env.example)
---
## 技术支持
- **作者:** 马建军
- **微信:** dekun03
- **手机:** 18364911125
未经授权不得商业使用或去除版权信息。
+7
View File
@@ -0,0 +1,7 @@
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/student_archive
SECRET_KEY=dev-secret-key-change-in-production
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
UPLOAD_DIR=uploads
OLLAMA_BASE_URL=http://127.0.0.1:11434
OLLAMA_MODEL=qwen2.5:7b
FLUCTUATION_THRESHOLD=0.08
+19
View File
@@ -0,0 +1,19 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libgomp1 libglib2.0-0 libsm6 libxext6 libxrender-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
ENV UPLOAD_DIR=/app/uploads
RUN mkdir -p /app/uploads
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
View File
View File
+21
View File
@@ -0,0 +1,21 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
DATABASE_URL: str = "postgresql://postgres:postgres@localhost:5432/student_archive"
SECRET_KEY: str = "change-me-in-production-use-a-long-random-string"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
ALGORITHM: str = "HS256"
UPLOAD_DIR: str = "uploads"
MAX_UPLOAD_SIZE: int = 10 * 1024 * 1024
OLLAMA_BASE_URL: str = "http://host.docker.internal:11434"
OLLAMA_MODEL: str = "qwen2.5:7b"
FLUCTUATION_THRESHOLD: float = 0.08
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000,http://localhost"
class Config:
env_file = ".env"
settings = Settings()
+19
View File
@@ -0,0 +1,19 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.core.config import settings
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+34
View File
@@ -0,0 +1,34 @@
import uuid
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.database import get_db
from app.models.user import User
security = HTTPBearer(auto_error=False)
def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
db: Session = Depends(get_db),
) -> User:
if credentials is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未登录")
token = credentials.credentials
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
user_id: str | None = payload.get("sub")
token_type: str | None = payload.get("type")
if user_id is None or token_type != "access":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效令牌")
except JWTError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效令牌") from exc
user = db.get(User, uuid.UUID(user_id))
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
return user
+31
View File
@@ -0,0 +1,31 @@
from datetime import datetime, timedelta, timezone
from typing import Any
from jose import jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(subject: str, expires_delta: timedelta | None = None) -> str:
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
)
payload: dict[str, Any] = {"sub": subject, "exp": expire, "type": "access"}
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def create_refresh_token(subject: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
payload: dict[str, Any] = {"sub": subject, "exp": expire, "type": "refresh"}
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
+48
View File
@@ -0,0 +1,48 @@
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.core.database import Base, SessionLocal, engine
from app.routers import auth, exams, export, students, subjects, wrong_questions
from app.services.migrate import run_migrations
from app.services.seed import seed_subjects
@asynccontextmanager
async def lifespan(app: FastAPI):
Path(settings.UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
Base.metadata.create_all(bind=engine)
run_migrations()
db = SessionLocal()
try:
seed_subjects(db)
finally:
db.close()
yield
app = FastAPI(title="中学成绩档案", version="1.0.0", lifespan=lifespan)
origins = [o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router, prefix="/api")
app.include_router(students.router, prefix="/api")
app.include_router(subjects.router, prefix="/api")
app.include_router(exams.router, prefix="/api")
app.include_router(wrong_questions.router, prefix="/api")
app.include_router(export.router, prefix="/api")
@app.get("/api/health")
def health():
return {"status": "ok"}
View File
+130
View File
@@ -0,0 +1,130 @@
import enum
import uuid
from datetime import date, datetime, timezone
from sqlalchemy import Date, DateTime, Enum, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class ExamType(str, enum.Enum):
weekly = "weekly"
monthly = "monthly"
final = "final"
class WrongQuestionStatus(str, enum.Enum):
pending = "pending"
ocr_done = "ocr_done"
solved = "solved"
failed = "failed"
class SchoolLevel(str, enum.Enum):
junior_high = "junior_high"
senior_high = "senior_high"
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
students: Mapped[list["Student"]] = relationship(back_populates="user", cascade="all, delete-orphan")
class Student(Base):
__tablename__ = "students"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
name: Mapped[str] = mapped_column(String(64))
school_level: Mapped[SchoolLevel] = mapped_column(
Enum(SchoolLevel), default=SchoolLevel.junior_high
)
grade: Mapped[str | None] = mapped_column(String(32), nullable=True)
class_name: Mapped[str | None] = mapped_column(String(32), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
user: Mapped["User"] = relationship(back_populates="students")
exam_records: Mapped[list["ExamRecord"]] = relationship(
back_populates="student", cascade="all, delete-orphan"
)
wrong_questions: Mapped[list["WrongQuestion"]] = relationship(
back_populates="student", cascade="all, delete-orphan"
)
class Subject(Base):
__tablename__ = "subjects"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(32), unique=True)
scores: Mapped[list["SubjectScore"]] = relationship(back_populates="subject")
wrong_questions: Mapped[list["WrongQuestion"]] = relationship(back_populates="subject")
class ExamRecord(Base):
__tablename__ = "exam_records"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
student_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("students.id"), index=True)
exam_type: Mapped[ExamType] = mapped_column(Enum(ExamType))
exam_date: Mapped[date] = mapped_column(Date)
title: Mapped[str | None] = mapped_column(String(128), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
student: Mapped["Student"] = relationship(back_populates="exam_records")
scores: Mapped[list["SubjectScore"]] = relationship(
back_populates="exam_record", cascade="all, delete-orphan"
)
class SubjectScore(Base):
__tablename__ = "subject_scores"
__table_args__ = (UniqueConstraint("exam_record_id", "subject_id", name="uq_exam_subject"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
exam_record_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("exam_records.id"), index=True
)
subject_id: Mapped[int] = mapped_column(Integer, ForeignKey("subjects.id"))
total_score: Mapped[float] = mapped_column(Numeric(8, 2))
obtained_score: Mapped[float] = mapped_column(Numeric(8, 2))
ratio: Mapped[float] = mapped_column(Numeric(8, 4))
exam_record: Mapped["ExamRecord"] = relationship(back_populates="scores")
subject: Mapped["Subject"] = relationship(back_populates="scores")
class WrongQuestion(Base):
__tablename__ = "wrong_questions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
student_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("students.id"), index=True)
subject_id: Mapped[int] = mapped_column(Integer, ForeignKey("subjects.id"))
image_path: Mapped[str] = mapped_column(String(512))
ocr_raw_text: Mapped[str | None] = mapped_column(Text, nullable=True)
question_text: Mapped[str | None] = mapped_column(Text, nullable=True)
solution_text: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[WrongQuestionStatus] = mapped_column(
Enum(WrongQuestionStatus), default=WrongQuestionStatus.pending
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
student: Mapped["Student"] = relationship(back_populates="wrong_questions")
subject: Mapped["Subject"] = relationship(back_populates="wrong_questions")
View File
+67
View File
@@ -0,0 +1,67 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.database import get_db
from app.core.deps import get_current_user
from app.core.security import (
create_access_token,
create_refresh_token,
get_password_hash,
verify_password,
)
from app.models.user import User
from app.schemas import RefreshRequest, TokenResponse, UserLogin, UserOut, UserRegister
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def register(data: UserRegister, db: Session = Depends(get_db)):
if db.query(User).filter(User.username == data.username).first():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名已存在")
user = User(username=data.username, password_hash=get_password_hash(data.password))
db.add(user)
db.commit()
db.refresh(user)
return user
@router.post("/login", response_model=TokenResponse)
def login(data: UserLogin, db: Session = Depends(get_db)):
user = db.query(User).filter(User.username == data.username).first()
if user is None or not verify_password(data.password, user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
return TokenResponse(
access_token=create_access_token(str(user.id)),
refresh_token=create_refresh_token(str(user.id)),
)
@router.post("/refresh", response_model=TokenResponse)
def refresh(data: RefreshRequest, db: Session = Depends(get_db)):
try:
payload = jwt.decode(data.refresh_token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
user_id = payload.get("sub")
token_type = payload.get("type")
if user_id is None or token_type != "refresh":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效刷新令牌")
except JWTError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效刷新令牌") from exc
user = db.get(User, uuid.UUID(user_id))
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
return TokenResponse(
access_token=create_access_token(str(user.id)),
refresh_token=create_refresh_token(str(user.id)),
)
@router.get("/me", response_model=UserOut)
def me(current_user: User = Depends(get_current_user)):
return current_user
+182
View File
@@ -0,0 +1,182 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session, joinedload
from app.core.database import get_db
from app.core.deps import get_current_user
from app.models.user import ExamRecord, SubjectScore, User
from app.schemas import ExamCreate, ExamOut, ExamUpdate, ScoreOut, TrendResponse
from app.services.score_trend import build_trend
from app.services.student_access import get_student_for_user
router = APIRouter(tags=["exams"])
def _score_to_out(score: SubjectScore) -> ScoreOut:
return ScoreOut(
id=score.id,
subject_id=score.subject_id,
subject_name=score.subject.name if score.subject else None,
total_score=float(score.total_score),
obtained_score=float(score.obtained_score),
ratio=float(score.ratio),
)
def _exam_to_out(exam: ExamRecord) -> ExamOut:
return ExamOut(
id=exam.id,
exam_type=exam.exam_type,
exam_date=exam.exam_date,
title=exam.title,
created_at=exam.created_at,
scores=[_score_to_out(s) for s in exam.scores],
)
def _apply_scores(db: Session, exam: ExamRecord, scores_data):
exam.scores.clear()
for item in scores_data:
ratio = round(item.obtained_score / item.total_score, 4)
exam.scores.append(
SubjectScore(
subject_id=item.subject_id,
total_score=item.total_score,
obtained_score=item.obtained_score,
ratio=ratio,
)
)
@router.get("/students/{student_id}/exams", response_model=list[ExamOut])
def list_exams(
student_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
get_student_for_user(db, student_id, current_user.id)
exams = (
db.query(ExamRecord)
.options(joinedload(ExamRecord.scores).joinedload(SubjectScore.subject))
.filter(ExamRecord.student_id == student_id)
.order_by(ExamRecord.exam_date.desc(), ExamRecord.created_at.desc())
.all()
)
return [_exam_to_out(e) for e in exams]
@router.post("/students/{student_id}/exams", response_model=ExamOut, status_code=status.HTTP_201_CREATED)
def create_exam(
student_id: uuid.UUID,
data: ExamCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
get_student_for_user(db, student_id, current_user.id)
exam = ExamRecord(
student_id=student_id,
exam_type=data.exam_type,
exam_date=data.exam_date,
title=data.title,
)
db.add(exam)
db.flush()
if data.scores:
_apply_scores(db, exam, data.scores)
db.commit()
db.refresh(exam)
exam = (
db.query(ExamRecord)
.options(joinedload(ExamRecord.scores).joinedload(SubjectScore.subject))
.filter(ExamRecord.id == exam.id)
.first()
)
return _exam_to_out(exam)
@router.get("/exams/{exam_id}", response_model=ExamOut)
def get_exam(
exam_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
exam = (
db.query(ExamRecord)
.options(joinedload(ExamRecord.scores).joinedload(SubjectScore.subject))
.join(ExamRecord.student)
.filter(ExamRecord.id == exam_id)
.first()
)
if exam is None or exam.student.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="考试记录不存在")
return _exam_to_out(exam)
@router.patch("/exams/{exam_id}", response_model=ExamOut)
def update_exam(
exam_id: uuid.UUID,
data: ExamUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
exam = (
db.query(ExamRecord)
.options(joinedload(ExamRecord.scores).joinedload(SubjectScore.subject))
.join(ExamRecord.student)
.filter(ExamRecord.id == exam_id)
.first()
)
if exam is None or exam.student.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="考试记录不存在")
if data.exam_type is not None:
exam.exam_type = data.exam_type
if data.exam_date is not None:
exam.exam_date = data.exam_date
if data.title is not None:
exam.title = data.title
if data.scores is not None:
_apply_scores(db, exam, data.scores)
db.commit()
db.refresh(exam)
exam = (
db.query(ExamRecord)
.options(joinedload(ExamRecord.scores).joinedload(SubjectScore.subject))
.filter(ExamRecord.id == exam.id)
.first()
)
return _exam_to_out(exam)
@router.delete("/exams/{exam_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_exam(
exam_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
exam = (
db.query(ExamRecord)
.join(ExamRecord.student)
.filter(ExamRecord.id == exam_id)
.first()
)
if exam is None or exam.student.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="考试记录不存在")
db.delete(exam)
db.commit()
@router.get("/students/{student_id}/scores/trend", response_model=TrendResponse)
def get_score_trend(
student_id: uuid.UUID,
subject_id: int = Query(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
get_student_for_user(db, student_id, current_user.id)
try:
return build_trend(db, student_id, subject_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
+54
View File
@@ -0,0 +1,54 @@
import csv
import io
import uuid
from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session, joinedload
from app.core.database import get_db
from app.core.deps import get_current_user
from app.models.user import ExamRecord, SubjectScore, User
from app.services.student_access import get_student_for_user
router = APIRouter(tags=["export"])
@router.get("/students/{student_id}/scores/export")
def export_scores_csv(
student_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
student = get_student_for_user(db, student_id, current_user.id)
exams = (
db.query(ExamRecord)
.options(joinedload(ExamRecord.scores).joinedload(SubjectScore.subject))
.filter(ExamRecord.student_id == student_id)
.order_by(ExamRecord.exam_date.asc())
.all()
)
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["考试日期", "考试类型", "标题", "科目", "总分", "得分", "占比"])
type_map = {"weekly": "周考", "monthly": "月考", "final": "期末"}
for exam in exams:
for score in exam.scores:
writer.writerow([
exam.exam_date.isoformat(),
type_map.get(exam.exam_type.value, exam.exam_type.value),
exam.title or "",
score.subject.name if score.subject else "",
float(score.total_score),
float(score.obtained_score),
f"{float(score.ratio) * 100:.2f}%",
])
output.seek(0)
filename = f"{student.name}_scores.csv"
return StreamingResponse(
iter([output.getvalue().encode("utf-8-sig")]),
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
+73
View File
@@ -0,0 +1,73 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.deps import get_current_user
from app.models.user import Student, User
from app.schemas import StudentCreate, StudentOut, StudentUpdate
from app.services.student_access import get_student_for_user
router = APIRouter(prefix="/students", tags=["students"])
@router.get("", response_model=list[StudentOut])
def list_students(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return (
db.query(Student)
.filter(Student.user_id == current_user.id)
.order_by(Student.created_at.desc())
.all()
)
@router.post("", response_model=StudentOut, status_code=status.HTTP_201_CREATED)
def create_student(
data: StudentCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
student = Student(user_id=current_user.id, **data.model_dump())
db.add(student)
db.commit()
db.refresh(student)
return student
@router.get("/{student_id}", response_model=StudentOut)
def get_student(
student_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return get_student_for_user(db, student_id, current_user.id)
@router.patch("/{student_id}", response_model=StudentOut)
def update_student(
student_id: uuid.UUID,
data: StudentUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
student = get_student_for_user(db, student_id, current_user.id)
for key, value in data.model_dump(exclude_unset=True).items():
setattr(student, key, value)
db.commit()
db.refresh(student)
return student
@router.delete("/{student_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_student(
student_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
student = get_student_for_user(db, student_id, current_user.id)
db.delete(student)
db.commit()
+17
View File
@@ -0,0 +1,17 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.deps import get_current_user
from app.models.user import Subject, User
from app.schemas import SubjectOut
router = APIRouter(prefix="/subjects", tags=["subjects"])
@router.get("", response_model=list[SubjectOut])
def list_subjects(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return db.query(Subject).order_by(Subject.id).all()
+313
View File
@@ -0,0 +1,313 @@
import uuid
from pathlib import Path
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, Query, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session, joinedload
from app.core.config import settings
from app.core.database import SessionLocal, get_db
from app.core.deps import get_current_user
from app.models.user import Subject, User, WrongQuestion, WrongQuestionStatus
from app.schemas import WrongQuestionOut, WrongQuestionUpdate
from app.services import ocr as ocr_service
from app.services import ollama as ollama_service
from app.services.student_access import get_student_for_user
router = APIRouter(tags=["wrong_questions"])
def _wq_to_out(wq: WrongQuestion) -> WrongQuestionOut:
return WrongQuestionOut(
id=wq.id,
student_id=wq.student_id,
subject_id=wq.subject_id,
subject_name=wq.subject.name if wq.subject else None,
image_path=wq.image_path,
ocr_raw_text=wq.ocr_raw_text,
question_text=wq.question_text,
solution_text=wq.solution_text,
status=wq.status,
created_at=wq.created_at,
)
def _process_wrong_question(question_id: uuid.UUID):
db = SessionLocal()
try:
wq = (
db.query(WrongQuestion)
.options(joinedload(WrongQuestion.subject), joinedload(WrongQuestion.student))
.filter(WrongQuestion.id == question_id)
.first()
)
if wq is None:
return
image_full = Path(settings.UPLOAD_DIR) / wq.image_path
try:
ocr_text = ocr_service.run_ocr(str(image_full))
wq.ocr_raw_text = ocr_text or None
wq.status = WrongQuestionStatus.ocr_done if ocr_text else WrongQuestionStatus.failed
db.commit()
except Exception:
wq.status = WrongQuestionStatus.failed
db.commit()
return
if not ocr_text:
return
subject_name = wq.subject.name if wq.subject else "综合"
school_level = wq.student.school_level if wq.student else None
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
question_text = loop.run_until_complete(
ollama_service.format_question(subject_name, ocr_text, school_level)
)
solution_text = loop.run_until_complete(
ollama_service.generate_solution(subject_name, question_text, school_level)
)
wq.question_text = question_text
wq.solution_text = solution_text
wq.status = WrongQuestionStatus.solved
db.commit()
except Exception:
wq.status = WrongQuestionStatus.ocr_done
db.commit()
finally:
loop.close()
finally:
db.close()
@router.get("/students/{student_id}/wrong-questions", response_model=list[WrongQuestionOut])
def list_wrong_questions(
student_id: uuid.UUID,
subject_id: int | None = Query(None),
q: str | None = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
get_student_for_user(db, student_id, current_user.id)
query = (
db.query(WrongQuestion)
.options(joinedload(WrongQuestion.subject))
.filter(WrongQuestion.student_id == student_id)
)
if subject_id is not None:
query = query.filter(WrongQuestion.subject_id == subject_id)
if q:
pattern = f"%{q}%"
query = query.filter(
(WrongQuestion.question_text.ilike(pattern))
| (WrongQuestion.solution_text.ilike(pattern))
| (WrongQuestion.ocr_raw_text.ilike(pattern))
)
items = query.order_by(WrongQuestion.created_at.desc()).all()
return [_wq_to_out(w) for w in items]
@router.post(
"/students/{student_id}/wrong-questions",
response_model=WrongQuestionOut,
status_code=status.HTTP_201_CREATED,
)
async def upload_wrong_question(
student_id: uuid.UUID,
background_tasks: BackgroundTasks,
subject_id: int = Form(...),
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
get_student_for_user(db, student_id, current_user.id)
subject = db.get(Subject, subject_id)
if subject is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="科目不存在")
content = await file.read()
if len(content) > settings.MAX_UPLOAD_SIZE:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文件超过10MB限制")
wq = WrongQuestion(
student_id=student_id,
subject_id=subject_id,
image_path="",
status=WrongQuestionStatus.pending,
)
db.add(wq)
db.flush()
rel_path = ocr_service.save_upload_file(
str(current_user.id), str(wq.id), file.filename or "image.jpg", content
)
wq.image_path = rel_path
db.commit()
db.refresh(wq)
wq = (
db.query(WrongQuestion)
.options(joinedload(WrongQuestion.subject))
.filter(WrongQuestion.id == wq.id)
.first()
)
background_tasks.add_task(_process_wrong_question, wq.id)
return _wq_to_out(wq)
@router.get("/wrong-questions/{question_id}", response_model=WrongQuestionOut)
def get_wrong_question(
question_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
wq = (
db.query(WrongQuestion)
.options(joinedload(WrongQuestion.subject), joinedload(WrongQuestion.student))
.filter(WrongQuestion.id == question_id)
.first()
)
if wq is None or wq.student.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在")
return _wq_to_out(wq)
@router.patch("/wrong-questions/{question_id}", response_model=WrongQuestionOut)
def update_wrong_question(
question_id: uuid.UUID,
data: WrongQuestionUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
wq = (
db.query(WrongQuestion)
.options(joinedload(WrongQuestion.subject), joinedload(WrongQuestion.student))
.filter(WrongQuestion.id == question_id)
.first()
)
if wq is None or wq.student.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在")
if data.subject_id is not None:
if db.get(Subject, data.subject_id) is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="科目不存在")
wq.subject_id = data.subject_id
if data.question_text is not None:
wq.question_text = data.question_text
if data.solution_text is not None:
wq.solution_text = data.solution_text
db.commit()
db.refresh(wq)
return _wq_to_out(wq)
@router.delete("/wrong-questions/{question_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_wrong_question(
question_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
wq = (
db.query(WrongQuestion)
.options(joinedload(WrongQuestion.student))
.filter(WrongQuestion.id == question_id)
.first()
)
if wq is None or wq.student.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在")
image_full = Path(settings.UPLOAD_DIR) / wq.image_path
db.delete(wq)
db.commit()
if image_full.exists():
image_full.unlink()
@router.post("/wrong-questions/{question_id}/retry-ocr", response_model=WrongQuestionOut)
def retry_ocr(
question_id: uuid.UUID,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
wq = (
db.query(WrongQuestion)
.options(joinedload(WrongQuestion.subject), joinedload(WrongQuestion.student))
.filter(WrongQuestion.id == question_id)
.first()
)
if wq is None or wq.student.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在")
wq.status = WrongQuestionStatus.pending
db.commit()
background_tasks.add_task(_process_wrong_question, wq.id)
return _wq_to_out(wq)
@router.post("/wrong-questions/{question_id}/regenerate-solution", response_model=WrongQuestionOut)
async def regenerate_solution(
question_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
wq = (
db.query(WrongQuestion)
.options(joinedload(WrongQuestion.subject), joinedload(WrongQuestion.student))
.filter(WrongQuestion.id == question_id)
.first()
)
if wq is None or wq.student.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在")
if not wq.question_text and not wq.ocr_raw_text:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="缺少题目内容")
subject_name = wq.subject.name if wq.subject else "综合"
school_level = wq.student.school_level if wq.student else None
question_text = wq.question_text or wq.ocr_raw_text or ""
try:
if not wq.question_text and wq.ocr_raw_text:
wq.question_text = await ollama_service.format_question(
subject_name, wq.ocr_raw_text, school_level
)
question_text = wq.question_text
wq.solution_text = await ollama_service.generate_solution(
subject_name, question_text, school_level
)
wq.status = WrongQuestionStatus.solved
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Ollama 调用失败: {exc}"
) from exc
db.commit()
db.refresh(wq)
return _wq_to_out(wq)
@router.get("/wrong-questions/{question_id}/image")
def get_wrong_question_image(
question_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
wq = (
db.query(WrongQuestion)
.options(joinedload(WrongQuestion.student))
.filter(WrongQuestion.id == question_id)
.first()
)
if wq is None or wq.student.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在")
image_full = Path(settings.UPLOAD_DIR) / wq.image_path
if not image_full.exists():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
return FileResponse(image_full)
+183
View File
@@ -0,0 +1,183 @@
from datetime import date, datetime
from enum import Enum
from uuid import UUID
from pydantic import BaseModel, Field, field_validator
class ExamTypeEnum(str, Enum):
weekly = "weekly"
monthly = "monthly"
final = "final"
class WrongQuestionStatusEnum(str, Enum):
pending = "pending"
ocr_done = "ocr_done"
solved = "solved"
failed = "failed"
class SchoolLevelEnum(str, Enum):
junior_high = "junior_high"
senior_high = "senior_high"
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class UserRegister(BaseModel):
username: str = Field(min_length=3, max_length=64)
password: str = Field(min_length=6, max_length=128)
class UserLogin(BaseModel):
username: str
password: str
class RefreshRequest(BaseModel):
refresh_token: str
class UserOut(BaseModel):
id: UUID
username: str
created_at: datetime
model_config = {"from_attributes": True}
class StudentCreate(BaseModel):
name: str = Field(min_length=1, max_length=64)
school_level: SchoolLevelEnum = SchoolLevelEnum.junior_high
grade: str | None = None
class_name: str | None = None
class StudentUpdate(BaseModel):
name: str | None = None
school_level: SchoolLevelEnum | None = None
grade: str | None = None
class_name: str | None = None
class StudentOut(BaseModel):
id: UUID
name: str
school_level: SchoolLevelEnum
grade: str | None
class_name: str | None
created_at: datetime
model_config = {"from_attributes": True}
class SubjectOut(BaseModel):
id: int
name: str
model_config = {"from_attributes": True}
class ScoreInput(BaseModel):
subject_id: int
total_score: float
obtained_score: float
@field_validator("total_score")
@classmethod
def validate_total(cls, v: float) -> float:
if v <= 0:
raise ValueError("总分必须大于0")
return v
@field_validator("obtained_score")
@classmethod
def validate_obtained(cls, v: float, info) -> float:
total = info.data.get("total_score")
if total is not None and v > total:
raise ValueError("得分不能大于总分")
if v < 0:
raise ValueError("得分不能为负")
return v
class ScoreOut(BaseModel):
id: UUID
subject_id: int
subject_name: str | None = None
total_score: float
obtained_score: float
ratio: float
model_config = {"from_attributes": True}
class ExamCreate(BaseModel):
exam_type: ExamTypeEnum
exam_date: date
title: str | None = None
scores: list[ScoreInput] = []
class ExamUpdate(BaseModel):
exam_type: ExamTypeEnum | None = None
exam_date: date | None = None
title: str | None = None
scores: list[ScoreInput] | None = None
class ExamOut(BaseModel):
id: UUID
exam_type: ExamTypeEnum
exam_date: date
title: str | None
created_at: datetime
scores: list[ScoreOut] = []
model_config = {"from_attributes": True}
class TrendPoint(BaseModel):
exam_id: UUID
exam_type: ExamTypeEnum
exam_date: date
title: str | None
ratio: float
ratio_percent: float
delta: float | None = None
delta_percent: float | None = None
is_volatile: bool = False
direction: str | None = None
class TrendResponse(BaseModel):
subject_id: int
subject_name: str
threshold: float
points: list[TrendPoint]
class WrongQuestionOut(BaseModel):
id: UUID
student_id: UUID
subject_id: int
subject_name: str | None = None
image_path: str
ocr_raw_text: str | None
question_text: str | None
solution_text: str | None
status: WrongQuestionStatusEnum
created_at: datetime
model_config = {"from_attributes": True}
class WrongQuestionUpdate(BaseModel):
question_text: str | None = None
solution_text: str | None = None
subject_id: int | None = None
View File
+20
View File
@@ -0,0 +1,20 @@
from sqlalchemy import inspect, text
from app.core.database import engine
def run_migrations() -> None:
"""Apply lightweight schema updates for existing databases."""
inspector = inspect(engine)
if "students" not in inspector.get_table_names():
return
columns = {col["name"] for col in inspector.get_columns("students")}
if "school_level" not in columns:
with engine.begin() as conn:
conn.execute(
text(
"ALTER TABLE students ADD COLUMN school_level VARCHAR(32) "
"NOT NULL DEFAULT 'junior_high'"
)
)
+40
View File
@@ -0,0 +1,40 @@
from pathlib import Path
from app.core.config import settings
_ocr_engine = None
def get_ocr_engine():
global _ocr_engine
if _ocr_engine is None:
from paddleocr import PaddleOCR
_ocr_engine = PaddleOCR(use_angle_cls=True, lang="ch", show_log=False)
return _ocr_engine
def run_ocr(image_path: str) -> str:
engine = get_ocr_engine()
result = engine.ocr(image_path, cls=True)
if not result or not result[0]:
return ""
lines = []
for line in result[0]:
if line and len(line) >= 2:
text = line[1][0]
if text:
lines.append(text)
return "\n".join(lines)
def save_upload_file(user_id: str, question_id: str, filename: str, content: bytes) -> str:
ext = Path(filename).suffix.lower() or ".jpg"
if ext not in {".jpg", ".jpeg", ".png", ".webp"}:
ext = ".jpg"
user_dir = Path(settings.UPLOAD_DIR) / user_id
user_dir.mkdir(parents=True, exist_ok=True)
rel_path = f"{user_id}/{question_id}{ext}"
full_path = Path(settings.UPLOAD_DIR) / rel_path
full_path.write_bytes(content)
return rel_path
+47
View File
@@ -0,0 +1,47 @@
import httpx
from app.core.config import settings
from app.services.school_level import school_level_label
QUESTION_PROMPT = """你是一位{stage}老师。以下是从试卷 OCR 识别出的文字,可能含有噪声。
科目{subject}
请整理出清晰的题目内容保留题号选项公式只输出题目正文不要解释
OCR 原文
{ocr_text}
"""
SOLUTION_PROMPT = """你是一位耐心的{stage}{subject}老师。请为以下题目给出详细解法。
要求步骤清晰语言适合{stage}学生理解指出考点和易错点
题目
{question_text}
"""
async def ollama_generate(prompt: str) -> str:
url = f"{settings.OLLAMA_BASE_URL.rstrip('/')}/api/generate"
payload = {
"model": settings.OLLAMA_MODEL,
"prompt": prompt,
"stream": False,
}
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(url, json=payload)
response.raise_for_status()
data = response.json()
return (data.get("response") or "").strip()
async def format_question(subject: str, ocr_text: str, school_level=None) -> str:
stage = school_level_label(school_level)
prompt = QUESTION_PROMPT.format(stage=stage, subject=subject, ocr_text=ocr_text)
return await ollama_generate(prompt)
async def generate_solution(subject: str, question_text: str, school_level=None) -> str:
stage = school_level_label(school_level)
prompt = SOLUTION_PROMPT.format(
stage=stage, subject=subject, question_text=question_text
)
return await ollama_generate(prompt)
+17
View File
@@ -0,0 +1,17 @@
from app.models.user import SchoolLevel
SCHOOL_LEVEL_LABELS: dict[SchoolLevel, str] = {
SchoolLevel.junior_high: "初中",
SchoolLevel.senior_high: "高中",
}
def school_level_label(level: SchoolLevel | str | None) -> str:
if level is None:
return "初中"
if isinstance(level, str):
try:
level = SchoolLevel(level)
except ValueError:
return "初中"
return SCHOOL_LEVEL_LABELS.get(level, "初中")
+66
View File
@@ -0,0 +1,66 @@
from sqlalchemy.orm import Session, joinedload
from app.core.config import settings
from app.models.user import ExamRecord, Subject, SubjectScore
from app.schemas import ExamTypeEnum, TrendPoint, TrendResponse
def build_trend(db: Session, student_id, subject_id: int) -> TrendResponse:
subject = db.get(Subject, subject_id)
if subject is None:
raise ValueError("科目不存在")
scores = (
db.query(SubjectScore)
.join(ExamRecord)
.options(joinedload(SubjectScore.exam_record))
.filter(
ExamRecord.student_id == student_id,
SubjectScore.subject_id == subject_id,
)
.order_by(ExamRecord.exam_date.asc(), ExamRecord.created_at.asc())
.all()
)
threshold = settings.FLUCTUATION_THRESHOLD
points: list[TrendPoint] = []
prev_ratio: float | None = None
for score in scores:
exam = score.exam_record
ratio = float(score.ratio)
delta = None if prev_ratio is None else ratio - prev_ratio
direction = None
is_volatile = False
if delta is not None:
if delta > 0:
direction = "up"
elif delta < 0:
direction = "down"
else:
direction = "flat"
is_volatile = abs(delta) >= threshold
points.append(
TrendPoint(
exam_id=exam.id,
exam_type=ExamTypeEnum(exam.exam_type.value),
exam_date=exam.exam_date,
title=exam.title,
ratio=ratio,
ratio_percent=round(ratio * 100, 2),
delta=delta,
delta_percent=round(delta * 100, 2) if delta is not None else None,
is_volatile=is_volatile,
direction=direction,
)
)
prev_ratio = ratio
return TrendResponse(
subject_id=subject_id,
subject_name=subject.name,
threshold=threshold,
points=points,
)
+23
View File
@@ -0,0 +1,23 @@
from sqlalchemy.orm import Session
from app.models.user import Subject
DEFAULT_SUBJECTS = [
"语文",
"数学",
"英语",
"物理",
"化学",
"生物",
"历史",
"地理",
"政治",
]
def seed_subjects(db: Session) -> None:
existing = {s.name for s in db.query(Subject).all()}
for name in DEFAULT_SUBJECTS:
if name not in existing:
db.add(Subject(name=name))
db.commit()
+13
View File
@@ -0,0 +1,13 @@
import uuid
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from app.models.user import Student
def get_student_for_user(db: Session, student_id: uuid.UUID, user_id: uuid.UUID) -> Student:
student = db.get(Student, student_id)
if student is None or student.user_id != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="学生不存在")
return student
+16
View File
@@ -0,0 +1,16 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy==2.0.36
psycopg2-binary==2.9.10
alembic==1.14.0
pydantic==2.10.3
pydantic-settings==2.6.1
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.2.1
python-multipart==0.0.20
httpx==0.28.1
paddleocr==2.9.1
paddlepaddle==2.6.2
Pillow==11.0.0
aiofiles==24.1.0
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
#
# 备份 PostgreSQL 与 uploads 目录
# 版权所有 (c) 马建军
#
set -euo pipefail
INSTALL_DIR="${INSTALL_DIR:-/opt/secondary-school-grade-archive}"
BACKUP_DIR="${BACKUP_DIR:-${INSTALL_DIR}/backups}"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
cd "${INSTALL_DIR}"
mkdir -p "${BACKUP_DIR}"
echo "[INFO] 备份数据库…"
docker compose --env-file .env exec -T db \
pg_dump -U "${POSTGRES_USER:-postgres}" "${POSTGRES_DB:-student_archive}" \
> "${BACKUP_DIR}/db_${TIMESTAMP}.sql"
echo "[INFO] 备份 uploads…"
tar -czf "${BACKUP_DIR}/uploads_${TIMESTAMP}.tar.gz" -C "${INSTALL_DIR}" uploads/
echo "[INFO] 备份完成:"
echo " ${BACKUP_DIR}/db_${TIMESTAMP}.sql"
echo " ${BACKUP_DIR}/uploads_${TIMESTAMP}.tar.gz"
+231
View File
@@ -0,0 +1,231 @@
#!/usr/bin/env bash
#
# 中学成绩档案系统 — Ubuntu 一键部署脚本
# 版权所有 (c) 马建军 微信: dekun03 手机: 18364911125
#
# 用法(root:
# curl -fsSL .../deploy/install.sh | bash
# 或
# bash deploy/install.sh
#
set -euo pipefail
REPO_URL="${REPO_URL:-https://git.bz121.com/dekun/secondary-school-grade-archive.git}"
INSTALL_DIR="${INSTALL_DIR:-/opt/secondary-school-grade-archive}"
WEB_PORT="${WEB_PORT:-23566}"
BRANCH="${BRANCH:-main}"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $*"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
require_root() {
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
log_error "请使用 root 用户运行本脚本,例如: sudo bash deploy/install.sh"
exit 1
fi
}
check_os() {
if [[ ! -f /etc/os-release ]]; then
log_error "无法识别操作系统,本脚本仅支持 Ubuntu/Debian 系 Linux"
exit 1
fi
# shellcheck source=/dev/null
source /etc/os-release
log_info "检测到系统: ${NAME:-Unknown} ${VERSION_ID:-}"
case "${ID:-}" in
ubuntu|debian)
;;
*)
log_warn "当前系统为 ${ID:-unknown},未经完整测试,继续安装…"
;;
esac
}
check_resources() {
local mem_kb disk_kb
mem_kb=$(grep MemTotal /proc/meminfo | awk '{print $2}')
disk_kb=$(df -k "${INSTALL_DIR%/*}" 2>/dev/null | tail -1 | awk '{print $4}')
if [[ "${mem_kb:-0}" -lt 1800000 ]]; then
log_warn "内存不足 2GBPaddleOCR 首次运行可能较慢"
fi
if [[ "${disk_kb:-0}" -lt 5242880 ]]; then
log_warn "可用磁盘空间不足 5GB,请确保有足够空间存放镜像与 OCR 模型"
fi
}
check_port() {
if command -v ss &>/dev/null; then
if ss -tln | grep -q ":${WEB_PORT} "; then
log_error "端口 ${WEB_PORT} 已被占用,请修改 WEB_PORT 环境变量后重试"
exit 1
fi
elif command -v netstat &>/dev/null; then
if netstat -tln | grep -q ":${WEB_PORT} "; then
log_error "端口 ${WEB_PORT} 已被占用"
exit 1
fi
fi
log_info "端口 ${WEB_PORT} 可用"
}
install_packages() {
log_info "安装基础依赖…"
apt-get update -qq
apt-get install -y -qq git curl ca-certificates openssl
}
install_docker() {
if command -v docker &>/dev/null; then
log_info "Docker 已安装: $(docker --version)"
else
log_info "正在安装 Docker…"
apt-get install -y -qq apt-transport-https gnupg lsb-release
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -qq
apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin
systemctl enable docker
systemctl start docker
log_info "Docker 安装完成"
fi
if ! docker compose version &>/dev/null; then
log_error "未检测到 docker compose 插件,请手动安装 docker-compose-plugin"
exit 1
fi
log_info "Docker Compose: $(docker compose version)"
}
clone_or_update_repo() {
if [[ -d "${INSTALL_DIR}/.git" ]]; then
log_info "更新代码: ${INSTALL_DIR}"
git -C "${INSTALL_DIR}" fetch origin
git -C "${INSTALL_DIR}" checkout "${BRANCH}" 2>/dev/null || true
git -C "${INSTALL_DIR}" pull --ff-only origin "${BRANCH}" || git -C "${INSTALL_DIR}" pull origin "${BRANCH}"
elif [[ -d "${INSTALL_DIR}" ]]; then
log_error "目录 ${INSTALL_DIR} 已存在但不是 git 仓库,请备份后删除或修改 INSTALL_DIR"
exit 1
else
log_info "克隆仓库到 ${INSTALL_DIR}"
mkdir -p "$(dirname "${INSTALL_DIR}")"
git clone --branch "${BRANCH}" "${REPO_URL}" "${INSTALL_DIR}" || git clone "${REPO_URL}" "${INSTALL_DIR}"
fi
}
generate_env() {
local env_file="${INSTALL_DIR}/.env"
local server_ip
server_ip=$(hostname -I 2>/dev/null | awk '{print $1}')
server_ip="${server_ip:-127.0.0.1}"
if [[ -f "${env_file}" ]]; then
log_info "保留已有 .env 配置"
# shellcheck source=/dev/null
source "${env_file}"
WEB_PORT="${WEB_PORT:-23566}"
return
fi
log_info "生成 .env 配置文件"
local secret pg_pass
secret=$(openssl rand -hex 32)
pg_pass=$(openssl rand -hex 16)
cat > "${env_file}" <<EOF
# 由 deploy/install.sh 自动生成 — $(date -Iseconds)
WEB_PORT=${WEB_PORT}
SECRET_KEY=${secret}
POSTGRES_USER=postgres
POSTGRES_PASSWORD=${pg_pass}
POSTGRES_DB=student_archive
CORS_ORIGINS=http://${server_ip}:${WEB_PORT},http://127.0.0.1:${WEB_PORT},http://localhost:${WEB_PORT}
OLLAMA_BASE_URL=http://host.docker.internal:11434
OLLAMA_MODEL=qwen2.5:7b
FLUCTUATION_THRESHOLD=0.08
EOF
chmod 600 "${env_file}"
log_info ".env 已写入 ${env_file}"
}
start_services() {
log_info "构建并启动 Docker 服务(首次可能需 1030 分钟)…"
cd "${INSTALL_DIR}"
mkdir -p uploads backups
docker compose --env-file .env pull 2>/dev/null || true
docker compose --env-file .env up -d --build
}
wait_healthy() {
local i max=60
log_info "等待 API 就绪…"
for ((i=1; i<=max; i++)); do
if docker compose --env-file "${INSTALL_DIR}/.env" exec -T api \
python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health')" &>/dev/null; then
log_info "API 健康检查通过"
return 0
fi
sleep 3
done
log_warn "API 启动超时,请执行: cd ${INSTALL_DIR} && docker compose logs api"
}
print_summary() {
local ip
ip=$(hostname -I 2>/dev/null | awk '{print $1}')
ip="${ip:-127.0.0.1}"
# shellcheck source=/dev/null
source "${INSTALL_DIR}/.env"
echo ""
echo "=========================================="
echo " 中学成绩档案系统 部署完成"
echo " 版权所有 (c) 马建军"
echo "=========================================="
echo ""
echo " 访问地址: http://${ip}:${WEB_PORT}"
echo " 本地访问: http://127.0.0.1:${WEB_PORT}"
echo " 安装目录: ${INSTALL_DIR}"
echo ""
echo " 常用命令:"
echo " 查看状态: cd ${INSTALL_DIR} && docker compose ps"
echo " 查看日志: cd ${INSTALL_DIR} && docker compose logs -f"
echo " 停止服务: cd ${INSTALL_DIR} && docker compose down"
echo " 更新版本: bash ${INSTALL_DIR}/deploy/update.sh"
echo " 数据备份: bash ${INSTALL_DIR}/deploy/backup.sh"
echo ""
echo " 反向代理(Nginx/Caddy 等)请自行配置,本项目不包含。"
echo " 详见文档: ${INSTALL_DIR}/docs/DEPLOY.md"
echo ""
echo " 技术支持: 微信 dekun03 手机 18364911125"
echo "=========================================="
}
main() {
echo ""
log_info "中学成绩档案系统 — 一键部署开始"
require_root
check_os
check_resources
check_port
install_packages
install_docker
clone_or_update_repo
generate_env
start_services
wait_healthy
print_summary
}
main "$@"
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
#
# 停止并移除容器(默认保留数据卷与 uploads)
# 版权所有 (c) 马建军
#
set -euo pipefail
INSTALL_DIR="${INSTALL_DIR:-/opt/secondary-school-grade-archive}"
REMOVE_VOLUMES="${REMOVE_VOLUMES:-0}"
cd "${INSTALL_DIR}" || exit 1
echo "将停止 Docker 服务…"
if [[ "${REMOVE_VOLUMES}" == "1" ]]; then
echo "警告: 将删除数据库卷(所有成绩数据会丢失)"
read -r -p "确认删除数据卷? 输入 yes: " ans
if [[ "${ans}" == "yes" ]]; then
docker compose --env-file .env down -v
else
echo "已取消"
exit 1
fi
else
docker compose --env-file .env down
echo "数据卷与 ${INSTALL_DIR}/uploads 已保留"
fi
echo "卸载完成。源码目录 ${INSTALL_DIR} 未自动删除,如需删除请手动 rm -rf"
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
#
# 更新已部署实例:拉取代码并重建容器
# 版权所有 (c) 马建军
#
set -euo pipefail
INSTALL_DIR="${INSTALL_DIR:-/opt/secondary-school-grade-archive}"
BRANCH="${BRANCH:-main}"
GREEN='\033[0;32m'
NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $*"; }
if [[ ! -d "${INSTALL_DIR}/.git" ]]; then
echo "未找到部署目录 ${INSTALL_DIR},请先运行 deploy/install.sh"
exit 1
fi
cd "${INSTALL_DIR}"
log_info "拉取最新代码…"
git fetch origin
git checkout "${BRANCH}" 2>/dev/null || true
git pull origin "${BRANCH}"
log_info "重建并重启服务…"
docker compose --env-file .env up -d --build
log_info "更新完成。访问端口见 .env 中 WEB_PORT"
+55
View File
@@ -0,0 +1,55 @@
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-student_archive}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"]
interval: 5s
timeout: 5s
retries: 5
networks:
- appnet
api:
build: ./backend
restart: unless-stopped
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-student_archive}
SECRET_KEY: ${SECRET_KEY:-dev-secret-key-change-in-production}
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:23566}
UPLOAD_DIR: /app/uploads
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://host.docker.internal:11434}
OLLAMA_MODEL: ${OLLAMA_MODEL:-qwen2.5:7b}
FLUCTUATION_THRESHOLD: ${FLUCTUATION_THRESHOLD:-0.08}
volumes:
- ./uploads:/app/uploads
depends_on:
db:
condition: service_healthy
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- appnet
web:
build: ./frontend
restart: unless-stopped
ports:
- "${WEB_PORT:-23566}:80"
depends_on:
- api
networks:
- appnet
volumes:
pgdata:
networks:
appnet:
driver: bridge
+304
View File
@@ -0,0 +1,304 @@
# Ubuntu 部署文档
> **中学成绩档案系统**Secondary School Grade Archive
> 版权所有 © 马建军 · 微信 **dekun03** · 手机 **18364911125**
> 代码仓库:[https://git.bz121.com/dekun/secondary-school-grade-archive.git](https://git.bz121.com/dekun/secondary-school-grade-archive.git)
---
## 1. 部署概述
| 项目 | 说明 |
|------|------|
| 目标系统 | Ubuntu 20.04 / 22.04 / 24.04(推荐 22.04+ |
| 运行用户 | **root**(一键脚本要求) |
| 安装目录 | **`/opt/secondary-school-grade-archive`** |
| 部署方式 | Docker Compose |
| 对外端口 | **`23566`**HTTP,可在 `.env` 修改 `WEB_PORT` |
| 反向代理 | **不包含在本项目中**,需用户自行配置 Nginx/Caddy 等 |
### 1.1 架构说明
```
浏览器 ──► :23566 (Nginx/Web) ──► API (内部) ──► PostgreSQL (内部)
└──► Ollama (宿主机,可选)
└──► uploads/ (宿主机挂载)
```
- **Web**Nginx 提供前端静态文件,并将 `/api` 反向代理到后端容器
- **API**:FastAPI,不直接暴露端口到宿主机
- **PostgreSQL**:仅 Docker 内网访问,不映射宿主机端口
- **Ollama**:可选,运行在宿主机,容器通过 `host.docker.internal` 访问
---
## 2. 环境要求
### 2.1 硬件(建议)
| 资源 | 最低 | 推荐 |
|------|------|------|
| CPU | 2 核 | 4 核+ |
| 内存 | 2 GB | 4 GB+(启用 OCR 建议 8 GB |
| 磁盘 | 10 GB | 20 GB+ |
### 2.2 软件
- Ubuntu Server64 位)
- 可访问互联网(拉取 Docker 镜像与 Git 仓库)
- 防火墙放行 **23566/TCP**(若需外网访问)
### 2.3 端口
| 端口 | 用途 | 是否必须对外开放 |
|------|------|------------------|
| **23566** | Web 前端 + API(经 Nginx 统一入口) | 是 |
| 11434 | Ollama(宿主机,错题 AI 解法) | 仅本机,可不对外 |
| 5432 | PostgreSQL | **否**(已关闭对外映射) |
---
## 3. 一键部署(推荐)
**root** 登录 Ubuntu 服务器后执行:
```bash
# 方式 A:克隆后执行(仓库已有代码时)
git clone https://git.bz121.com/dekun/secondary-school-grade-archive.git /opt/secondary-school-grade-archive
cd /opt/secondary-school-grade-archive
chmod +x deploy/*.sh
bash deploy/install.sh
```
```bash
# 方式 B:仅下载安装脚本(仓库已推送 install.sh 后)
export WEB_PORT=23566
export INSTALL_DIR=/opt/secondary-school-grade-archive
curl -fsSL https://git.bz121.com/dekun/secondary-school-grade-archive/raw/branch/main/deploy/install.sh -o /tmp/install.sh
chmod +x /tmp/install.sh
bash /tmp/install.sh
```
### 3.1 脚本自动完成的事项
1. 检测是否为 root 用户
2. 检测 Ubuntu/Debian 系操作系统
3. 检测内存、磁盘(不足时警告)
4. 检测 **23566** 端口是否占用
5. 安装 `git``curl``openssl` 等基础工具
6. 若未安装 Docker,自动安装 **Docker CE + Compose 插件**
7. 克隆/更新代码到 `/opt/secondary-school-grade-archive`
8. 自动生成 `.env`(随机 `SECRET_KEY`、数据库密码)
9. `docker compose up -d --build` 构建并启动
10. 等待 API 健康检查通过后输出访问地址
### 3.2 部署成功后的访问
```
http://<服务器IP>:23566
```
首次使用请在页面 **注册** 账号,然后登录添加学生。
---
## 4. 环境变量(`.env`
部署后配置文件位于:
```
/opt/secondary-school-grade-archive/.env
```
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `WEB_PORT` | `23566` | Web 对外端口 |
| `SECRET_KEY` | 自动生成 | JWT 密钥,生产环境请勿泄露 |
| `POSTGRES_PASSWORD` | 自动生成 | 数据库密码 |
| `CORS_ORIGINS` | 含服务器 IP | 前端跨域白名单 |
| `OLLAMA_BASE_URL` | `http://host.docker.internal:11434` | Ollama 地址 |
| `OLLAMA_MODEL` | `qwen2.5:7b` | 模型名称 |
| `FLUCTUATION_THRESHOLD` | `0.08` | 成绩波动高亮阈值(8% |
修改 `.env` 后重启:
```bash
cd /opt/secondary-school-grade-archive
docker compose --env-file .env up -d
```
修改 `WEB_PORT` 后需重新创建容器:
```bash
docker compose --env-file .env up -d --force-recreate web
```
---
## 5. 常用运维命令
```bash
cd /opt/secondary-school-grade-archive
# 查看运行状态
docker compose ps
# 查看日志
docker compose logs -f
docker compose logs -f api
docker compose logs -f web
# 停止服务
docker compose down
# 启动服务
docker compose --env-file .env up -d
# 更新版本(拉代码 + 重建)
bash deploy/update.sh
# 数据备份
bash deploy/backup.sh
# 卸载容器(保留数据)
bash deploy/uninstall.sh
# 卸载并删除数据库卷(危险)
REMOVE_VOLUMES=1 bash deploy/uninstall.sh
```
---
## 6. Ollama 可选配置(错题 AI 解法)
错题上传后的「整理题目 / 生成解法」依赖宿主机 Ollama。若不安装,OCR 仍可用,AI 解法需手动填写。
```bash
# 安装 Ollama(参考 https://ollama.com
curl -fsSL https://ollama.com/install.sh | sh
# 拉取模型
ollama pull qwen2.5:7b
# 确认服务运行
curl http://127.0.0.1:11434/api/tags
```
确保 `.env` 中:
```
OLLAMA_BASE_URL=http://host.docker.internal:11434
OLLAMA_MODEL=qwen2.5:7b
```
---
## 7. 反向代理(用户自行配置)
**本项目不包含 HTTPS、域名、反向代理配置。** 若需通过域名访问或启用 HTTPS,请在宿主机自行配置 Nginx/Caddy/Traefik 等。
### 7.1 原则
- 反向代理将流量转发到 **`http://127.0.0.1:23566`**
- 代理需支持 **WebSocket**(若未来扩展)及 **大文件上传**(错题图片最大 10MB
- 代理 `/api``/` 均应转发到同一 upstream(本项目 Nginx 已统一处理)
### 7.2 Nginx 示例(仅供参考,不包含在项目中)
```nginx
server {
listen 443 ssl http2;
server_name grade.example.com;
ssl_certificate /path/to/fullchain.pem;
ssl_certificate_key /path/to/privkey.pem;
client_max_body_size 10M;
location / {
proxy_pass http://127.0.0.1:23566;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
使用 HTTPS 反向代理后,请同步修改 `.env` 中的 `CORS_ORIGINS`,加入 `https://grade.example.com`
---
## 8. 防火墙示例(UFW
```bash
ufw allow 22/tcp
ufw allow 23566/tcp
ufw enable
```
---
## 9. 故障排查
| 现象 | 处理 |
|------|------|
| 无法访问 23566 | `docker compose ps` 确认 web 运行;`ufw status` 检查防火墙 |
| 注册/登录 502 | `docker compose logs api` 查看后端;确认 db 健康 |
| OCR 很慢/失败 | 首次运行需下载 PaddleOCR 模型;查看 api 日志 |
| AI 解法失败 | 确认宿主机 Ollama 运行;`curl host.docker.internal:11434` 从 api 容器内测试 |
| 端口被占用 | 修改 `.env``WEB_PORT` 或释放占用进程 |
进入 API 容器调试:
```bash
docker compose exec api bash
```
---
## 10. 数据备份与恢复
### 备份
```bash
bash /opt/secondary-school-grade-archive/deploy/backup.sh
```
生成文件位于 `backups/`
- `db_YYYYMMDD_HHMMSS.sql` — 数据库
- `uploads_YYYYMMDD_HHMMSS.tar.gz` — 错题图片
### 恢复数据库(示例)
```bash
cd /opt/secondary-school-grade-archive
docker compose exec -T db psql -U postgres student_archive < backups/db_XXXXXX.sql
```
---
## 11. 版权与授权
本软件著作权归 **马建军** 所有。部署和使用须遵守 [LICENSE](../LICENSE) 与 [COPYRIGHT.md](../COPYRIGHT.md)。
- 微信:**dekun03**
- 手机:**18364911125**
未经授权不得用于商业分发或去除版权信息。
---
## 12. 自定义安装参数
```bash
# 自定义端口
WEB_PORT=23566 bash deploy/install.sh
# 自定义目录
INSTALL_DIR=/opt/my-grade-app bash deploy/install.sh
# 指定分支
BRANCH=main bash deploy/install.sh
```
+162
View File
@@ -0,0 +1,162 @@
# 使用说明
> **中学成绩档案系统**(初中 / 高中)
> 版权所有 © 马建军 · 微信 **dekun03** · 手机 **18364911125**
---
## 1. 系统简介
本系统面向**初中、高中**学生,提供:
- 多用户账号,数据互相隔离
- 学生档案管理(学段、年级、班级)
- 成绩录入:周考、月考、期末
- 分科成绩占比曲线(上升绿色、下降红色、大幅波动高亮)
- 错题库:拍照上传 → OCR 识别 → AI 生成解法(可编辑)
- 成绩 CSV 导出
部署方式见 [DEPLOY.md](./DEPLOY.md)。
---
## 2. 快速上手
### 2.1 登录与注册
1. 浏览器打开 `http://<服务器IP>:23566`
2. 首次使用点击 **注册**,设置用户名(≥3 字符)和密码(≥6 字符)
3. 注册成功后自动登录
> 系统无默认管理员账号,首个注册用户即为普通用户,数据仅本人可见。
### 2.2 添加学生
1. 首页点击 **添加学生**
2. 填写:
- **姓名**(必填)
- **学段**:初中 / 高中
- **年级**:初一~初三 或 高一~高三
- **班级**:如「3班」(可选)
3. 保存后在卡片上可看到学段标签
### 2.3 录入成绩
进入学生详情 → **成绩录入** 标签:
1. 点击 **录入成绩**
2. 选择 **考试类型**:周考 / 月考 / 期末
3. 选择 **考试日期**
4. 在表格中填写各科 **总分**、**得分**(未考科目可留空)
5. 系统自动计算 **占比**
6. 保存后在列表中可编辑或删除
**校验规则:**
- 总分必须 > 0
- 得分不能大于总分
- 至少录入一科成绩
---
## 3. 成绩分析
### 3.1 成绩总览
**成绩总览** 标签以表格展示历次考试各科得分与占比。
- 顶部 **波动预警** 会标记相邻两次占比变化 ≥ 8% 的考试
- 可横向滚动查看所有科目
### 3.2 分科曲线
**分科曲线** 标签:
1. 选择科目
2. 查看占比(%)随时间变化折线图
**图例说明:**
| 元素 | 含义 |
|------|------|
| 绿色线段 | 较上一次占比 **上升** |
| 红色线段 | 较上一次占比 **下降** |
| 橙色大圆点 | **大幅波动**(变化 ≥ 8% |
| Tooltip | 显示日期、考试类型、占比、较上次变化 |
---
## 4. 错题库
### 4.1 上传错题
进入 **错题库** 标签:
1. 选择 **科目**
2. 点击 **上传错题图片**(支持 jpg/png/webp,最大 10MB
3. 上传后后台自动:**OCR 识别 → AI 整理题目 → 生成解法**
4. 处理状态:处理中 → 已识别 → 已生成解法
> AI 解法依赖服务器上的 **Ollama**。未配置时仍可 OCR,解法需手动填写。
### 4.2 查看与编辑
点击错题卡片打开详情:
- **左侧**:原始试卷图片、OCR 原文
- **右侧**:识别题目、解法(均可编辑)
- **保存编辑**:手动修正后点击保存
- **重新 OCR**:识别不准时可重试
- **重新生成解法**:基于当前题目重新调用 AI
> 解法标注「AI 生成,请核对」,使用前请人工确认。
### 4.3 筛选与搜索
- 按 **科目** 筛选
- **搜索** 题目/解法关键词
---
## 5. 数据导出
学生详情页点击 **导出 CSV**,下载该生全部成绩记录,可用 Excel 打开。
---
## 6. 预置科目
语文、数学、英语、物理、化学、生物、历史、地理、政治。
---
## 7. 常见问题
**Q:忘记密码怎么办?**
A:当前版本无找回密码功能,需管理员在数据库中重置或重新注册(生产环境建议后续增加找回流程)。
**Q:多人能否共用一台服务器?**
A:可以。每人注册独立账号,数据互不可见。
**Q:能否同时管理初中和高中孩子?**
A:可以。添加学生时分别选择学段即可。
**Q:换手机/电脑能否访问?**
A:可以。使用同一服务器地址与账号登录即可。
**QHTTPS 和域名怎么配置?**
A:本项目不包含反向代理配置,请参考 [DEPLOY.md 第 7 节](./DEPLOY.md#7-反向代理用户自行配置) 自行设置。
---
## 8. 技术支持与版权
| 项目 | 内容 |
|------|------|
| 作者 | 马建军 |
| 微信 | dekun03 |
| 手机 | 18364911125 |
| 仓库 | [secondary-school-grade-archive](https://git.bz121.com/dekun/secondary-school-grade-archive.git) |
使用本软件须遵守 [LICENSE](../LICENSE) 与 [COPYRIGHT.md](../COPYRIGHT.md)。
商业使用或二次分发请联系作者取得授权。
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+11
View File
@@ -0,0 +1,11 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+32
View File
@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="author" content="马建军" />
<meta name="copyright" content="Copyright (c) 马建军. All rights reserved." />
<title>中学成绩档案</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://api:8000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
client_max_body_size 10M;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+3995
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^6.3.2",
"antd": "^6.5.0",
"axios": "^1.18.1",
"dayjs": "^1.11.21",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.18.0",
"tslib": "^2.8.1"
},
"devDependencies": {
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"oxlint": "^1.69.0",
"typescript": "~6.0.2",
"vite": "^8.1.0"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+184
View File
@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+37
View File
@@ -0,0 +1,37 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import { useAuth } from './context/AuthContext'
import LoginPage from './pages/LoginPage'
import StudentDetailPage from './pages/StudentDetailPage'
import StudentsPage from './pages/StudentsPage'
function PrivateRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth()
if (loading) return null
if (!user) return <Navigate to="/login" replace />
return <>{children}</>
}
export default function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
element={
<PrivateRoute>
<StudentsPage />
</PrivateRoute>
}
/>
<Route
path="/students/:id"
element={
<PrivateRoute>
<StudentDetailPage />
</PrivateRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}
+117
View File
@@ -0,0 +1,117 @@
import axios from 'axios'
import type {
Exam,
ScoreInput,
SchoolLevel,
Student,
Subject,
TokenResponse,
TrendResponse,
User,
WrongQuestion,
} from '../types'
import type { ExamType } from '../types'
const api = axios.create({
baseURL: '/api',
})
api.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
api.interceptors.response.use(
(res) => res,
async (error) => {
const original = error.config
if (error.response?.status === 401 && !original._retry) {
original._retry = true
const refresh = localStorage.getItem('refresh_token')
if (refresh) {
try {
const { data } = await axios.post<TokenResponse>('/api/auth/refresh', {
refresh_token: refresh,
})
localStorage.setItem('access_token', data.access_token)
localStorage.setItem('refresh_token', data.refresh_token)
original.headers.Authorization = `Bearer ${data.access_token}`
return api(original)
} catch {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
window.location.href = '/login'
}
}
}
return Promise.reject(error)
},
)
export const authApi = {
register: (username: string, password: string) =>
api.post<User>('/auth/register', { username, password }),
login: (username: string, password: string) =>
api.post<TokenResponse>('/auth/login', { username, password }),
me: () => api.get<User>('/auth/me'),
}
export const studentApi = {
list: () => api.get<Student[]>('/students'),
create: (data: {
name: string
school_level?: SchoolLevel
grade?: string
class_name?: string
}) => api.post<Student>('/students', data),
get: (id: string) => api.get<Student>(`/students/${id}`),
update: (id: string, data: Partial<Student>) => api.patch<Student>(`/students/${id}`, data),
remove: (id: string) => api.delete(`/students/${id}`),
}
export const subjectApi = {
list: () => api.get<Subject[]>('/subjects'),
}
export const examApi = {
list: (studentId: string) => api.get<Exam[]>(`/students/${studentId}/exams`),
create: (
studentId: string,
data: { exam_type: ExamType; exam_date: string; title?: string; scores: ScoreInput[] },
) => api.post<Exam>(`/students/${studentId}/exams`, data),
update: (
examId: string,
data: Partial<{ exam_type: ExamType; exam_date: string; title: string; scores: ScoreInput[] }>,
) => api.patch<Exam>(`/exams/${examId}`, data),
remove: (examId: string) => api.delete(`/exams/${examId}`),
trend: (studentId: string, subjectId: number) =>
api.get<TrendResponse>(`/students/${studentId}/scores/trend`, {
params: { subject_id: subjectId },
}),
exportCsv: (studentId: string) =>
api.get(`/students/${studentId}/scores/export`, { responseType: 'blob' }),
}
export const wrongQuestionApi = {
list: (studentId: string, params?: { subject_id?: number; q?: string }) =>
api.get<WrongQuestion[]>(`/students/${studentId}/wrong-questions`, { params }),
upload: (studentId: string, subjectId: number, file: File) => {
const form = new FormData()
form.append('subject_id', String(subjectId))
form.append('file', file)
return api.post<WrongQuestion>(`/students/${studentId}/wrong-questions`, form)
},
get: (id: string) => api.get<WrongQuestion>(`/wrong-questions/${id}`),
update: (id: string, data: Partial<WrongQuestion>) =>
api.patch<WrongQuestion>(`/wrong-questions/${id}`, data),
remove: (id: string) => api.delete(`/wrong-questions/${id}`),
retryOcr: (id: string) => api.post<WrongQuestion>(`/wrong-questions/${id}/retry-ocr`),
regenerate: (id: string) =>
api.post<WrongQuestion>(`/wrong-questions/${id}/regenerate-solution`),
imageUrl: (id: string) => `/api/wrong-questions/${id}/image`,
}
export default api
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+241
View File
@@ -0,0 +1,241 @@
import { DeleteOutlined, EditOutlined } from '@ant-design/icons'
import { Button, DatePicker, Form, Input, InputNumber, Modal, Select, Space, Table, message } from 'antd'
import dayjs from 'dayjs'
import { useEffect, useState } from 'react'
import { examApi } from '../api/client'
import type { Exam, ExamType, ScoreInput, Subject } from '../types'
import { EXAM_TYPE_LABELS } from '../types'
interface Props {
studentId: string
subjects: Subject[]
exams: Exam[]
onRefresh: () => void
}
export default function ScoreForm({ studentId, subjects, exams, onRefresh }: Props) {
const [modalOpen, setModalOpen] = useState(false)
const [editing, setEditing] = useState<Exam | null>(null)
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
useEffect(() => {
if (modalOpen && editing) {
form.setFieldsValue({
exam_type: editing.exam_type,
exam_date: dayjs(editing.exam_date),
title: editing.title,
scores: subjects.map((s) => {
const found = editing.scores.find((sc) => sc.subject_id === s.id)
return found
? { subject_id: s.id, total_score: found.total_score, obtained_score: found.obtained_score }
: { subject_id: s.id, total_score: undefined, obtained_score: undefined }
}),
})
} else if (modalOpen) {
form.setFieldsValue({
exam_type: 'weekly',
exam_date: dayjs(),
scores: subjects.map((s) => ({ subject_id: s.id })),
})
}
}, [modalOpen, editing, subjects, form])
const openCreate = () => {
setEditing(null)
setModalOpen(true)
}
const openEdit = (exam: Exam) => {
setEditing(exam)
setModalOpen(true)
}
const handleSubmit = async () => {
try {
const values = await form.validateFields()
const scores: ScoreInput[] = (values.scores || [])
.map((s: ScoreInput, idx: number) => ({
subject_id: subjects[idx]?.id ?? s.subject_id,
total_score: s.total_score,
obtained_score: s.obtained_score,
}))
.filter(
(s: ScoreInput) =>
s.subject_id != null &&
s.total_score != null &&
s.obtained_score != null &&
s.total_score > 0,
)
.map((s: ScoreInput) => ({
subject_id: s.subject_id,
total_score: Number(s.total_score),
obtained_score: Number(s.obtained_score),
}))
if (scores.length === 0) {
message.warning('请至少录入一科成绩')
return
}
setLoading(true)
const payload = {
exam_type: values.exam_type as ExamType,
exam_date: values.exam_date.format('YYYY-MM-DD'),
title: values.title || undefined,
scores,
}
if (editing) {
await examApi.update(editing.id, payload)
message.success('已更新')
} else {
await examApi.create(studentId, payload)
message.success('已添加')
}
setModalOpen(false)
onRefresh()
} catch {
/* validation */
} finally {
setLoading(false)
}
}
const handleDelete = async (exam: Exam) => {
Modal.confirm({
title: '确认删除该考试记录?',
onOk: async () => {
await examApi.remove(exam.id)
message.success('已删除')
onRefresh()
},
})
}
const columns = [
{ title: '日期', dataIndex: 'exam_date', key: 'exam_date', width: 110 },
{
title: '类型',
dataIndex: 'exam_type',
key: 'exam_type',
width: 80,
render: (t: ExamType) => EXAM_TYPE_LABELS[t],
},
{ title: '标题', dataIndex: 'title', key: 'title', ellipsis: true },
{
title: '科目数',
key: 'count',
width: 80,
render: (_: unknown, r: Exam) => r.scores.length,
},
{
title: '平均占比',
key: 'avg',
width: 100,
render: (_: unknown, r: Exam) => {
if (!r.scores.length) return '-'
const avg = r.scores.reduce((a, s) => a + s.ratio, 0) / r.scores.length
return `${(avg * 100).toFixed(1)}%`
},
},
{
title: '操作',
key: 'action',
width: 120,
render: (_: unknown, r: Exam) => (
<Space>
<Button type="link" icon={<EditOutlined />} onClick={() => openEdit(r)} />
<Button type="link" danger icon={<DeleteOutlined />} onClick={() => handleDelete(r)} />
</Space>
),
},
]
return (
<div>
<Button type="primary" onClick={openCreate} style={{ marginBottom: 16 }}>
</Button>
<Table rowKey="id" columns={columns} dataSource={exams} pagination={{ pageSize: 10 }} scroll={{ x: 600 }} />
<Modal
title={editing ? '编辑考试' : '录入成绩'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={handleSubmit}
confirmLoading={loading}
width={720}
destroyOnHidden
>
<Form form={form} layout="vertical">
<Space style={{ width: '100%' }} size="large">
<Form.Item name="exam_type" label="考试类型" rules={[{ required: true }]}>
<Select
style={{ width: 120 }}
options={Object.entries(EXAM_TYPE_LABELS).map(([k, v]) => ({ value: k, label: v }))}
/>
</Form.Item>
<Form.Item name="exam_date" label="考试日期" rules={[{ required: true }]}>
<DatePicker />
</Form.Item>
<Form.Item name="title" label="备注标题">
<Input placeholder="可选" style={{ width: 200 }} />
</Form.Item>
</Space>
<Form.List name="scores">
{(fields) => (
<Table
size="small"
pagination={false}
dataSource={fields.map((f, i) => ({ ...f, subject: subjects[i] }))}
rowKey="key"
columns={[
{
title: '科目',
render: (_, row) => (
<>
<Form.Item name={[row.name, 'subject_id']} hidden initialValue={row.subject?.id}>
<InputNumber />
</Form.Item>
{row.subject?.name}
</>
),
},
{
title: '总分',
render: (_, row) => (
<Form.Item name={[row.name, 'total_score']} noStyle>
<InputNumber min={0} placeholder="总分" style={{ width: 100 }} />
</Form.Item>
),
},
{
title: '得分',
render: (_, row) => (
<Form.Item name={[row.name, 'obtained_score']} noStyle>
<InputNumber min={0} placeholder="得分" style={{ width: 100 }} />
</Form.Item>
),
},
{
title: '占比',
render: (_, row) => {
const total = form.getFieldValue(['scores', row.name, 'total_score'])
const obtained = form.getFieldValue(['scores', row.name, 'obtained_score'])
if (total > 0 && obtained != null) {
return `${((obtained / total) * 100).toFixed(1)}%`
}
return '-'
},
},
]}
/>
)}
</Form.List>
</Form>
</Modal>
</div>
)
}
+76
View File
@@ -0,0 +1,76 @@
import { Table, Tag } from 'antd'
import type { Exam } from '../types'
import { EXAM_TYPE_LABELS } from '../types'
interface Props {
exams: Exam[]
subjectNames: Record<number, string>
}
export default function ScoreOverview({ exams, subjectNames }: Props) {
const subjectIds = new Set<number>()
exams.forEach((e) => e.scores.forEach((s) => subjectIds.add(s.subject_id)))
const sortedSubjects = [...subjectIds].sort((a, b) => a - b)
const columns = [
{ title: '日期', dataIndex: 'exam_date', key: 'date', width: 110, fixed: 'left' as const },
{
title: '类型',
dataIndex: 'exam_type',
key: 'type',
width: 80,
render: (t: keyof typeof EXAM_TYPE_LABELS) => (
<Tag>{EXAM_TYPE_LABELS[t]}</Tag>
),
},
...sortedSubjects.map((sid) => ({
title: subjectNames[sid] || `科目${sid}`,
key: `s${sid}`,
width: 100,
render: (_: unknown, exam: Exam) => {
const score = exam.scores.find((s) => s.subject_id === sid)
if (!score) return '-'
return `${score.obtained_score}/${score.total_score} (${(score.ratio * 100).toFixed(1)}%)`
},
})),
]
const volatileExams = exams.filter((exam) => {
return exam.scores.some((s) => {
const allScores = exams
.filter((e) => e.exam_date <= exam.exam_date)
.flatMap((e) => e.scores.filter((sc) => sc.subject_id === s.subject_id))
.sort((a, b) => {
const ea = exams.find((e) => e.scores.includes(a))
const eb = exams.find((e) => e.scores.includes(b))
return (ea?.exam_date || '').localeCompare(eb?.exam_date || '')
})
const idx = allScores.findIndex((sc) => sc.id === s.id)
if (idx <= 0) return false
return Math.abs(allScores[idx].ratio - allScores[idx - 1].ratio) >= 0.08
})
})
return (
<div>
{volatileExams.length > 0 && (
<div style={{ marginBottom: 16, padding: 12, background: '#fff7e6', borderRadius: 8 }}>
<strong></strong>
{volatileExams.slice(0, 5).map((e) => (
<Tag key={e.id} color="orange" style={{ marginTop: 4 }}>
{e.exam_date} {EXAM_TYPE_LABELS[e.exam_type]}
</Tag>
))}
</div>
)}
<Table
rowKey="id"
columns={columns}
dataSource={[...exams].sort((a, b) => b.exam_date.localeCompare(a.exam_date))}
pagination={{ pageSize: 15 }}
scroll={{ x: 'max-content' }}
size="small"
/>
</div>
)
}
+132
View File
@@ -0,0 +1,132 @@
import ReactECharts from 'echarts-for-react'
import type { TrendPoint } from '../types'
import { EXAM_TYPE_LABELS } from '../types'
interface Props {
points: TrendPoint[]
subjectName: string
threshold: number
}
const COLORS = {
up: '#52c41a',
down: '#ff4d4f',
flat: '#8c8c8c',
volatile: '#fa8c16',
}
export default function TrendChart({ points, subjectName, threshold }: Props) {
if (points.length === 0) {
return <div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
}
const dates = points.map((p) => p.exam_date)
const values = points.map((p) => p.ratio_percent)
const lineSeries = points.slice(1).map((point, i) => {
let color = COLORS.flat
if (point.direction === 'up') color = COLORS.up
if (point.direction === 'down') color = COLORS.down
return {
type: 'line' as const,
data: dates.map((_, idx) => (idx === i || idx === i + 1 ? values[idx] : null)),
connectNulls: false,
showSymbol: false,
lineStyle: { width: 3, color },
tooltip: { show: false },
silent: true,
}
})
const markPoints = points
.map((point, i) => ({ point, i }))
.filter(({ point }) => point.is_volatile)
.map(({ i }) => ({
coord: [dates[i], values[i]],
symbol: 'circle',
symbolSize: 18,
itemStyle: {
color: COLORS.volatile,
borderColor: '#fff',
borderWidth: 2,
},
label: { show: false },
}))
const option = {
title: {
text: `${subjectName} 成绩占比趋势`,
left: 'center',
textStyle: { fontSize: 16 },
},
tooltip: {
trigger: 'axis',
formatter: (params: Array<{ dataIndex: number; value: number }>) => {
const idx = params[0]?.dataIndex ?? 0
const p = points[idx]
if (!p) return ''
const typeLabel = EXAM_TYPE_LABELS[p.exam_type]
let html = `<strong>${p.exam_date}</strong> (${typeLabel})<br/>占比: ${p.ratio_percent}%`
if (p.title) html += `<br/>${p.title}`
if (p.delta_percent !== null) {
const sign = p.delta_percent > 0 ? '+' : ''
html += `<br/>较上次: ${sign}${p.delta_percent}%`
if (p.is_volatile) html += ' <span style="color:#fa8c16">[大幅波动]</span>'
}
return html
},
},
grid: { left: 50, right: 30, top: 60, bottom: 50 },
xAxis: {
type: 'category',
data: dates,
axisLabel: { rotate: 30 },
},
yAxis: {
type: 'value',
name: '占比 (%)',
min: 0,
max: 100,
},
series: [
{
type: 'line',
data: values,
symbol: 'circle',
symbolSize: (_val: number, params: { dataIndex: number }) =>
points[params.dataIndex]?.is_volatile ? 14 : 8,
itemStyle: {
color: (params: { dataIndex: number }) => {
const p = points[params.dataIndex]
if (p?.is_volatile) return COLORS.volatile
if (p?.direction === 'up') return COLORS.up
if (p?.direction === 'down') return COLORS.down
return '#1677ff'
},
},
lineStyle: { opacity: 0 },
markPoint: markPoints.length ? { data: markPoints } : undefined,
z: 10,
},
...lineSeries,
],
legend: {
bottom: 0,
data: [
{ name: '上升', itemStyle: { color: COLORS.up } },
{ name: '下降', itemStyle: { color: COLORS.down } },
{ name: '大幅波动', itemStyle: { color: COLORS.volatile } },
],
},
}
return (
<div>
<ReactECharts option={option} style={{ height: 400, width: '100%' }} notMerge />
<p style={{ color: '#888', fontSize: 12, marginTop: 8 }}>
: {(threshold * 100).toFixed(0)}%
</p>
</div>
)
}
@@ -0,0 +1,93 @@
import { ReloadOutlined, UploadOutlined } from '@ant-design/icons'
import { Button, Input, Select, Space, Upload, message } from 'antd'
import { useState } from 'react'
import { wrongQuestionApi } from '../api/client'
import type { Subject } from '../types'
interface Props {
studentId: string
subjects: Subject[]
onUploaded: () => void
}
export default function WrongQuestionUpload({ studentId, subjects, onUploaded }: Props) {
const [subjectId, setSubjectId] = useState<number | undefined>(subjects[0]?.id)
const [uploading, setUploading] = useState(false)
const handleUpload = async (file: File) => {
if (!subjectId) {
message.warning('请选择科目')
return false
}
setUploading(true)
try {
await wrongQuestionApi.upload(studentId, subjectId, file)
message.success('上传成功,正在 OCR 识别并生成解法…')
onUploaded()
} catch {
message.error('上传失败')
} finally {
setUploading(false)
}
return false
}
return (
<Space wrap style={{ marginBottom: 16 }}>
<Select
style={{ width: 120 }}
placeholder="选择科目"
value={subjectId}
onChange={setSubjectId}
options={subjects.map((s) => ({ value: s.id, label: s.name }))}
/>
<Upload beforeUpload={handleUpload} showUploadList={false} accept="image/*">
<Button icon={<UploadOutlined />} loading={uploading} type="primary">
</Button>
</Upload>
</Space>
)
}
interface SearchProps {
subjectId?: number
onSubjectChange: (id?: number) => void
search: string
onSearchChange: (q: string) => void
onRefresh: () => void
subjects: Subject[]
}
export function WrongQuestionFilters({
subjectId,
onSubjectChange,
search,
onSearchChange,
onRefresh,
subjects,
}: SearchProps) {
return (
<Space wrap style={{ marginBottom: 16 }}>
<Select
allowClear
style={{ width: 140 }}
placeholder="全部科目"
value={subjectId}
onChange={onSubjectChange}
options={subjects.map((s) => ({ value: s.id, label: s.name }))}
/>
<Input.Search
placeholder="搜索题目/解法"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
onSearch={onRefresh}
style={{ width: 220 }}
allowClear
/>
<Button icon={<ReloadOutlined />} onClick={onRefresh}>
</Button>
</Space>
)
}
+26
View File
@@ -0,0 +1,26 @@
import type { SchoolLevel } from '../types'
export type { SchoolLevel }
export const SCHOOL_LEVEL_LABELS: Record<SchoolLevel, string> = {
junior_high: '初中',
senior_high: '高中',
}
export const GRADE_OPTIONS: Record<SchoolLevel, string[]> = {
junior_high: ['初一', '初二', '初三'],
senior_high: ['高一', '高二', '高三'],
}
export function formatStudentMeta(student: {
school_level: SchoolLevel
grade?: string | null
class_name?: string | null
}): string {
const parts = [
SCHOOL_LEVEL_LABELS[student.school_level],
student.grade,
student.class_name,
].filter(Boolean)
return parts.length ? parts.join(' · ') : '未设置学段年级'
}
+65
View File
@@ -0,0 +1,65 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import { authApi } from '../api/client'
import type { User } from '../types'
interface AuthContextValue {
user: User | null
loading: boolean
login: (username: string, password: string) => Promise<void>
register: (username: string, password: string) => Promise<void>
logout: () => void
}
const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const token = localStorage.getItem('access_token')
if (!token) {
setLoading(false)
return
}
authApi
.me()
.then((res) => setUser(res.data))
.catch(() => {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
})
.finally(() => setLoading(false))
}, [])
const login = async (username: string, password: string) => {
const { data } = await authApi.login(username, password)
localStorage.setItem('access_token', data.access_token)
localStorage.setItem('refresh_token', data.refresh_token)
const me = await authApi.me()
setUser(me.data)
}
const register = async (username: string, password: string) => {
await authApi.register(username, password)
await login(username, password)
}
const logout = () => {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
setUser(null)
}
return (
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
return ctx
}
+25
View File
@@ -0,0 +1,25 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
sans-serif;
background: #f5f5f5;
-webkit-font-smoothing: antialiased;
}
#root {
min-height: 100vh;
}
a {
color: inherit;
}
@media (max-width: 576px) {
.ant-table {
font-size: 12px;
}
}
+20
View File
@@ -0,0 +1,20 @@
import { ConfigProvider } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import { AuthProvider } from './context/AuthContext'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ConfigProvider locale={zhCN}>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</ConfigProvider>
</StrictMode>,
)
+118
View File
@@ -0,0 +1,118 @@
import { LockOutlined, UserOutlined } from '@ant-design/icons'
import { Button, Card, Form, Input, Tabs, Typography, message } from 'antd'
import { Navigate, useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export default function LoginPage() {
const { user, login, register, loading } = useAuth()
const navigate = useNavigate()
const [loginForm] = Form.useForm()
const [registerForm] = Form.useForm()
if (!loading && user) return <Navigate to="/" replace />
const onLogin = async (values: { username: string; password: string }) => {
try {
await login(values.username, values.password)
message.success('登录成功')
navigate('/')
} catch {
message.error('用户名或密码错误')
}
}
const onRegister = async (values: { username: string; password: string; confirm: string }) => {
if (values.password !== values.confirm) {
message.error('两次密码不一致')
return
}
try {
await register(values.username, values.password)
message.success('注册成功')
navigate('/')
} catch {
message.error('注册失败,用户名可能已存在')
}
}
return (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: 16,
}}
>
<Card style={{ width: '100%', maxWidth: 420 }} title="中学生成绩档案">
<Typography.Paragraph type="secondary" style={{ textAlign: 'center' }}>
· ·
</Typography.Paragraph>
<Tabs
items={[
{
key: 'login',
label: '登录',
children: (
<Form form={loginForm} onFinish={onLogin} layout="vertical">
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
<Input prefix={<UserOutlined />} placeholder="用户名" />
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
</Form.Item>
<Button type="primary" htmlType="submit" block>
</Button>
</Form>
),
},
{
key: 'register',
label: '注册',
children: (
<Form form={registerForm} onFinish={onRegister} layout="vertical">
<Form.Item
name="username"
rules={[
{ required: true, message: '请输入用户名' },
{ min: 3, message: '至少3个字符' },
]}
>
<Input prefix={<UserOutlined />} placeholder="用户名" />
</Form.Item>
<Form.Item
name="password"
rules={[
{ required: true, message: '请输入密码' },
{ min: 6, message: '至少6个字符' },
]}
>
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
</Form.Item>
<Form.Item
name="confirm"
rules={[{ required: true, message: '请确认密码' }]}
>
<Input.Password prefix={<LockOutlined />} placeholder="确认密码" />
</Form.Item>
<Button type="primary" htmlType="submit" block>
</Button>
</Form>
),
},
]}
/>
<Typography.Paragraph
type="secondary"
style={{ textAlign: 'center', fontSize: 12, marginTop: 16, marginBottom: 0 }}
>
© · dekun03 · 18364911125
</Typography.Paragraph>
</Card>
</div>
)
}
+229
View File
@@ -0,0 +1,229 @@
import { ArrowLeftOutlined, DownloadOutlined } from '@ant-design/icons'
import { Button, Select, Space, Spin, Tabs, Tag, Typography, message } from 'antd'
import { useCallback, useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { examApi, studentApi, subjectApi, wrongQuestionApi } from '../api/client'
import ScoreForm from '../components/ScoreForm'
import ScoreOverview from '../components/ScoreOverview'
import TrendChart from '../components/TrendChart'
import WrongQuestionUpload, { WrongQuestionFilters } from '../components/WrongQuestionUpload'
import { formatStudentMeta, SCHOOL_LEVEL_LABELS } from '../constants/school'
import type { Exam, Student, Subject, TrendResponse, WrongQuestion } from '../types'
import { STATUS_LABELS } from '../types'
import WrongQuestionDetail from './WrongQuestionDetail'
export default function StudentDetailPage() {
const { id } = useParams<{ id: string }>()
const [student, setStudent] = useState<Student | null>(null)
const [subjects, setSubjects] = useState<Subject[]>([])
const [exams, setExams] = useState<Exam[]>([])
const [trend, setTrend] = useState<TrendResponse | null>(null)
const [selectedSubject, setSelectedSubject] = useState<number>()
const [wrongQuestions, setWrongQuestions] = useState<WrongQuestion[]>([])
const [wqSubjectFilter, setWqSubjectFilter] = useState<number>()
const [wqSearch, setWqSearch] = useState('')
const [selectedWq, setSelectedWq] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const subjectNames = Object.fromEntries(subjects.map((s) => [s.id, s.name]))
const loadExams = useCallback(async () => {
if (!id) return
const { data } = await examApi.list(id)
setExams(data)
}, [id])
const loadTrend = useCallback(async () => {
if (!id || !selectedSubject) return
const { data } = await examApi.trend(id, selectedSubject)
setTrend(data)
}, [id, selectedSubject])
const loadWrongQuestions = useCallback(async () => {
if (!id) return
const { data } = await wrongQuestionApi.list(id, {
subject_id: wqSubjectFilter,
q: wqSearch || undefined,
})
setWrongQuestions(data)
}, [id, wqSubjectFilter, wqSearch])
useEffect(() => {
if (!id) return
const init = async () => {
setLoading(true)
try {
const [studentRes, subjectRes] = await Promise.all([
studentApi.get(id),
subjectApi.list(),
])
setStudent(studentRes.data)
setSubjects(subjectRes.data)
if (subjectRes.data.length) setSelectedSubject(subjectRes.data[0].id)
await loadExams()
await loadWrongQuestions()
} finally {
setLoading(false)
}
}
init()
}, [id, loadExams, loadWrongQuestions])
useEffect(() => {
loadTrend()
}, [loadTrend])
const handleExport = async () => {
if (!id) return
try {
const { data } = await examApi.exportCsv(id)
const url = URL.createObjectURL(data)
const a = document.createElement('a')
a.href = url
a.download = `${student?.name || 'student'}_scores.csv`
a.click()
URL.revokeObjectURL(url)
} catch {
message.error('导出失败')
}
}
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
)
}
if (!student) return <Typography.Text></Typography.Text>
return (
<div style={{ maxWidth: 1100, margin: '0 auto', padding: '16px 16px 32px' }}>
<Space style={{ marginBottom: 16 }} wrap>
<Link to="/">
<Button icon={<ArrowLeftOutlined />}></Button>
</Link>
<Typography.Title level={4} style={{ margin: 0 }}>
{student.name}
</Typography.Title>
<Tag color={student.school_level === 'senior_high' ? 'purple' : 'blue'}>
{SCHOOL_LEVEL_LABELS[student.school_level]}
</Tag>
<Typography.Text type="secondary">{formatStudentMeta(student)}</Typography.Text>
<Button icon={<DownloadOutlined />} onClick={handleExport}>
CSV
</Button>
</Space>
<Tabs
items={[
{
key: 'scores',
label: '成绩录入',
children: (
<ScoreForm
studentId={id!}
subjects={subjects}
exams={exams}
onRefresh={loadExams}
/>
),
},
{
key: 'overview',
label: '成绩总览',
children: <ScoreOverview exams={exams} subjectNames={subjectNames} />,
},
{
key: 'trend',
label: '分科曲线',
children: (
<div>
<Select
style={{ width: 140, marginBottom: 16 }}
value={selectedSubject}
onChange={setSelectedSubject}
options={subjects.map((s) => ({ value: s.id, label: s.name }))}
/>
{trend && (
<TrendChart
points={trend.points}
subjectName={trend.subject_name}
threshold={trend.threshold}
/>
)}
</div>
),
},
{
key: 'wrong',
label: '错题库',
children: (
<div>
<WrongQuestionUpload
studentId={id!}
subjects={subjects}
onUploaded={loadWrongQuestions}
/>
<WrongQuestionFilters
subjectId={wqSubjectFilter}
onSubjectChange={setWqSubjectFilter}
search={wqSearch}
onSearchChange={setWqSearch}
onRefresh={loadWrongQuestions}
subjects={subjects}
/>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 16 }}>
{wrongQuestions.map((wq) => (
<div
key={wq.id}
onClick={() => setSelectedWq(wq.id)}
style={{
border: '1px solid #f0f0f0',
borderRadius: 8,
overflow: 'hidden',
cursor: 'pointer',
}}
>
<img
src={wrongQuestionApi.imageUrl(wq.id)}
alt="错题"
style={{ width: '100%', height: 140, objectFit: 'cover', background: '#fafafa' }}
/>
<div style={{ padding: 12 }}>
<Space>
<Typography.Text strong>{wq.subject_name}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{STATUS_LABELS[wq.status]}
</Typography.Text>
</Space>
<Typography.Paragraph
ellipsis={{ rows: 2 }}
style={{ margin: '8px 0 0', fontSize: 13 }}
>
{wq.question_text || wq.ocr_raw_text || '处理中…'}
</Typography.Paragraph>
</div>
</div>
))}
</div>
{wrongQuestions.length === 0 && (
<Typography.Text type="secondary"></Typography.Text>
)}
{selectedWq && (
<WrongQuestionDetail
questionId={selectedWq}
open={!!selectedWq}
onClose={() => setSelectedWq(null)}
onUpdated={loadWrongQuestions}
/>
)}
</div>
),
},
]}
/>
</div>
)
}
+146
View File
@@ -0,0 +1,146 @@
import { LogoutOutlined, PlusOutlined, UserOutlined } from '@ant-design/icons'
import { Button, Card, Col, Form, Input, Modal, Row, Select, Space, Spin, Tag, Typography, message } from 'antd'
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { studentApi } from '../api/client'
import { formatStudentMeta, GRADE_OPTIONS, SCHOOL_LEVEL_LABELS } from '../constants/school'
import { useAuth } from '../context/AuthContext'
import type { SchoolLevel, Student } from '../types'
export default function StudentsPage() {
const { user, logout } = useAuth()
const [students, setStudents] = useState<Student[]>([])
const [loading, setLoading] = useState(true)
const [modalOpen, setModalOpen] = useState(false)
const [form] = Form.useForm()
const schoolLevel = Form.useWatch('school_level', form) as SchoolLevel | undefined
const load = async () => {
setLoading(true)
try {
const { data } = await studentApi.list()
setStudents(data)
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
}, [])
const openCreate = () => {
form.setFieldsValue({ school_level: 'junior_high', grade: undefined })
setModalOpen(true)
}
const handleCreate = async () => {
const values = await form.validateFields()
await studentApi.create(values)
message.success('学生已添加')
setModalOpen(false)
form.resetFields()
load()
}
return (
<div style={{ maxWidth: 960, margin: '0 auto', padding: '16px 16px 32px' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 24,
flexWrap: 'wrap',
gap: 12,
}}
>
<div>
<Typography.Title level={3} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">{user?.username}</Typography.Text>
</div>
<Space wrap>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
<Button icon={<LogoutOutlined />} onClick={logout}>
退
</Button>
</Space>
</div>
<Spin spinning={loading}>
<Row gutter={[16, 16]}>
{students.map((s) => (
<Col xs={24} sm={12} md={8} key={s.id}>
<Link to={`/students/${s.id}`} style={{ textDecoration: 'none' }}>
<Card hoverable>
<Space align="start">
<UserOutlined style={{ fontSize: 24, color: '#1677ff' }} />
<div>
<Space size={4}>
<Typography.Text strong>{s.name}</Typography.Text>
<Tag color={s.school_level === 'senior_high' ? 'purple' : 'blue'}>
{SCHOOL_LEVEL_LABELS[s.school_level]}
</Tag>
</Space>
<br />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{formatStudentMeta(s)}
</Typography.Text>
</div>
</Space>
</Card>
</Link>
</Col>
))}
{!loading && students.length === 0 && (
<Col span={24}>
<Card>
<Typography.Text type="secondary"></Typography.Text>
</Card>
</Col>
)}
</Row>
</Spin>
<Modal
title="添加学生"
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={handleCreate}
destroyOnHidden
>
<Form form={form} layout="vertical" initialValues={{ school_level: 'junior_high' }}>
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="school_level" label="学段" rules={[{ required: true }]}>
<Select
options={Object.entries(SCHOOL_LEVEL_LABELS).map(([value, label]) => ({
value,
label,
}))}
onChange={() => form.setFieldValue('grade', undefined)}
/>
</Form.Item>
<Form.Item name="grade" label="年级">
<Select
allowClear
placeholder={schoolLevel === 'senior_high' ? '如:高一' : '如:初二'}
options={(GRADE_OPTIONS[schoolLevel || 'junior_high'] || []).map((g) => ({
value: g,
label: g,
}))}
/>
</Form.Item>
<Form.Item name="class_name" label="班级">
<Input placeholder="如:3班" />
</Form.Item>
</Form>
</Modal>
</div>
)
}
+162
View File
@@ -0,0 +1,162 @@
import { Alert, Button, Col, Input, Modal, Row, Space, Spin, Typography, message } from 'antd'
import { useEffect, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import { wrongQuestionApi } from '../api/client'
import type { WrongQuestion } from '../types'
import { STATUS_LABELS } from '../types'
interface Props {
questionId: string
open: boolean
onClose: () => void
onUpdated: () => void
}
export default function WrongQuestionDetail({ questionId, open, onClose, onUpdated }: Props) {
const [wq, setWq] = useState<WrongQuestion | null>(null)
const [loading, setLoading] = useState(false)
const [questionText, setQuestionText] = useState('')
const [solutionText, setSolutionText] = useState('')
const [saving, setSaving] = useState(false)
const [regenerating, setRegenerating] = useState(false)
const load = async () => {
setLoading(true)
try {
const { data } = await wrongQuestionApi.get(questionId)
setWq(data)
setQuestionText(data.question_text || '')
setSolutionText(data.solution_text || '')
} finally {
setLoading(false)
}
}
useEffect(() => {
if (open && questionId) load()
}, [open, questionId])
const handleSave = async () => {
setSaving(true)
try {
await wrongQuestionApi.update(questionId, {
question_text: questionText,
solution_text: solutionText,
})
message.success('已保存')
onUpdated()
} finally {
setSaving(false)
}
}
const handleRegenerate = async () => {
setRegenerating(true)
try {
const { data } = await wrongQuestionApi.regenerate(questionId)
setWq(data)
setQuestionText(data.question_text || '')
setSolutionText(data.solution_text || '')
message.success('解法已重新生成')
onUpdated()
} catch {
message.error('生成失败,请确认 Ollama 已启动')
} finally {
setRegenerating(false)
}
}
const handleRetryOcr = async () => {
await wrongQuestionApi.retryOcr(questionId)
message.info('已重新识别,请稍后刷新')
onUpdated()
onClose()
}
return (
<Modal
title={wq ? `${wq.subject_name} · 错题详情` : '错题详情'}
open={open}
onCancel={onClose}
width="90%"
style={{ maxWidth: 960 }}
footer={
<Space wrap>
<Button onClick={handleRetryOcr}> OCR</Button>
<Button loading={regenerating} onClick={handleRegenerate}>
</Button>
<Button type="primary" loading={saving} onClick={handleSave}>
</Button>
</Space>
}
>
<Spin spinning={loading}>
{wq && (
<>
<Typography.Text type="secondary">{STATUS_LABELS[wq.status]}</Typography.Text>
{wq.solution_text && (
<Alert
message="AI 生成内容,请核对后再使用"
type="warning"
showIcon
style={{ margin: '12px 0' }}
/>
)}
<Row gutter={16} style={{ marginTop: 12 }}>
<Col xs={24} md={10}>
<img
src={wrongQuestionApi.imageUrl(wq.id)}
alt="原题"
style={{ width: '100%', borderRadius: 8, border: '1px solid #f0f0f0' }}
/>
{wq.ocr_raw_text && (
<div style={{ marginTop: 12 }}>
<Typography.Text strong>OCR </Typography.Text>
<pre
style={{
background: '#fafafa',
padding: 8,
fontSize: 12,
maxHeight: 150,
overflow: 'auto',
whiteSpace: 'pre-wrap',
}}
>
{wq.ocr_raw_text}
</pre>
</div>
)}
</Col>
<Col xs={24} md={14}>
<Typography.Text strong></Typography.Text>
<Input.TextArea
rows={6}
value={questionText}
onChange={(e) => setQuestionText(e.target.value)}
style={{ marginTop: 8, marginBottom: 16 }}
/>
<Typography.Text strong></Typography.Text>
<Input.TextArea
rows={8}
value={solutionText}
onChange={(e) => setSolutionText(e.target.value)}
style={{ marginTop: 8, marginBottom: 12 }}
/>
{solutionText && (
<div style={{ background: '#fafafa', padding: 12, borderRadius: 8 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
</Typography.Text>
<ReactMarkdown>{solutionText}</ReactMarkdown>
</div>
)}
</Col>
</Row>
</>
)}
</Spin>
</Modal>
)
}
+101
View File
@@ -0,0 +1,101 @@
export interface TokenResponse {
access_token: string
refresh_token: string
token_type: string
}
export interface User {
id: string
username: string
created_at: string
}
export type SchoolLevel = 'junior_high' | 'senior_high'
export interface Student {
id: string
name: string
school_level: SchoolLevel
grade: string | null
class_name: string | null
created_at: string
}
export interface Subject {
id: number
name: string
}
export interface Score {
id: string
subject_id: number
subject_name?: string
total_score: number
obtained_score: number
ratio: number
}
export type ExamType = 'weekly' | 'monthly' | 'final'
export interface Exam {
id: string
exam_type: ExamType
exam_date: string
title: string | null
created_at: string
scores: Score[]
}
export interface ScoreInput {
subject_id: number
total_score: number
obtained_score: number
}
export interface TrendPoint {
exam_id: string
exam_type: ExamType
exam_date: string
title: string | null
ratio: number
ratio_percent: number
delta: number | null
delta_percent: number | null
is_volatile: boolean
direction: 'up' | 'down' | 'flat' | null
}
export interface TrendResponse {
subject_id: number
subject_name: string
threshold: number
points: TrendPoint[]
}
export type WrongQuestionStatus = 'pending' | 'ocr_done' | 'solved' | 'failed'
export interface WrongQuestion {
id: string
student_id: string
subject_id: number
subject_name?: string
image_path: string
ocr_raw_text: string | null
question_text: string | null
solution_text: string | null
status: WrongQuestionStatus
created_at: string
}
export const EXAM_TYPE_LABELS: Record<ExamType, string> = {
weekly: '周考',
monthly: '月考',
final: '期末',
}
export const STATUS_LABELS: Record<WrongQuestionStatus, string> = {
pending: '处理中',
ocr_done: '已识别',
solved: '已生成解法',
failed: '失败',
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})
+14
View File
@@ -0,0 +1,14 @@
$ErrorActionPreference = "Stop"
$BackupDir = if ($args[0]) { $args[0] } else { ".\backups" }
$Timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
New-Item -ItemType Directory -Force -Path $BackupDir | Out-Null
Write-Host "Backing up database..."
docker compose exec -T db pg_dump -U postgres student_archive | Out-File -Encoding utf8 "$BackupDir\db_$Timestamp.sql"
Write-Host "Backing up uploads..."
Compress-Archive -Path "uploads\*" -DestinationPath "$BackupDir\uploads_$Timestamp.zip" -Force
Write-Host "Backup complete:"
Write-Host " $BackupDir\db_$Timestamp.sql"
Write-Host " $BackupDir\uploads_$Timestamp.zip"
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
# 兼容入口,请优先使用 deploy/backup.sh
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec bash "${SCRIPT_DIR}/../deploy/backup.sh" "$@"