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:
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
uploads/
|
||||
backups/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
!.env.example
|
||||
node_modules/
|
||||
dist/
|
||||
@@ -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**
|
||||
@@ -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 等)各自适用其
|
||||
原项目许可证,与本版权声明无关。
|
||||
@@ -0,0 +1,3 @@
|
||||
# 中学成绩档案系统
|
||||
# Copyright (c) 马建军. All rights reserved.
|
||||
# 微信: dekun03 手机: 18364911125
|
||||
@@ -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
|
||||
|
||||
未经授权不得商业使用或去除版权信息。
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"}
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}"'},
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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'"
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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, "初中")
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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 "内存不足 2GB,PaddleOCR 首次运行可能较慢"
|
||||
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 服务(首次可能需 10–30 分钟)…"
|
||||
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 "$@"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
@@ -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 Server(64 位)
|
||||
- 可访问互联网(拉取 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
@@ -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:可以。使用同一服务器地址与账号登录即可。
|
||||
|
||||
**Q:HTTPS 和域名怎么配置?**
|
||||
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)。
|
||||
商业使用或二次分发请联系作者取得授权。
|
||||
@@ -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?
|
||||
@@ -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 }]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+3995
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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 |
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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(' · ') : '未设置学段年级'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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: '失败',
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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"
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
# 兼容入口,请优先使用 deploy/backup.sh
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec bash "${SCRIPT_DIR}/../deploy/backup.sh" "$@"
|
||||
Reference in New Issue
Block a user