学生资料设置、头像与自动备份恢复。
首页卡片支持修改/删除;详情页设置 Tab 可维护学校、年级与头像;系统设置新增数据备份下载与恢复;备份默认存 /root/grade-archive-backups,详见 docs/BACKUP.md。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,6 +2,9 @@ DATABASE_URL=postgresql://gradeapp:postgres@127.0.0.1:5432/student_archive
|
|||||||
SECRET_KEY=dev-secret-key-change-in-production
|
SECRET_KEY=dev-secret-key-change-in-production
|
||||||
CORS_ORIGINS=http://localhost:5173,http://localhost:23566
|
CORS_ORIGINS=http://localhost:5173,http://localhost:23566
|
||||||
UPLOAD_DIR=uploads
|
UPLOAD_DIR=uploads
|
||||||
|
BACKUP_DIR=/root/grade-archive-backups
|
||||||
|
BACKUP_RETENTION_DAYS=30
|
||||||
|
AUTO_BACKUP_INTERVAL_HOURS=24
|
||||||
API_PORT=23568
|
API_PORT=23568
|
||||||
OLLAMA_BASE_URL=http://127.0.0.1:11434
|
OLLAMA_BASE_URL=http://127.0.0.1:11434
|
||||||
OLLAMA_MODEL=qwen2.5:7b
|
OLLAMA_MODEL=qwen2.5:7b
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ class Settings(BaseSettings):
|
|||||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||||
ALGORITHM: str = "HS256"
|
ALGORITHM: str = "HS256"
|
||||||
UPLOAD_DIR: str = "uploads"
|
UPLOAD_DIR: str = "uploads"
|
||||||
|
BACKUP_DIR: str = "/root/grade-archive-backups"
|
||||||
|
BACKUP_RETENTION_DAYS: int = 30
|
||||||
|
AUTO_BACKUP_INTERVAL_HOURS: int = 24
|
||||||
MAX_UPLOAD_SIZE: int = 10 * 1024 * 1024
|
MAX_UPLOAD_SIZE: int = 10 * 1024 * 1024
|
||||||
OLLAMA_BASE_URL: str = "http://127.0.0.1:11434"
|
OLLAMA_BASE_URL: str = "http://127.0.0.1:11434"
|
||||||
OLLAMA_MODEL: str = "qwen2.5:7b"
|
OLLAMA_MODEL: str = "qwen2.5:7b"
|
||||||
|
|||||||
+20
-1
@@ -1,5 +1,8 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@@ -8,7 +11,8 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import Base, SessionLocal, engine
|
from app.core.database import Base, SessionLocal, engine
|
||||||
from app.routers import admin, auth, compositions, exams, export, settings as settings_router, students, subjects, wrong_questions
|
from app.routers import admin, auth, backups, compositions, exams, export, settings as settings_router, students, subjects, wrong_questions
|
||||||
|
from app.services import backup as backup_service
|
||||||
from app.services import ocr as ocr_service
|
from app.services import ocr as ocr_service
|
||||||
from app.services.migrate import run_migrations
|
from app.services.migrate import run_migrations
|
||||||
from app.services.seed import seed_admin_and_settings, seed_subjects
|
from app.services.seed import seed_admin_and_settings, seed_subjects
|
||||||
@@ -24,9 +28,21 @@ def resolve_frontend_dist() -> Path | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_backup_loop() -> None:
|
||||||
|
interval = max(settings.AUTO_BACKUP_INTERVAL_HOURS, 1) * 3600
|
||||||
|
time.sleep(300)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
backup_service.create_backup()
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger(__name__).exception("自动备份失败")
|
||||||
|
time.sleep(interval)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
Path(settings.UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
|
Path(settings.UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
|
||||||
|
Path(settings.BACKUP_DIR).mkdir(parents=True, exist_ok=True)
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
run_migrations()
|
run_migrations()
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
@@ -36,6 +52,8 @@ async def lifespan(app: FastAPI):
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
ocr_service.warmup_ocr_engine()
|
ocr_service.warmup_ocr_engine()
|
||||||
|
if settings.AUTO_BACKUP_INTERVAL_HOURS > 0:
|
||||||
|
threading.Thread(target=_auto_backup_loop, daemon=True).start()
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
@@ -53,6 +71,7 @@ app.add_middleware(
|
|||||||
app.include_router(auth.router, prefix="/api")
|
app.include_router(auth.router, prefix="/api")
|
||||||
app.include_router(settings_router.router, prefix="/api")
|
app.include_router(settings_router.router, prefix="/api")
|
||||||
app.include_router(admin.router, prefix="/api")
|
app.include_router(admin.router, prefix="/api")
|
||||||
|
app.include_router(backups.router, prefix="/api")
|
||||||
app.include_router(students.router, prefix="/api")
|
app.include_router(students.router, prefix="/api")
|
||||||
app.include_router(subjects.router, prefix="/api")
|
app.include_router(subjects.router, prefix="/api")
|
||||||
app.include_router(exams.router, prefix="/api")
|
app.include_router(exams.router, prefix="/api")
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ class Student(Base):
|
|||||||
)
|
)
|
||||||
grade: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
grade: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
class_name: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
class_name: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
|
school_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
|
avatar_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
from app.core.deps import get_superuser
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas import BackupInfoOut
|
||||||
|
from app.services import backup as backup_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin/backups", tags=["admin-backups"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[BackupInfoOut])
|
||||||
|
def list_backups(_: User = Depends(get_superuser)):
|
||||||
|
return backup_service.list_backups()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/run", response_model=BackupInfoOut)
|
||||||
|
def run_backup(_: User = Depends(get_superuser)):
|
||||||
|
try:
|
||||||
|
path = backup_service.create_backup()
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"备份失败: {exc}",
|
||||||
|
) from exc
|
||||||
|
stat = path.stat()
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
return BackupInfoOut(
|
||||||
|
filename=path.name,
|
||||||
|
size_bytes=stat.st_size,
|
||||||
|
created_at=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{filename}/download")
|
||||||
|
def download_backup(filename: str, _: User = Depends(get_superuser)):
|
||||||
|
try:
|
||||||
|
path = backup_service.resolve_backup_file(filename)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="备份不存在") from exc
|
||||||
|
return FileResponse(
|
||||||
|
path,
|
||||||
|
media_type="application/gzip",
|
||||||
|
filename=filename,
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/restore")
|
||||||
|
async def restore_backup(file: UploadFile = File(...), _: User = Depends(get_superuser)):
|
||||||
|
if not file.filename or not file.filename.endswith(".tar.gz"):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请上传 .tar.gz 备份包")
|
||||||
|
content = await file.read()
|
||||||
|
if len(content) > 512 * 1024 * 1024:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="备份文件过大(最大 512MB)")
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
tmp = Path(tempfile.mkdtemp(prefix="grade-archive-upload-"))
|
||||||
|
archive = tmp / "restore.tar.gz"
|
||||||
|
try:
|
||||||
|
archive.write_bytes(content)
|
||||||
|
backup_service.restore_backup(archive)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"恢复失败: {exc}",
|
||||||
|
) from exc
|
||||||
|
finally:
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
return {"ok": True, "message": "数据已恢复,建议重启服务以确保缓存刷新"}
|
||||||
@@ -1,28 +1,37 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.deps import get_current_user
|
from app.core.deps import get_current_user
|
||||||
from app.models.user import Student, User
|
from app.models.user import Student, User
|
||||||
from app.schemas import StudentCreate, StudentOut, StudentUpdate
|
from app.schemas import StudentCreate, StudentOut, StudentUpdate
|
||||||
from app.services.student_access import get_student_for_user
|
from app.services.student_access import get_student_for_user
|
||||||
|
from app.services.student_avatar import delete_avatar_file, save_avatar
|
||||||
|
|
||||||
router = APIRouter(prefix="/students", tags=["students"])
|
router = APIRouter(prefix="/students", tags=["students"])
|
||||||
|
|
||||||
|
|
||||||
|
def _to_out(student: Student) -> StudentOut:
|
||||||
|
return StudentOut.from_student(student)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[StudentOut])
|
@router.get("", response_model=list[StudentOut])
|
||||||
def list_students(
|
def list_students(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
return (
|
rows = (
|
||||||
db.query(Student)
|
db.query(Student)
|
||||||
.filter(Student.user_id == current_user.id)
|
.filter(Student.user_id == current_user.id)
|
||||||
.order_by(Student.created_at.desc())
|
.order_by(Student.created_at.desc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
return [_to_out(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=StudentOut, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=StudentOut, status_code=status.HTTP_201_CREATED)
|
||||||
@@ -35,7 +44,7 @@ def create_student(
|
|||||||
db.add(student)
|
db.add(student)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(student)
|
db.refresh(student)
|
||||||
return student
|
return _to_out(student)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{student_id}", response_model=StudentOut)
|
@router.get("/{student_id}", response_model=StudentOut)
|
||||||
@@ -44,7 +53,8 @@ def get_student(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
return get_student_for_user(db, student_id, current_user.id)
|
student = get_student_for_user(db, student_id, current_user.id)
|
||||||
|
return _to_out(student)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{student_id}", response_model=StudentOut)
|
@router.patch("/{student_id}", response_model=StudentOut)
|
||||||
@@ -59,7 +69,7 @@ def update_student(
|
|||||||
setattr(student, key, value)
|
setattr(student, key, value)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(student)
|
db.refresh(student)
|
||||||
return student
|
return _to_out(student)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{student_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{student_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
@@ -69,5 +79,56 @@ def delete_student(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
student = get_student_for_user(db, student_id, current_user.id)
|
student = get_student_for_user(db, student_id, current_user.id)
|
||||||
|
delete_avatar_file(student.avatar_path)
|
||||||
db.delete(student)
|
db.delete(student)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{student_id}/avatar", response_model=StudentOut)
|
||||||
|
async def upload_avatar(
|
||||||
|
student_id: uuid.UUID,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
student = get_student_for_user(db, student_id, current_user.id)
|
||||||
|
content = await file.read()
|
||||||
|
if len(content) > settings.MAX_UPLOAD_SIZE:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文件超过10MB限制")
|
||||||
|
if not (file.content_type or "").startswith("image/"):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请上传图片文件")
|
||||||
|
|
||||||
|
delete_avatar_file(student.avatar_path)
|
||||||
|
student.avatar_path = save_avatar(str(current_user.id), str(student.id), content)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(student)
|
||||||
|
return _to_out(student)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{student_id}/avatar", response_model=StudentOut)
|
||||||
|
def remove_avatar(
|
||||||
|
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)
|
||||||
|
delete_avatar_file(student.avatar_path)
|
||||||
|
student.avatar_path = None
|
||||||
|
db.commit()
|
||||||
|
db.refresh(student)
|
||||||
|
return _to_out(student)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{student_id}/avatar")
|
||||||
|
def get_avatar(
|
||||||
|
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)
|
||||||
|
if not student.avatar_path:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="未设置头像")
|
||||||
|
path = Path(settings.UPLOAD_DIR) / student.avatar_path
|
||||||
|
if not path.is_file():
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="头像文件不存在")
|
||||||
|
return FileResponse(path, media_type="image/jpeg")
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ class StudentCreate(BaseModel):
|
|||||||
school_level: SchoolLevelEnum = SchoolLevelEnum.junior_high
|
school_level: SchoolLevelEnum = SchoolLevelEnum.junior_high
|
||||||
grade: str | None = None
|
grade: str | None = None
|
||||||
class_name: str | None = None
|
class_name: str | None = None
|
||||||
|
school_name: str | None = Field(default=None, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
class StudentUpdate(BaseModel):
|
class StudentUpdate(BaseModel):
|
||||||
@@ -143,6 +144,7 @@ class StudentUpdate(BaseModel):
|
|||||||
school_level: SchoolLevelEnum | None = None
|
school_level: SchoolLevelEnum | None = None
|
||||||
grade: str | None = None
|
grade: str | None = None
|
||||||
class_name: str | None = None
|
class_name: str | None = None
|
||||||
|
school_name: str | None = Field(default=None, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
class StudentOut(BaseModel):
|
class StudentOut(BaseModel):
|
||||||
@@ -151,10 +153,25 @@ class StudentOut(BaseModel):
|
|||||||
school_level: SchoolLevelEnum
|
school_level: SchoolLevelEnum
|
||||||
grade: str | None
|
grade: str | None
|
||||||
class_name: str | None
|
class_name: str | None
|
||||||
|
school_name: str | None
|
||||||
|
has_avatar: bool = False
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_student(cls, student) -> "StudentOut":
|
||||||
|
return cls(
|
||||||
|
id=student.id,
|
||||||
|
name=student.name,
|
||||||
|
school_level=SchoolLevelEnum(student.school_level.value),
|
||||||
|
grade=student.grade,
|
||||||
|
class_name=student.class_name,
|
||||||
|
school_name=student.school_name,
|
||||||
|
has_avatar=bool(getattr(student, "avatar_path", None)),
|
||||||
|
created_at=student.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SubjectOut(BaseModel):
|
class SubjectOut(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
@@ -335,3 +352,9 @@ class CompositionOut(BaseModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class BackupInfoOut(BaseModel):
|
||||||
|
filename: str
|
||||||
|
size_bytes: int
|
||||||
|
created_at: datetime
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
BACKUP_NAME_PATTERN = re.compile(r"^grade-archive_\d{8}_\d{6}\.tar\.gz$")
|
||||||
|
APP_VERSION = "1.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def backup_dir() -> Path:
|
||||||
|
path = Path(settings.BACKUP_DIR)
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _pg_params() -> dict[str, str]:
|
||||||
|
parsed = urlparse(settings.DATABASE_URL)
|
||||||
|
return {
|
||||||
|
"host": parsed.hostname or "127.0.0.1",
|
||||||
|
"port": str(parsed.port or 5432),
|
||||||
|
"user": parsed.username or "postgres",
|
||||||
|
"password": parsed.password or "",
|
||||||
|
"dbname": parsed.path.lstrip("/") or "student_archive",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _dump_database(dest: Path) -> None:
|
||||||
|
pg = _pg_params()
|
||||||
|
env = {**os.environ, "PGPASSWORD": pg["password"]}
|
||||||
|
cmd = [
|
||||||
|
"pg_dump",
|
||||||
|
"-h",
|
||||||
|
pg["host"],
|
||||||
|
"-p",
|
||||||
|
pg["port"],
|
||||||
|
"-U",
|
||||||
|
pg["user"],
|
||||||
|
"-d",
|
||||||
|
pg["dbname"],
|
||||||
|
"--no-owner",
|
||||||
|
"--no-privileges",
|
||||||
|
"--clean",
|
||||||
|
"--if-exists",
|
||||||
|
]
|
||||||
|
with dest.open("w", encoding="utf-8") as out:
|
||||||
|
subprocess.run(cmd, check=True, env=env, stdout=out)
|
||||||
|
|
||||||
|
|
||||||
|
def create_backup() -> Path:
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
archive_path = backup_dir() / f"grade-archive_{timestamp}.tar.gz"
|
||||||
|
work = Path(tempfile.mkdtemp(prefix="grade-archive-backup-"))
|
||||||
|
try:
|
||||||
|
db_file = work / "database.sql"
|
||||||
|
_dump_database(db_file)
|
||||||
|
manifest = {
|
||||||
|
"app": "secondary-school-grade-archive",
|
||||||
|
"version": APP_VERSION,
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"database": pg["dbname"] if (pg := _pg_params()) else "student_archive",
|
||||||
|
}
|
||||||
|
manifest_file = work / "manifest.json"
|
||||||
|
manifest_file.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
uploads_dir = Path(settings.UPLOAD_DIR)
|
||||||
|
with tarfile.open(archive_path, "w:gz") as tar:
|
||||||
|
tar.add(db_file, arcname="database.sql")
|
||||||
|
tar.add(manifest_file, arcname="manifest.json")
|
||||||
|
if uploads_dir.is_dir():
|
||||||
|
tar.add(uploads_dir, arcname="uploads")
|
||||||
|
|
||||||
|
cleanup_old_backups()
|
||||||
|
logger.info("Backup created: %s", archive_path)
|
||||||
|
return archive_path
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(work, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_old_backups() -> None:
|
||||||
|
if settings.BACKUP_RETENTION_DAYS <= 0:
|
||||||
|
return
|
||||||
|
cutoff = datetime.now().timestamp() - settings.BACKUP_RETENTION_DAYS * 86400
|
||||||
|
for item in backup_dir().glob("grade-archive_*.tar.gz"):
|
||||||
|
if item.stat().st_mtime < cutoff:
|
||||||
|
item.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def list_backups() -> list[dict]:
|
||||||
|
items: list[dict] = []
|
||||||
|
for path in sorted(backup_dir().glob("grade-archive_*.tar.gz"), reverse=True):
|
||||||
|
stat = path.stat()
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"filename": path.name,
|
||||||
|
"size_bytes": stat.st_size,
|
||||||
|
"created_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_backup_file(filename: str) -> Path:
|
||||||
|
if not BACKUP_NAME_PATTERN.match(filename):
|
||||||
|
raise ValueError("无效的备份文件名")
|
||||||
|
path = backup_dir() / filename
|
||||||
|
if not path.is_file():
|
||||||
|
raise FileNotFoundError(filename)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def restore_backup(archive_path: Path) -> None:
|
||||||
|
work = Path(tempfile.mkdtemp(prefix="grade-archive-restore-"))
|
||||||
|
try:
|
||||||
|
with tarfile.open(archive_path, "r:gz") as tar:
|
||||||
|
tar.extractall(work)
|
||||||
|
|
||||||
|
db_file = work / "database.sql"
|
||||||
|
if not db_file.is_file():
|
||||||
|
raise ValueError("备份包缺少 database.sql")
|
||||||
|
|
||||||
|
pg = _pg_params()
|
||||||
|
env = {**os.environ, "PGPASSWORD": pg["password"]}
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"psql",
|
||||||
|
"-h",
|
||||||
|
pg["host"],
|
||||||
|
"-p",
|
||||||
|
pg["port"],
|
||||||
|
"-U",
|
||||||
|
pg["user"],
|
||||||
|
"-d",
|
||||||
|
pg["dbname"],
|
||||||
|
"-v",
|
||||||
|
"ON_ERROR_STOP=1",
|
||||||
|
"-f",
|
||||||
|
str(db_file),
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
|
||||||
|
uploads_src = work / "uploads"
|
||||||
|
uploads_dest = Path(settings.UPLOAD_DIR)
|
||||||
|
if uploads_src.is_dir():
|
||||||
|
if uploads_dest.exists():
|
||||||
|
shutil.rmtree(uploads_dest)
|
||||||
|
shutil.copytree(uploads_src, uploads_dest)
|
||||||
|
else:
|
||||||
|
uploads_dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
logger.info("Backup restored from %s", archive_path)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(work, ignore_errors=True)
|
||||||
@@ -101,3 +101,14 @@ def run_migrations() -> None:
|
|||||||
"ALTER TABLE system_settings ADD COLUMN ai_review_enabled BOOLEAN NOT NULL DEFAULT TRUE"
|
"ALTER TABLE system_settings ADD COLUMN ai_review_enabled BOOLEAN NOT NULL DEFAULT TRUE"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
student_columns = {col["name"] for col in inspector.get_columns("students")}
|
||||||
|
student_alters: list[str] = []
|
||||||
|
if "school_name" not in student_columns:
|
||||||
|
student_alters.append("ADD COLUMN school_name VARCHAR(128)")
|
||||||
|
if "avatar_path" not in student_columns:
|
||||||
|
student_alters.append("ADD COLUMN avatar_path VARCHAR(512)")
|
||||||
|
if student_alters:
|
||||||
|
with engine.begin() as conn:
|
||||||
|
for clause in student_alters:
|
||||||
|
conn.execute(text(f"ALTER TABLE students {clause}"))
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import uuid
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def avatar_rel_path(user_id: str, student_id: str) -> str:
|
||||||
|
return f"{user_id}/avatars/{student_id}.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
def save_avatar(user_id: str, student_id: str, content: bytes) -> str:
|
||||||
|
rel = avatar_rel_path(user_id, student_id)
|
||||||
|
full = Path(settings.UPLOAD_DIR) / rel
|
||||||
|
full.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
image = Image.open(BytesIO(content))
|
||||||
|
if image.mode not in ("RGB", "L"):
|
||||||
|
image = image.convert("RGB")
|
||||||
|
image.thumbnail((256, 256), Image.Resampling.LANCZOS)
|
||||||
|
image.save(full, format="JPEG", quality=85, optimize=True)
|
||||||
|
return rel
|
||||||
|
|
||||||
|
|
||||||
|
def delete_avatar_file(avatar_path: str | None) -> None:
|
||||||
|
if not avatar_path:
|
||||||
|
return
|
||||||
|
full = Path(settings.UPLOAD_DIR) / avatar_path
|
||||||
|
if full.is_file():
|
||||||
|
full.unlink()
|
||||||
+25
-7
@@ -1,9 +1,17 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
# 中学成绩档案 — 数据备份(数据库 + uploads,统一 tar.gz)
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
INSTALL_DIR="${INSTALL_DIR:-/opt/secondary-school-grade-archive}"
|
INSTALL_DIR="${INSTALL_DIR:-/opt/secondary-school-grade-archive}"
|
||||||
BACKUP_DIR="${BACKUP_DIR:-${INSTALL_DIR}/backups}"
|
BACKUP_DIR="${BACKUP_DIR:-/root/grade-archive-backups}"
|
||||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||||
|
ARCHIVE="${BACKUP_DIR}/grade-archive_${TIMESTAMP}.tar.gz"
|
||||||
|
WORK=$(mktemp -d)
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "${WORK}"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
cd "${INSTALL_DIR}"
|
cd "${INSTALL_DIR}"
|
||||||
# shellcheck disable=SC1090
|
# shellcheck disable=SC1090
|
||||||
@@ -12,11 +20,21 @@ mkdir -p "${BACKUP_DIR}"
|
|||||||
|
|
||||||
echo "[INFO] 备份数据库…"
|
echo "[INFO] 备份数据库…"
|
||||||
PGPASSWORD="${POSTGRES_PASSWORD}" pg_dump -h 127.0.0.1 -U "${POSTGRES_USER}" "${POSTGRES_DB}" \
|
PGPASSWORD="${POSTGRES_PASSWORD}" pg_dump -h 127.0.0.1 -U "${POSTGRES_USER}" "${POSTGRES_DB}" \
|
||||||
> "${BACKUP_DIR}/db_${TIMESTAMP}.sql"
|
--no-owner --no-privileges --clean --if-exists \
|
||||||
|
> "${WORK}/database.sql"
|
||||||
|
|
||||||
echo "[INFO] 备份 uploads…"
|
cat > "${WORK}/manifest.json" <<EOF
|
||||||
tar -czf "${BACKUP_DIR}/uploads_${TIMESTAMP}.tar.gz" -C "${INSTALL_DIR}" uploads/
|
{
|
||||||
|
"app": "secondary-school-grade-archive",
|
||||||
|
"created_at": "$(date -Iseconds)",
|
||||||
|
"database": "${POSTGRES_DB}"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
echo "[INFO] 完成:"
|
echo "[INFO] 打包 uploads…"
|
||||||
echo " ${BACKUP_DIR}/db_${TIMESTAMP}.sql"
|
tar -czf "${ARCHIVE}" -C "${WORK}" database.sql manifest.json -C "${INSTALL_DIR}" uploads/
|
||||||
echo " ${BACKUP_DIR}/uploads_${TIMESTAMP}.tar.gz"
|
|
||||||
|
echo "[INFO] 完成: ${ARCHIVE}"
|
||||||
|
|
||||||
|
# 清理 30 天前的备份
|
||||||
|
find "${BACKUP_DIR}" -name 'grade-archive_*.tar.gz' -mtime +30 -delete 2>/dev/null || true
|
||||||
|
|||||||
+17
-1
@@ -149,6 +149,9 @@ POSTGRES_PASSWORD=${pg_pass}
|
|||||||
POSTGRES_DB=student_archive
|
POSTGRES_DB=student_archive
|
||||||
DATABASE_URL=postgresql://${pg_user}:${pg_pass}@127.0.0.1:5432/student_archive
|
DATABASE_URL=postgresql://${pg_user}:${pg_pass}@127.0.0.1:5432/student_archive
|
||||||
UPLOAD_DIR=${INSTALL_DIR}/uploads
|
UPLOAD_DIR=${INSTALL_DIR}/uploads
|
||||||
|
BACKUP_DIR=/root/grade-archive-backups
|
||||||
|
BACKUP_RETENTION_DAYS=30
|
||||||
|
AUTO_BACKUP_INTERVAL_HOURS=24
|
||||||
CORS_ORIGINS=http://${server_ip}:${WEB_PORT},http://127.0.0.1:${WEB_PORT},http://localhost:${WEB_PORT}
|
CORS_ORIGINS=http://${server_ip}:${WEB_PORT},http://127.0.0.1:${WEB_PORT},http://localhost:${WEB_PORT}
|
||||||
# OCR 同机 GPU Worker(screen 常驻)
|
# OCR 同机 GPU Worker(screen 常驻)
|
||||||
OCR_SERVICE_URL=http://127.0.0.1:${OCR_PORT}
|
OCR_SERVICE_URL=http://127.0.0.1:${OCR_PORT}
|
||||||
@@ -237,10 +240,21 @@ setup_systemd() {
|
|||||||
start_service() {
|
start_service() {
|
||||||
log_info "启动主程序…"
|
log_info "启动主程序…"
|
||||||
cd "${INSTALL_DIR}"
|
cd "${INSTALL_DIR}"
|
||||||
mkdir -p uploads backups
|
mkdir -p uploads backups /root/grade-archive-backups
|
||||||
|
chmod +x deploy/backup.sh deploy/restore.sh 2>/dev/null || true
|
||||||
systemctl restart grade-archive
|
systemctl restart grade-archive
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setup_backup_cron() {
|
||||||
|
log_info "配置每日自动备份(/root/grade-archive-backups)…"
|
||||||
|
cat > /etc/cron.d/grade-archive-backup <<EOF
|
||||||
|
SHELL=/bin/bash
|
||||||
|
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
|
||||||
|
0 3 * * * root INSTALL_DIR=${INSTALL_DIR} BACKUP_DIR=/root/grade-archive-backups bash ${INSTALL_DIR}/deploy/backup.sh >> /var/log/grade-archive-backup.log 2>&1
|
||||||
|
EOF
|
||||||
|
chmod 644 /etc/cron.d/grade-archive-backup
|
||||||
|
}
|
||||||
|
|
||||||
wait_healthy() {
|
wait_healthy() {
|
||||||
local i
|
local i
|
||||||
log_info "等待主程序就绪(最多 2 分钟)…"
|
log_info "等待主程序就绪(最多 2 分钟)…"
|
||||||
@@ -285,6 +299,7 @@ print_summary() {
|
|||||||
echo ""
|
echo ""
|
||||||
echo " 主程序: systemctl status grade-archive"
|
echo " 主程序: systemctl status grade-archive"
|
||||||
echo " 更新: sudo bash ${INSTALL_DIR}/deploy/update.sh"
|
echo " 更新: sudo bash ${INSTALL_DIR}/deploy/update.sh"
|
||||||
|
echo " 备份说明: docs/BACKUP.md"
|
||||||
echo " 卸载: sudo bash ${INSTALL_DIR}/deploy/uninstall.sh"
|
echo " 卸载: sudo bash ${INSTALL_DIR}/deploy/uninstall.sh"
|
||||||
echo " 微信 dekun03 手机 18364911125"
|
echo " 微信 dekun03 手机 18364911125"
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
@@ -305,6 +320,7 @@ main() {
|
|||||||
setup_ocr_gpu
|
setup_ocr_gpu
|
||||||
stop_legacy_pm2
|
stop_legacy_pm2
|
||||||
setup_systemd
|
setup_systemd
|
||||||
|
setup_backup_cron
|
||||||
start_service
|
start_service
|
||||||
wait_healthy
|
wait_healthy
|
||||||
print_summary
|
print_summary
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 中学成绩档案 — 从备份包恢复(命令行,适合新服务器迁移)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ $# -lt 1 ]]; then
|
||||||
|
echo "用法: sudo bash deploy/restore.sh /path/to/grade-archive_YYYYMMDD_HHMMSS.tar.gz"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ARCHIVE="$1"
|
||||||
|
INSTALL_DIR="${INSTALL_DIR:-/opt/secondary-school-grade-archive}"
|
||||||
|
WORK=$(mktemp -d)
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "${WORK}"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
if [[ ! -f "${ARCHIVE}" ]]; then
|
||||||
|
echo "[ERROR] 备份文件不存在: ${ARCHIVE}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "${INSTALL_DIR}"
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source .env
|
||||||
|
|
||||||
|
echo "[WARN] 即将恢复数据库与 uploads,当前数据将被覆盖。"
|
||||||
|
read -rp "确认继续?[y/N] " confirm
|
||||||
|
if [[ "${confirm}" != "y" && "${confirm}" != "Y" ]]; then
|
||||||
|
echo "已取消"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[INFO] 解压备份…"
|
||||||
|
tar -xzf "${ARCHIVE}" -C "${WORK}"
|
||||||
|
|
||||||
|
if [[ ! -f "${WORK}/database.sql" ]]; then
|
||||||
|
echo "[ERROR] 备份包缺少 database.sql"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[INFO] 恢复数据库…"
|
||||||
|
PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" \
|
||||||
|
-v ON_ERROR_STOP=1 -f "${WORK}/database.sql"
|
||||||
|
|
||||||
|
if [[ -d "${WORK}/uploads" ]]; then
|
||||||
|
echo "[INFO] 恢复 uploads…"
|
||||||
|
rm -rf "${INSTALL_DIR}/uploads"
|
||||||
|
cp -a "${WORK}/uploads" "${INSTALL_DIR}/uploads"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[INFO] 重启服务…"
|
||||||
|
systemctl restart grade-archive 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "[INFO] 恢复完成"
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
# 数据备份与恢复
|
||||||
|
|
||||||
|
> **中学成绩档案系统** · 备份目录默认 `/root/grade-archive-backups`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 备份内容
|
||||||
|
|
||||||
|
每次备份生成一个压缩包 `grade-archive_YYYYMMDD_HHMMSS.tar.gz`,包含:
|
||||||
|
|
||||||
|
| 文件/目录 | 说明 |
|
||||||
|
|-----------|------|
|
||||||
|
| `database.sql` | PostgreSQL 全库导出(含 `--clean`,可覆盖恢复) |
|
||||||
|
| `uploads/` | 错题图片、学生头像等上传文件 |
|
||||||
|
| `manifest.json` | 备份元信息(时间、库名) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 自动备份
|
||||||
|
|
||||||
|
- **目录**:`/root/grade-archive-backups`(可通过 `.env` 中 `BACKUP_DIR` 修改)
|
||||||
|
- **频率**:应用启动后每 **24 小时**自动备份一次(`AUTO_BACKUP_INTERVAL_HOURS=24`)
|
||||||
|
- **保留**:默认保留最近 **30 天**(`BACKUP_RETENTION_DAYS=30`)
|
||||||
|
- **系统 cron**(可选,安装脚本会写入):每天凌晨 3:00 执行 `deploy/backup.sh`
|
||||||
|
|
||||||
|
### 环境变量(`.env`)
|
||||||
|
|
||||||
|
```env
|
||||||
|
BACKUP_DIR=/root/grade-archive-backups
|
||||||
|
BACKUP_RETENTION_DAYS=30
|
||||||
|
AUTO_BACKUP_INTERVAL_HOURS=24
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 手动备份(服务器命令行)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo BACKUP_DIR=/root/grade-archive-backups \
|
||||||
|
bash /opt/secondary-school-grade-archive/deploy/backup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
备份文件位于 `/root/grade-archive-backups/`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 系统设置中下载备份
|
||||||
|
|
||||||
|
1. 使用超级管理员登录
|
||||||
|
2. 进入 **系统设置 → 数据备份**
|
||||||
|
3. 点击 **立即备份** 或等待自动备份
|
||||||
|
4. 在列表中点击 **下载** 保存 `.tar.gz` 到本地
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 更换服务器 — 数据恢复
|
||||||
|
|
||||||
|
### 方式 A:Web 界面(推荐)
|
||||||
|
|
||||||
|
1. 在新服务器完成 `deploy/install.sh` 并 `git pull` 到最新版本
|
||||||
|
2. 超级管理员登录 → **系统设置 → 数据备份**
|
||||||
|
3. 在「数据恢复」区域上传旧服务器下载的 `grade-archive_*.tar.gz`
|
||||||
|
4. 恢复成功后建议执行:`sudo systemctl restart grade-archive`
|
||||||
|
|
||||||
|
### 方式 B:命令行
|
||||||
|
|
||||||
|
1. 将备份包复制到新服务器,例如 `/root/grade-archive_20260628_030000.tar.gz`
|
||||||
|
2. 执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash /opt/secondary-school-grade-archive/deploy/restore.sh \
|
||||||
|
/root/grade-archive_20260628_030000.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 按提示确认后,脚本会恢复数据库与 `uploads/`,并尝试重启服务
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 迁移检查清单
|
||||||
|
|
||||||
|
- [ ] 旧服务器下载最新备份包
|
||||||
|
- [ ] 新服务器安装系统(`install.sh`)并配置 Ollama / OCR 地址
|
||||||
|
- [ ] 上传备份并恢复
|
||||||
|
- [ ] 验证学生资料、成绩、错题图片、头像是否正常
|
||||||
|
- [ ] 确认 `.env` 中 `OLLAMA_BASE_URL`、`OCR_SERVICE_URL` 符合新环境
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 注意事项
|
||||||
|
|
||||||
|
- **恢复会覆盖**当前数据库与 `uploads` 目录,操作前请先备份当前数据
|
||||||
|
- 备份与恢复需要服务器已安装 `pg_dump` / `psql`(安装脚本已包含 PostgreSQL)
|
||||||
|
- 备份目录在 `/root` 下,仅 root 可读写;应用以 systemd 运行时需确保 `BACKUP_DIR` 对运行用户可写,或保持默认由 root cron / 管理员 API 触发
|
||||||
|
- 学生头像、学校、年级等资料保存在数据库 `students` 表中,随数据库一并备份
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 学生资料字段说明
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 姓名 | 必填 |
|
||||||
|
| 学校 | 可选,显示在卡片与详情 |
|
||||||
|
| 学段 | 初中 / 高中 |
|
||||||
|
| 年级 | 初一~初三 或 高一~高三(带明确标识) |
|
||||||
|
| 班级 | 如 `3` 或 `3班` |
|
||||||
|
| 头像 | 保存在 `uploads/{用户ID}/avatars/{学生ID}.jpg` |
|
||||||
|
|
||||||
|
在学生详情页 **设置** Tab 或首页卡片 **修改** 中维护;首页卡片支持 **删除**。
|
||||||
+118
-118
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
<meta name="author" content="马建军" />
|
<meta name="author" content="马建军" />
|
||||||
<meta name="copyright" content="Copyright (c) 马建军. All rights reserved." />
|
<meta name="copyright" content="Copyright (c) 马建军. All rights reserved." />
|
||||||
<title>中学成绩档案</title>
|
<title>中学成绩档案</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BFUIx7uW.js"></script>
|
<script type="module" crossorigin src="/assets/index-C01Hd5WH.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BSTLtBBx.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BSTLtBBx.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
AdminUser,
|
AdminUser,
|
||||||
AIProvider,
|
AIProvider,
|
||||||
AppFeatures,
|
AppFeatures,
|
||||||
|
BackupInfo,
|
||||||
Composition,
|
Composition,
|
||||||
CompositionInputMode,
|
CompositionInputMode,
|
||||||
Exam,
|
Exam,
|
||||||
@@ -96,6 +97,15 @@ export const adminApi = {
|
|||||||
resetUserPassword: (id: string, password: string) =>
|
resetUserPassword: (id: string, password: string) =>
|
||||||
api.patch<AdminUser>(`/admin/users/${id}`, { password }),
|
api.patch<AdminUser>(`/admin/users/${id}`, { password }),
|
||||||
deleteUser: (id: string) => api.delete(`/admin/users/${id}`),
|
deleteUser: (id: string) => api.delete(`/admin/users/${id}`),
|
||||||
|
listBackups: () => api.get<BackupInfo[]>('/admin/backups'),
|
||||||
|
runBackup: () => api.post<BackupInfo>('/admin/backups/run'),
|
||||||
|
downloadBackup: (filename: string) =>
|
||||||
|
api.get(`/admin/backups/${encodeURIComponent(filename)}/download`, { responseType: 'blob' }),
|
||||||
|
restoreBackup: (file: File) => {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
return api.post<{ ok: boolean; message: string }>('/admin/backups/restore', form)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const studentApi = {
|
export const studentApi = {
|
||||||
@@ -103,12 +113,28 @@ export const studentApi = {
|
|||||||
create: (data: {
|
create: (data: {
|
||||||
name: string
|
name: string
|
||||||
school_level?: SchoolLevel
|
school_level?: SchoolLevel
|
||||||
|
school_name?: string
|
||||||
grade?: string
|
grade?: string
|
||||||
class_name?: string
|
class_name?: string
|
||||||
}) => api.post<Student>('/students', data),
|
}) => api.post<Student>('/students', data),
|
||||||
get: (id: string) => api.get<Student>(`/students/${id}`),
|
get: (id: string) => api.get<Student>(`/students/${id}`),
|
||||||
update: (id: string, data: Partial<Student>) => api.patch<Student>(`/students/${id}`, data),
|
update: (
|
||||||
|
id: string,
|
||||||
|
data: Partial<{
|
||||||
|
name: string
|
||||||
|
school_level: SchoolLevel
|
||||||
|
school_name: string
|
||||||
|
grade: string
|
||||||
|
class_name: string
|
||||||
|
}>,
|
||||||
|
) => api.patch<Student>(`/students/${id}`, data),
|
||||||
remove: (id: string) => api.delete(`/students/${id}`),
|
remove: (id: string) => api.delete(`/students/${id}`),
|
||||||
|
uploadAvatar: (id: string, file: File) => {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
return api.post<Student>(`/students/${id}/avatar`, form)
|
||||||
|
},
|
||||||
|
removeAvatar: (id: string) => api.delete<Student>(`/students/${id}/avatar`),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const subjectApi = {
|
export const subjectApi = {
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { UserOutlined } from '@ant-design/icons'
|
||||||
|
import { Avatar } from 'antd'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import api from '../api/client'
|
||||||
|
import type { Student } from '../types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
student: Pick<Student, 'id' | 'name' | 'has_avatar'>
|
||||||
|
size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StudentAvatar({ student, size = 40 }: Props) {
|
||||||
|
const [src, setSrc] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!student.has_avatar) {
|
||||||
|
setSrc(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let objectUrl: string | null = null
|
||||||
|
let cancelled = false
|
||||||
|
api
|
||||||
|
.get(`/students/${student.id}/avatar`, { responseType: 'blob' })
|
||||||
|
.then((res) => {
|
||||||
|
if (cancelled) return
|
||||||
|
objectUrl = URL.createObjectURL(res.data)
|
||||||
|
setSrc(objectUrl)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setSrc(null)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl)
|
||||||
|
}
|
||||||
|
}, [student.id, student.has_avatar])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Avatar
|
||||||
|
size={size}
|
||||||
|
src={src || undefined}
|
||||||
|
icon={!src ? <UserOutlined /> : undefined}
|
||||||
|
style={{ backgroundColor: src ? undefined : '#1677ff', flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{!src ? student.name.slice(0, 1) : null}
|
||||||
|
</Avatar>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Form, Input, Select } from 'antd'
|
||||||
|
import { GRADE_OPTIONS, SCHOOL_LEVEL_LABELS } from '../constants/school'
|
||||||
|
import type { SchoolLevel } from '../types'
|
||||||
|
|
||||||
|
export interface StudentFormValues {
|
||||||
|
name: string
|
||||||
|
school_level: SchoolLevel
|
||||||
|
school_name?: string
|
||||||
|
grade?: string
|
||||||
|
class_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
form: ReturnType<typeof Form.useForm<StudentFormValues>>[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StudentFormFields({ form }: Props) {
|
||||||
|
const schoolLevel = Form.useWatch('school_level', form) as SchoolLevel | undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form.Item name="name" label="姓名" rules={[{ required: true, message: '请输入姓名' }]}>
|
||||||
|
<Input placeholder="学生姓名" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="school_name" label="学校">
|
||||||
|
<Input placeholder="如:XX中学" />
|
||||||
|
</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="年级" rules={[{ required: true, message: '请选择年级' }]}>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder={schoolLevel === 'senior_high' ? '请选择高几' : '请选择初几'}
|
||||||
|
options={(GRADE_OPTIONS[schoolLevel || 'junior_high'] || []).map((item) => ({
|
||||||
|
value: item.value,
|
||||||
|
label: item.label,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="class_name" label="班级">
|
||||||
|
<Input placeholder="如:3 或 3班" />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { DeleteOutlined, UploadOutlined } from '@ant-design/icons'
|
||||||
|
import { Button, Form, Popconfirm, Space, Typography, Upload, message } from 'antd'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { studentApi } from '../api/client'
|
||||||
|
import type { Student } from '../types'
|
||||||
|
import StudentAvatar from './StudentAvatar'
|
||||||
|
import StudentFormFields, { type StudentFormValues } from './StudentFormFields'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
student: Student
|
||||||
|
onUpdated: (student: Student) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StudentSettingsPanel({ student, onUpdated }: Props) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [form] = Form.useForm<StudentFormValues>()
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [avatarLoading, setAvatarLoading] = useState(false)
|
||||||
|
const [current, setCurrent] = useState(student)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrent(student)
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: student.name,
|
||||||
|
school_name: student.school_name || undefined,
|
||||||
|
school_level: student.school_level,
|
||||||
|
grade: student.grade || undefined,
|
||||||
|
class_name: student.class_name || undefined,
|
||||||
|
})
|
||||||
|
}, [student, form])
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const { data } = await studentApi.update(student.id, values)
|
||||||
|
setCurrent(data)
|
||||||
|
onUpdated(data)
|
||||||
|
message.success('学生资料已保存')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAvatar = async (file: File) => {
|
||||||
|
setAvatarLoading(true)
|
||||||
|
try {
|
||||||
|
const { data } = await studentApi.uploadAvatar(student.id, file)
|
||||||
|
setCurrent(data)
|
||||||
|
onUpdated(data)
|
||||||
|
message.success('头像已更新')
|
||||||
|
} catch {
|
||||||
|
message.error('头像上传失败')
|
||||||
|
} finally {
|
||||||
|
setAvatarLoading(false)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveAvatar = async () => {
|
||||||
|
const { data } = await studentApi.removeAvatar(student.id)
|
||||||
|
setCurrent(data)
|
||||||
|
onUpdated(data)
|
||||||
|
message.success('头像已移除')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
await studentApi.remove(student.id)
|
||||||
|
message.success('学生已删除')
|
||||||
|
navigate('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" size="large" style={{ width: '100%', maxWidth: 520 }}>
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||||
|
在此修改学生基本信息。年级按学段选择(初一至初三 / 高一至高三),供 AI 与统计按课内要求使用。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
|
||||||
|
<Space align="center" size="middle">
|
||||||
|
<StudentAvatar student={current} size={72} />
|
||||||
|
<Space direction="vertical" size={8}>
|
||||||
|
<Upload beforeUpload={handleAvatar} showUploadList={false} accept="image/*">
|
||||||
|
<Button icon={<UploadOutlined />} loading={avatarLoading}>
|
||||||
|
上传头像
|
||||||
|
</Button>
|
||||||
|
</Upload>
|
||||||
|
{current.has_avatar && (
|
||||||
|
<Button size="small" danger onClick={handleRemoveAvatar}>
|
||||||
|
移除头像
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<StudentFormFields form={form} />
|
||||||
|
<Space wrap>
|
||||||
|
<Button type="primary" loading={saving} onClick={handleSave}>
|
||||||
|
保存资料
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定删除该学生?"
|
||||||
|
description="将同时删除其成绩、错题、作文等全部数据,且不可恢复。"
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
>
|
||||||
|
<Button danger icon={<DeleteOutlined />}>
|
||||||
|
删除学生
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
</Form>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,20 +7,44 @@ export const SCHOOL_LEVEL_LABELS: Record<SchoolLevel, string> = {
|
|||||||
senior_high: '高中',
|
senior_high: '高中',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GRADE_OPTIONS: Record<SchoolLevel, string[]> = {
|
export const GRADE_OPTIONS: Record<SchoolLevel, { value: string; label: string }[]> = {
|
||||||
junior_high: ['初一', '初二', '初三'],
|
junior_high: [
|
||||||
senior_high: ['高一', '高二', '高三'],
|
{ value: '初一', label: '初一(七年级)' },
|
||||||
|
{ value: '初二', label: '初二(八年级)' },
|
||||||
|
{ value: '初三', label: '初三(九年级)' },
|
||||||
|
],
|
||||||
|
senior_high: [
|
||||||
|
{ value: '高一', label: '高一(十年级)' },
|
||||||
|
{ value: '高二', label: '高二(十一年级)' },
|
||||||
|
{ value: '高三', label: '高三(十二年级)' },
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatStudentMeta(student: {
|
export function formatStudentMeta(
|
||||||
|
student: {
|
||||||
school_level: SchoolLevel
|
school_level: SchoolLevel
|
||||||
|
school_name?: string | null
|
||||||
|
grade?: string | null
|
||||||
|
class_name?: string | null
|
||||||
|
},
|
||||||
|
options?: { includeLevel?: boolean },
|
||||||
|
): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (student.school_name?.trim()) parts.push(student.school_name.trim())
|
||||||
|
if (options?.includeLevel !== false) parts.push(SCHOOL_LEVEL_LABELS[student.school_level])
|
||||||
|
if (student.grade) parts.push(student.grade)
|
||||||
|
if (student.class_name) {
|
||||||
|
const cls = student.class_name.trim()
|
||||||
|
parts.push(cls.endsWith('班') ? cls : `${cls}班`)
|
||||||
|
}
|
||||||
|
return parts.length ? parts.join(' · ') : '未设置年级信息'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatStudentSubtitle(student: {
|
||||||
|
school_level: SchoolLevel
|
||||||
|
school_name?: string | null
|
||||||
grade?: string | null
|
grade?: string | null
|
||||||
class_name?: string | null
|
class_name?: string | null
|
||||||
}): string {
|
}): string {
|
||||||
const parts = [
|
return formatStudentMeta(student, { includeLevel: false })
|
||||||
SCHOOL_LEVEL_LABELS[student.school_level],
|
|
||||||
student.grade,
|
|
||||||
student.class_name,
|
|
||||||
].filter(Boolean)
|
|
||||||
return parts.length ? parts.join(' · ') : '未设置学段年级'
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { LockOutlined, SettingOutlined, UserOutlined } from '@ant-design/icons'
|
import { DownloadOutlined, LockOutlined, SettingOutlined, UploadOutlined, UserOutlined } from '@ant-design/icons'
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Form,
|
Form,
|
||||||
@@ -12,13 +13,20 @@ import {
|
|||||||
Table,
|
Table,
|
||||||
Tabs,
|
Tabs,
|
||||||
Typography,
|
Typography,
|
||||||
|
Upload,
|
||||||
message,
|
message,
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link, Navigate } from 'react-router-dom'
|
import { Link, Navigate } from 'react-router-dom'
|
||||||
import { adminApi } from '../api/client'
|
import { adminApi } from '../api/client'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import type { AdminUser, AIProvider, SystemSettings } from '../types'
|
import type { AdminUser, AIProvider, BackupInfo, SystemSettings } from '../types'
|
||||||
|
|
||||||
|
function formatBytes(size: number): string {
|
||||||
|
if (size < 1024) return `${size} B`
|
||||||
|
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
|
||||||
|
return `${(size / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
@@ -31,6 +39,10 @@ export default function SettingsPage() {
|
|||||||
const [resetUser, setResetUser] = useState<AdminUser | null>(null)
|
const [resetUser, setResetUser] = useState<AdminUser | null>(null)
|
||||||
const [resetForm] = Form.useForm()
|
const [resetForm] = Form.useForm()
|
||||||
const [aiForm] = Form.useForm()
|
const [aiForm] = Form.useForm()
|
||||||
|
const [backups, setBackups] = useState<BackupInfo[]>([])
|
||||||
|
const [backupLoading, setBackupLoading] = useState(false)
|
||||||
|
const [runningBackup, setRunningBackup] = useState(false)
|
||||||
|
const [restoring, setRestoring] = useState(false)
|
||||||
const aiProvider = Form.useWatch('ai_provider', aiForm) as AIProvider | undefined
|
const aiProvider = Form.useWatch('ai_provider', aiForm) as AIProvider | undefined
|
||||||
|
|
||||||
if (!user?.is_superuser) return <Navigate to="/" replace />
|
if (!user?.is_superuser) return <Navigate to="/" replace />
|
||||||
@@ -38,12 +50,14 @@ export default function SettingsPage() {
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const [settingsRes, usersRes] = await Promise.all([
|
const [settingsRes, usersRes, backupsRes] = await Promise.all([
|
||||||
adminApi.getSettings(),
|
adminApi.getSettings(),
|
||||||
adminApi.listUsers(),
|
adminApi.listUsers(),
|
||||||
|
adminApi.listBackups().catch(() => ({ data: [] as BackupInfo[] })),
|
||||||
])
|
])
|
||||||
setSettings(settingsRes.data)
|
setSettings(settingsRes.data)
|
||||||
setUsers(usersRes.data)
|
setUsers(usersRes.data)
|
||||||
|
setBackups(backupsRes.data)
|
||||||
profileForm.setFieldsValue({ username: user.username })
|
profileForm.setFieldsValue({ username: user.username })
|
||||||
aiForm.setFieldsValue({
|
aiForm.setFieldsValue({
|
||||||
ai_provider: settingsRes.data.ai_provider,
|
ai_provider: settingsRes.data.ai_provider,
|
||||||
@@ -141,6 +155,57 @@ export default function SettingsPage() {
|
|||||||
message.success('AI 模型配置已保存')
|
message.success('AI 模型配置已保存')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadBackups = async () => {
|
||||||
|
setBackupLoading(true)
|
||||||
|
try {
|
||||||
|
const { data } = await adminApi.listBackups()
|
||||||
|
setBackups(data)
|
||||||
|
} finally {
|
||||||
|
setBackupLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRunBackup = async () => {
|
||||||
|
setRunningBackup(true)
|
||||||
|
try {
|
||||||
|
await adminApi.runBackup()
|
||||||
|
message.success('备份已完成')
|
||||||
|
await loadBackups()
|
||||||
|
} catch {
|
||||||
|
message.error('备份失败,请检查服务器 pg_dump 与目录权限')
|
||||||
|
} finally {
|
||||||
|
setRunningBackup(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDownloadBackup = async (filename: string) => {
|
||||||
|
try {
|
||||||
|
const { data } = await adminApi.downloadBackup(filename)
|
||||||
|
const url = URL.createObjectURL(data)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
} catch {
|
||||||
|
message.error('下载失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestore = async (file: File) => {
|
||||||
|
setRestoring(true)
|
||||||
|
try {
|
||||||
|
const { data } = await adminApi.restoreBackup(file)
|
||||||
|
message.success(data.message || '数据已恢复')
|
||||||
|
await loadBackups()
|
||||||
|
} catch {
|
||||||
|
message.error('恢复失败,请确认备份包完整且未损坏')
|
||||||
|
} finally {
|
||||||
|
setRestoring(false)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||||
@@ -273,6 +338,71 @@ export default function SettingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'backup',
|
||||||
|
label: '数据备份',
|
||||||
|
children: (
|
||||||
|
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message="自动备份"
|
||||||
|
description="系统默认每 24 小时自动备份到 /root/grade-archive-backups,并保留最近 30 天。更换服务器时可下载备份包,在新服务器上传恢复。"
|
||||||
|
/>
|
||||||
|
<Card
|
||||||
|
title="备份管理"
|
||||||
|
extra={
|
||||||
|
<Button type="primary" loading={runningBackup} onClick={handleRunBackup}>
|
||||||
|
立即备份
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
rowKey="filename"
|
||||||
|
loading={backupLoading}
|
||||||
|
dataSource={backups}
|
||||||
|
pagination={{ pageSize: 8 }}
|
||||||
|
locale={{ emptyText: '暂无备份,请点击「立即备份」' }}
|
||||||
|
columns={[
|
||||||
|
{ title: '文件名', dataIndex: 'filename' },
|
||||||
|
{
|
||||||
|
title: '大小',
|
||||||
|
dataIndex: 'size_bytes',
|
||||||
|
render: (v: number) => formatBytes(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
render: (v: string) => new Date(v).toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
render: (_, record) => (
|
||||||
|
<Button
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
onClick={() => handleDownloadBackup(record.filename)}
|
||||||
|
>
|
||||||
|
下载
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
<Card title="数据恢复(迁移服务器)">
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
上传此前下载的 <Typography.Text code>grade-archive_*.tar.gz</Typography.Text>{' '}
|
||||||
|
备份包。恢复会覆盖当前数据库与 uploads 目录,操作前请先备份。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Upload beforeUpload={handleRestore} showUploadList={false} accept=".tar.gz,application/gzip">
|
||||||
|
<Button icon={<UploadOutlined />} loading={restoring} danger>
|
||||||
|
上传并恢复
|
||||||
|
</Button>
|
||||||
|
</Upload>
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'users',
|
key: 'users',
|
||||||
label: '用户管理',
|
label: '用户管理',
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import WrongQuestionList from '../components/WrongQuestionList'
|
|||||||
import WrongQuestionUpload, { WrongQuestionFilters } from '../components/WrongQuestionUpload'
|
import WrongQuestionUpload, { WrongQuestionFilters } from '../components/WrongQuestionUpload'
|
||||||
import ExamReviewPanel from '../components/ExamReviewPanel'
|
import ExamReviewPanel from '../components/ExamReviewPanel'
|
||||||
import CompositionPanel from '../components/CompositionPanel'
|
import CompositionPanel from '../components/CompositionPanel'
|
||||||
import { formatStudentMeta, SCHOOL_LEVEL_LABELS } from '../constants/school'
|
import StudentAvatar from '../components/StudentAvatar'
|
||||||
|
import StudentSettingsPanel from '../components/StudentSettingsPanel'
|
||||||
|
import { formatStudentSubtitle, SCHOOL_LEVEL_LABELS } from '../constants/school'
|
||||||
import type { Exam, Student, Subject, TrendResponse, WrongQuestion } from '../types'
|
import type { Exam, Student, Subject, TrendResponse, WrongQuestion } from '../types'
|
||||||
|
|
||||||
const TAB_KEYS = ['scores', 'overview', 'trend', 'review', 'composition', 'wrong', 'olympiad'] as const
|
const TAB_KEYS = ['scores', 'overview', 'trend', 'review', 'composition', 'wrong', 'olympiad', 'settings'] as const
|
||||||
type TabKey = (typeof TAB_KEYS)[number]
|
type TabKey = (typeof TAB_KEYS)[number]
|
||||||
|
|
||||||
export default function StudentDetailPage() {
|
export default function StudentDetailPage() {
|
||||||
@@ -164,11 +166,12 @@ export default function StudentDetailPage() {
|
|||||||
<Link to="/">
|
<Link to="/">
|
||||||
<Button icon={<ArrowLeftOutlined />}>返回</Button>
|
<Button icon={<ArrowLeftOutlined />}>返回</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
<StudentAvatar student={student} size={40} />
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
{student.name}
|
{student.name}
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
<Tag color={student.school_level === 'senior_high' ? 'purple' : 'blue'}>{stageLabel}</Tag>
|
<Tag color={student.school_level === 'senior_high' ? 'purple' : 'blue'}>{stageLabel}</Tag>
|
||||||
<Typography.Text type="secondary">{formatStudentMeta(student)}</Typography.Text>
|
<Typography.Text type="secondary">{formatStudentSubtitle(student)}</Typography.Text>
|
||||||
<Button icon={<DownloadOutlined />} onClick={handleExport}>
|
<Button icon={<DownloadOutlined />} onClick={handleExport}>
|
||||||
导出 CSV
|
导出 CSV
|
||||||
</Button>
|
</Button>
|
||||||
@@ -286,6 +289,16 @@ export default function StudentDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'settings',
|
||||||
|
label: '设置',
|
||||||
|
children: (
|
||||||
|
<StudentSettingsPanel
|
||||||
|
student={student}
|
||||||
|
onUpdated={(updated) => setStudent(updated)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import { LogoutOutlined, PlusOutlined, SettingOutlined, UserOutlined } from '@ant-design/icons'
|
import { DeleteOutlined, EditOutlined, LogoutOutlined, PlusOutlined, SettingOutlined } from '@ant-design/icons'
|
||||||
import { Button, Card, Col, Form, Input, Modal, Row, Select, Space, Spin, Tag, Typography, message } from 'antd'
|
import { Button, Card, Col, Form, Modal, Popconfirm, Row, Space, Spin, Tag, Typography, message } from 'antd'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { studentApi } from '../api/client'
|
import { studentApi } from '../api/client'
|
||||||
import { formatStudentMeta, GRADE_OPTIONS, SCHOOL_LEVEL_LABELS } from '../constants/school'
|
import StudentAvatar from '../components/StudentAvatar'
|
||||||
|
import StudentFormFields, { type StudentFormValues } from '../components/StudentFormFields'
|
||||||
|
import { formatStudentSubtitle, SCHOOL_LEVEL_LABELS } from '../constants/school'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import type { SchoolLevel, Student } from '../types'
|
import type { Student } from '../types'
|
||||||
|
|
||||||
export default function StudentsPage() {
|
export default function StudentsPage() {
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
const [students, setStudents] = useState<Student[]>([])
|
const [students, setStudents] = useState<Student[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [modalOpen, setModalOpen] = useState(false)
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
const [form] = Form.useForm()
|
const [editing, setEditing] = useState<Student | null>(null)
|
||||||
const schoolLevel = Form.useWatch('school_level', form) as SchoolLevel | undefined
|
const [form] = Form.useForm<StudentFormValues>()
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -30,19 +32,43 @@ export default function StudentsPage() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
form.setFieldsValue({ school_level: 'junior_high', grade: undefined })
|
setEditing(null)
|
||||||
|
form.setFieldsValue({ school_level: 'junior_high', grade: undefined, school_name: undefined })
|
||||||
setModalOpen(true)
|
setModalOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const openEdit = (student: Student) => {
|
||||||
|
setEditing(student)
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: student.name,
|
||||||
|
school_name: student.school_name || undefined,
|
||||||
|
school_level: student.school_level,
|
||||||
|
grade: student.grade || undefined,
|
||||||
|
class_name: student.class_name || undefined,
|
||||||
|
})
|
||||||
|
setModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
const values = await form.validateFields()
|
const values = await form.validateFields()
|
||||||
|
if (editing) {
|
||||||
|
await studentApi.update(editing.id, values)
|
||||||
|
message.success('学生资料已更新')
|
||||||
|
} else {
|
||||||
await studentApi.create(values)
|
await studentApi.create(values)
|
||||||
message.success('学生已添加')
|
message.success('学生已添加')
|
||||||
|
}
|
||||||
setModalOpen(false)
|
setModalOpen(false)
|
||||||
form.resetFields()
|
form.resetFields()
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (student: Student) => {
|
||||||
|
await studentApi.remove(student.id)
|
||||||
|
message.success('学生已删除')
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
<div
|
<div
|
||||||
@@ -80,12 +106,34 @@ export default function StudentsPage() {
|
|||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
{students.map((s) => (
|
{students.map((s) => (
|
||||||
<Col xs={24} sm={12} md={8} key={s.id}>
|
<Col xs={24} sm={12} md={8} key={s.id}>
|
||||||
<Link to={`/students/${s.id}`} style={{ textDecoration: 'none' }}>
|
<Card
|
||||||
<Card hoverable>
|
hoverable
|
||||||
|
actions={[
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => openEdit(s)}
|
||||||
|
key="edit"
|
||||||
|
>
|
||||||
|
修改
|
||||||
|
</Button>,
|
||||||
|
<Popconfirm
|
||||||
|
key="delete"
|
||||||
|
title="确定删除该学生?"
|
||||||
|
description="将删除其全部成绩与错题数据"
|
||||||
|
onConfirm={() => handleDelete(s)}
|
||||||
|
>
|
||||||
|
<Button type="link" danger icon={<DeleteOutlined />}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Link to={`/students/${s.id}`} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||||
<Space align="start">
|
<Space align="start">
|
||||||
<UserOutlined style={{ fontSize: 24, color: '#1677ff' }} />
|
<StudentAvatar student={s} size={48} />
|
||||||
<div>
|
<div>
|
||||||
<Space size={4}>
|
<Space size={4} wrap>
|
||||||
<Typography.Text strong>{s.name}</Typography.Text>
|
<Typography.Text strong>{s.name}</Typography.Text>
|
||||||
<Tag color={s.school_level === 'senior_high' ? 'purple' : 'blue'}>
|
<Tag color={s.school_level === 'senior_high' ? 'purple' : 'blue'}>
|
||||||
{SCHOOL_LEVEL_LABELS[s.school_level]}
|
{SCHOOL_LEVEL_LABELS[s.school_level]}
|
||||||
@@ -93,12 +141,12 @@ export default function StudentsPage() {
|
|||||||
</Space>
|
</Space>
|
||||||
<br />
|
<br />
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
{formatStudentMeta(s)}
|
{formatStudentSubtitle(s)}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
|
||||||
</Link>
|
</Link>
|
||||||
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
))}
|
))}
|
||||||
{!loading && students.length === 0 && (
|
{!loading && students.length === 0 && (
|
||||||
@@ -112,38 +160,14 @@ export default function StudentsPage() {
|
|||||||
</Spin>
|
</Spin>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title="添加学生"
|
title={editing ? '修改学生' : '添加学生'}
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
onCancel={() => setModalOpen(false)}
|
onCancel={() => setModalOpen(false)}
|
||||||
onOk={handleCreate}
|
onOk={handleSubmit}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" initialValues={{ school_level: 'junior_high' }}>
|
<Form form={form} layout="vertical" initialValues={{ school_level: 'junior_high' }}>
|
||||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
|
<StudentFormFields form={form} />
|
||||||
<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>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ export interface Student {
|
|||||||
school_level: SchoolLevel
|
school_level: SchoolLevel
|
||||||
grade: string | null
|
grade: string | null
|
||||||
class_name: string | null
|
class_name: string | null
|
||||||
|
school_name: string | null
|
||||||
|
has_avatar: boolean
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupInfo {
|
||||||
|
filename: string
|
||||||
|
size_bytes: number
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user