学生资料设置、头像与自动备份恢复。

首页卡片支持修改/删除;详情页设置 Tab 可维护学校、年级与头像;系统设置新增数据备份下载与恢复;备份默认存 /root/grade-archive-backups,详见 docs/BACKUP.md。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-28 17:56:09 +08:00
parent 1cb3c7fad5
commit 530a8b70a1
25 changed files with 1230 additions and 194 deletions
+3
View File
@@ -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
+3
View File
@@ -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
View File
@@ -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")
+2
View File
@@ -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)
) )
+80
View File
@@ -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": "数据已恢复,建议重启服务以确保缓存刷新"}
+66 -5
View File
@@ -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")
+23
View File
@@ -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
+166
View File
@@ -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)
+11
View File
@@ -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}"))
+32
View File
@@ -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
View File
@@ -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
View File
@@ -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 Workerscreen 常驻) # OCR 同机 GPU Workerscreen 常驻)
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
+56
View File
@@ -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
View File
@@ -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 或首页卡片 **修改** 中维护;首页卡片支持 **删除**
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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>
+27 -1
View File
@@ -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 = {
+48
View File
@@ -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>
)
}
+34 -10
View File
@@ -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(' · ') : '未设置学段年级'
} }
+133 -3
View File
@@ -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: '用户管理',
+16 -3
View File
@@ -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>
+65 -41
View File
@@ -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>
+8
View File
@@ -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
} }