Compare commits
33 Commits
8652476abc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 530a8b70a1 | |||
| 1cb3c7fad5 | |||
| aaa08cdf38 | |||
| 02e7ba055a | |||
| 5f00f07dbe | |||
| b4df6e5e18 | |||
| 4b55eb54b0 | |||
| f7a761da33 | |||
| bec9df5d6f | |||
| ff4e0b1d37 | |||
| acfe002fbf | |||
| 23be608521 | |||
| e5ff76c20b | |||
| 4de460c235 | |||
| 035b65dcc8 | |||
| 0d4861fa62 | |||
| edd3e80ef1 | |||
| 04f1381a2d | |||
| 357f61c57c | |||
| 9713c640b4 | |||
| ff0c103dc5 | |||
| 14bf314544 | |||
| 6200dbb596 | |||
| c42cd0b46d | |||
| 1c50ebc0ec | |||
| a145f38606 | |||
| a2a6d59f7c | |||
| c30e21b51e | |||
| 43483bf56f | |||
| 4375ea491e | |||
| f1ad4273f4 | |||
| a3d4875bde | |||
| 2a5fb5f469 |
+10
-2
@@ -2,8 +2,7 @@
|
||||
# 部署目录默认:/opt/secondary-school-grade-archive
|
||||
|
||||
WEB_PORT=23566
|
||||
API_PORT=8000
|
||||
API_TARGET=http://127.0.0.1:8000
|
||||
FRONTEND_DIST=/opt/secondary-school-grade-archive/frontend/dist
|
||||
|
||||
SECRET_KEY=请替换为随机字符串
|
||||
POSTGRES_USER=gradeapp
|
||||
@@ -17,4 +16,13 @@ CORS_ORIGINS=http://127.0.0.1:23566,http://localhost:23566
|
||||
|
||||
OLLAMA_BASE_URL=http://127.0.0.1:11434
|
||||
OLLAMA_MODEL=qwen2.5:7b
|
||||
# 局域网 GPU OCR 服务(deploy/ocr-worker),留空则本机 CPU 识别
|
||||
OCR_SERVICE_URL=http://127.0.0.1:23567
|
||||
OCR_PORT=23567
|
||||
OCR_API_KEY=
|
||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
OPENAI_MODEL=gpt-4o-mini
|
||||
FLUCTUATION_THRESHOLD=0.08
|
||||
|
||||
ADMIN_DEFAULT_USERNAME=admin
|
||||
ADMIN_DEFAULT_PASSWORD=admin123
|
||||
|
||||
+2
-1
@@ -5,4 +5,5 @@ __pycache__/
|
||||
.env
|
||||
!.env.example
|
||||
node_modules/
|
||||
dist/
|
||||
frontend/node_modules/
|
||||
# frontend/dist 随仓库发布,服务器无需 npm 构建
|
||||
|
||||
@@ -14,12 +14,74 @@ Secondary School Grade Archive — 多用户 Web 系统:成绩录入、占比
|
||||
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| [docs/DEPLOY.md](./docs/DEPLOY.md) | **Ubuntu PM2 一键部署** |
|
||||
| [docs/DEPLOY.md](./docs/DEPLOY.md) | **Ubuntu 零 Node 部署**(systemd + FastAPI 单进程) |
|
||||
| [docs/USAGE.md](./docs/USAGE.md) | 用户使用说明 |
|
||||
|
||||
---
|
||||
|
||||
## Ubuntu 一键部署(PM2)
|
||||
## 修改代码后如何发布(必读)
|
||||
|
||||
**生产服务器不安装 Node.js,也不在服务器上执行 `npm build`。**
|
||||
凡涉及前端或全栈改动的发布,均按以下流程在**开发机**完成构建后再推送:
|
||||
|
||||
```
|
||||
开发机改代码 → 本地构建 frontend/dist → git push 远端仓库 → 服务器 git pull + update.sh
|
||||
```
|
||||
|
||||
### 仅改后端(`backend/`)
|
||||
|
||||
```bash
|
||||
git add backend/
|
||||
git commit -m "你的说明"
|
||||
git push
|
||||
```
|
||||
|
||||
服务器(需代理时先 `export http_proxy=http://192.168.8.246:10810` 等,见 [DEPLOY.md §3](./docs/DEPLOY.md#3-一键部署新服务器)):
|
||||
|
||||
```bash
|
||||
bash /opt/secondary-school-grade-archive/deploy/update.sh
|
||||
```
|
||||
|
||||
### 改前端或同时改前后端(`frontend/`)
|
||||
|
||||
**必须先本地构建,再把 `frontend/dist` 一并提交推送:**
|
||||
|
||||
```powershell
|
||||
# Windows
|
||||
.\deploy\build-frontend.ps1
|
||||
```
|
||||
|
||||
```bash
|
||||
# Linux / macOS
|
||||
bash deploy/build-frontend.sh
|
||||
```
|
||||
|
||||
```bash
|
||||
git add frontend/ frontend/dist
|
||||
git commit -m "你的说明"
|
||||
git push
|
||||
```
|
||||
|
||||
服务器(需代理时先 `export http_proxy=http://192.168.8.246:10810` 等,见 [DEPLOY.md §3](./docs/DEPLOY.md#3-一键部署新服务器)):
|
||||
|
||||
```bash
|
||||
bash /opt/secondary-school-grade-archive/deploy/update.sh
|
||||
```
|
||||
|
||||
> 若只推送源码而未推送 `frontend/dist`,服务器更新后页面不会变化。详见 [docs/DEPLOY.md §2](./docs/DEPLOY.md#2-代码修改与发布流程重要)。
|
||||
|
||||
---
|
||||
|
||||
## Ubuntu 一键部署(零 Node)
|
||||
|
||||
若服务器访问外网需走代理,**先设置代理再执行安装**:
|
||||
|
||||
```bash
|
||||
export http_proxy=http://192.168.8.246:10810
|
||||
export https_proxy=http://192.168.8.246:10810
|
||||
export HTTP_PROXY="$http_proxy"
|
||||
export HTTPS_PROXY="$https_proxy"
|
||||
```
|
||||
|
||||
```bash
|
||||
git clone https://git.bz121.com/dekun/secondary-school-grade-archive.git /opt/secondary-school-grade-archive
|
||||
@@ -28,49 +90,57 @@ chmod +x deploy/*.sh
|
||||
bash deploy/install.sh
|
||||
```
|
||||
|
||||
> `install.sh` 会自动将代理用于 `apt`、`git`、`pip`、`curl`。无需代理时可省略 `export` 步骤。
|
||||
|
||||
- 安装目录:`/opt/secondary-school-grade-archive`
|
||||
- 访问地址:`http://<服务器IP>:23566`
|
||||
- 进程管理:`pm2 status` / `pm2 logs`
|
||||
- 进程管理:`systemctl status grade-archive` / `journalctl -u grade-archive -f`
|
||||
- 默认超级管理员:**admin / admin123**(登录后请在「系统设置」中修改)
|
||||
|
||||
详见 [docs/DEPLOY.md](./docs/DEPLOY.md)。**反向代理不包含在本项目中。**
|
||||
**前提:** 仓库中已包含 `frontend/dist/`(由开发机构建后推送)。详见 [docs/DEPLOY.md](./docs/DEPLOY.md)。**反向代理不包含在本项目中。**
|
||||
|
||||
---
|
||||
|
||||
## 本地开发
|
||||
|
||||
### 方式一:前后端分离(推荐日常开发)
|
||||
|
||||
```bash
|
||||
# PostgreSQL 本地安装后
|
||||
cp backend/.env.example backend/.env
|
||||
cp .env.example .env
|
||||
|
||||
cd backend
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload --port 8000
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 23566
|
||||
|
||||
cd frontend
|
||||
npm install && npm run dev
|
||||
```
|
||||
|
||||
开发前端:http://localhost:5173(代理 `/api` 到 8000)
|
||||
- 前端开发:http://localhost:5173(Vite 代理 `/api` → 23566)
|
||||
- 后端 API:http://localhost:23566/api/health
|
||||
|
||||
生产网关本地模拟:
|
||||
### 方式二:模拟生产(单进程 + 静态 dist)
|
||||
|
||||
```bash
|
||||
cd deploy/pm2 && npm install
|
||||
# 先 build 前端
|
||||
cd ../../frontend && npm run build
|
||||
WEB_PORT=23566 pm2 start ../deploy/pm2/ecosystem.config.cjs
|
||||
cd frontend && npm run build
|
||||
cd ../backend
|
||||
source venv/bin/activate
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 23566
|
||||
```
|
||||
|
||||
访问 http://localhost:23566
|
||||
|
||||
---
|
||||
|
||||
## 运维
|
||||
|
||||
```bash
|
||||
bash deploy/update.sh # 更新
|
||||
bash deploy/update.sh # 拉代码 + 更新依赖 + 重启服务
|
||||
bash deploy/backup.sh # 备份
|
||||
bash deploy/uninstall.sh # 停止
|
||||
bash deploy/uninstall.sh # 停止并卸载
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -2,7 +2,12 @@ DATABASE_URL=postgresql://gradeapp:postgres@127.0.0.1:5432/student_archive
|
||||
SECRET_KEY=dev-secret-key-change-in-production
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:23566
|
||||
UPLOAD_DIR=uploads
|
||||
API_PORT=8000
|
||||
BACKUP_DIR=/root/grade-archive-backups
|
||||
BACKUP_RETENTION_DAYS=30
|
||||
AUTO_BACKUP_INTERVAL_HOURS=24
|
||||
API_PORT=23568
|
||||
OLLAMA_BASE_URL=http://127.0.0.1:11434
|
||||
OLLAMA_MODEL=qwen2.5:7b
|
||||
OCR_SERVICE_URL=
|
||||
OCR_API_KEY=
|
||||
FLUCTUATION_THRESHOLD=0.08
|
||||
|
||||
@@ -8,11 +8,31 @@ class Settings(BaseSettings):
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
ALGORITHM: str = "HS256"
|
||||
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
|
||||
OLLAMA_BASE_URL: str = "http://127.0.0.1:11434"
|
||||
OLLAMA_MODEL: str = "qwen2.5:7b"
|
||||
OPENAI_BASE_URL: str = "https://api.openai.com/v1"
|
||||
OPENAI_MODEL: str = "gpt-4o-mini"
|
||||
FLUCTUATION_THRESHOLD: float = 0.08
|
||||
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000,http://localhost"
|
||||
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:23566,http://localhost"
|
||||
WEB_PORT: int = 23566
|
||||
FRONTEND_DIST: str = "../frontend/dist"
|
||||
ADMIN_DEFAULT_USERNAME: str = "admin"
|
||||
ADMIN_DEFAULT_PASSWORD: str = "admin123"
|
||||
OCR_TIMEOUT_SECONDS: int = 180
|
||||
AI_TIMEOUT_SECONDS: int = 600
|
||||
PROCESS_STALE_MINUTES: int = 20
|
||||
OCR_MAX_SIDE: int = 1280
|
||||
UPLOAD_MAX_SIDE: int = 2048
|
||||
OCR_WARMUP: bool = True
|
||||
OCR_SERVICE_URL: str = "http://127.0.0.1:23567"
|
||||
OCR_API_KEY: str = ""
|
||||
OCR_USE_GPU: bool = False
|
||||
OCR_FILTER_HANDWRITING: bool = True
|
||||
OCR_ANSWER_ZONE_RATIO: float = 0.45
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
@@ -32,3 +32,9 @@ def get_current_user(
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
|
||||
return user
|
||||
|
||||
|
||||
def get_superuser(current_user: User = Depends(get_current_user)) -> User:
|
||||
if not current_user.is_superuser:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要超级管理员权限")
|
||||
return current_user
|
||||
|
||||
+66
-3
@@ -1,26 +1,59 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import Base, SessionLocal, engine
|
||||
from app.routers import auth, exams, export, students, subjects, wrong_questions
|
||||
from app.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.migrate import run_migrations
|
||||
from app.services.seed import seed_subjects
|
||||
from app.services.seed import seed_admin_and_settings, seed_subjects
|
||||
|
||||
|
||||
def resolve_frontend_dist() -> Path | None:
|
||||
backend_dir = Path(__file__).resolve().parent.parent
|
||||
dist = Path(settings.FRONTEND_DIST)
|
||||
if not dist.is_absolute():
|
||||
dist = (backend_dir / dist).resolve()
|
||||
if dist.is_dir() and (dist / "index.html").is_file():
|
||||
return dist
|
||||
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
|
||||
async def lifespan(app: FastAPI):
|
||||
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)
|
||||
run_migrations()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
seed_subjects(db)
|
||||
seed_admin_and_settings(db)
|
||||
finally:
|
||||
db.close()
|
||||
ocr_service.warmup_ocr_engine()
|
||||
if settings.AUTO_BACKUP_INTERVAL_HOURS > 0:
|
||||
threading.Thread(target=_auto_backup_loop, daemon=True).start()
|
||||
yield
|
||||
|
||||
|
||||
@@ -36,13 +69,43 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
app.include_router(auth.router, prefix="/api")
|
||||
app.include_router(settings_router.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(subjects.router, prefix="/api")
|
||||
app.include_router(exams.router, prefix="/api")
|
||||
app.include_router(wrong_questions.router, prefix="/api")
|
||||
app.include_router(compositions.router, prefix="/api")
|
||||
app.include_router(export.router, prefix="/api")
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
dist_dir = resolve_frontend_dist()
|
||||
if dist_dir is not None:
|
||||
assets_dir = dist_dir / "assets"
|
||||
if assets_dir.is_dir():
|
||||
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
|
||||
|
||||
@app.api_route(
|
||||
"/api/{rest:path}",
|
||||
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def api_not_found(rest: str):
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
|
||||
@app.get("/{full_path:path}", include_in_schema=False)
|
||||
async def serve_spa(full_path: str):
|
||||
if full_path.startswith("api/") or full_path == "api":
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
if full_path in ("", "index.html"):
|
||||
return FileResponse(dist_dir / "index.html")
|
||||
candidate = dist_dir / full_path
|
||||
if candidate.is_file():
|
||||
return FileResponse(candidate)
|
||||
return FileResponse(dist_dir / "index.html")
|
||||
|
||||
@@ -22,6 +22,28 @@ class WrongQuestionStatus(str, enum.Enum):
|
||||
failed = "failed"
|
||||
|
||||
|
||||
class WrongQuestionCategory(str, enum.Enum):
|
||||
regular = "regular"
|
||||
olympiad = "olympiad"
|
||||
|
||||
|
||||
class CompositionStatus(str, enum.Enum):
|
||||
pending = "pending"
|
||||
generating = "generating"
|
||||
done = "done"
|
||||
failed = "failed"
|
||||
|
||||
|
||||
class CompositionInputMode(str, enum.Enum):
|
||||
manual = "manual"
|
||||
ocr = "ocr"
|
||||
|
||||
|
||||
class AIProvider(str, enum.Enum):
|
||||
ollama = "ollama"
|
||||
openai = "openai"
|
||||
|
||||
|
||||
class SchoolLevel(str, enum.Enum):
|
||||
junior_high = "junior_high"
|
||||
senior_high = "senior_high"
|
||||
@@ -33,6 +55,7 @@ class User(Base):
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
is_superuser: Mapped[bool] = mapped_column(default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
@@ -51,6 +74,8 @@ class Student(Base):
|
||||
)
|
||||
grade: 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(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
@@ -62,6 +87,9 @@ class Student(Base):
|
||||
wrong_questions: Mapped[list["WrongQuestion"]] = relationship(
|
||||
back_populates="student", cascade="all, delete-orphan"
|
||||
)
|
||||
compositions: Mapped[list["Composition"]] = relationship(
|
||||
back_populates="student", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class Subject(Base):
|
||||
@@ -104,6 +132,7 @@ class SubjectScore(Base):
|
||||
total_score: Mapped[float] = mapped_column(Numeric(8, 2))
|
||||
obtained_score: Mapped[float] = mapped_column(Numeric(8, 2))
|
||||
ratio: Mapped[float] = mapped_column(Numeric(8, 4))
|
||||
review_statuses_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
exam_record: Mapped["ExamRecord"] = relationship(back_populates="scores")
|
||||
subject: Mapped["Subject"] = relationship(back_populates="scores")
|
||||
@@ -118,13 +147,64 @@ class WrongQuestion(Base):
|
||||
image_path: Mapped[str] = mapped_column(String(512))
|
||||
ocr_raw_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
question_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
solution_approach: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
solution_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
mark_regions_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
annotated_image_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
cropped_image_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[WrongQuestionStatus] = mapped_column(
|
||||
Enum(WrongQuestionStatus), default=WrongQuestionStatus.pending
|
||||
)
|
||||
category: Mapped[WrongQuestionCategory] = mapped_column(
|
||||
Enum(WrongQuestionCategory), default=WrongQuestionCategory.regular
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
student: Mapped["Student"] = relationship(back_populates="wrong_questions")
|
||||
subject: Mapped["Subject"] = relationship(back_populates="wrong_questions")
|
||||
|
||||
|
||||
class Composition(Base):
|
||||
__tablename__ = "compositions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
student_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("students.id"), index=True)
|
||||
topic: Mapped[str] = mapped_column(Text)
|
||||
input_mode: Mapped[CompositionInputMode] = mapped_column(
|
||||
Enum(CompositionInputMode), default=CompositionInputMode.manual
|
||||
)
|
||||
writing_plan: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
sample_essay: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[CompositionStatus] = mapped_column(
|
||||
Enum(CompositionStatus), default=CompositionStatus.pending
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
student: Mapped["Student"] = relationship(back_populates="compositions")
|
||||
|
||||
|
||||
class SystemSettings(Base):
|
||||
__tablename__ = "system_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
||||
registration_enabled: Mapped[bool] = mapped_column(default=True)
|
||||
ai_review_enabled: Mapped[bool] = mapped_column(default=True)
|
||||
ai_provider: Mapped[str] = mapped_column(String(16), default="ollama")
|
||||
ollama_base_url: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
ollama_model: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
openai_base_url: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
openai_model: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
openai_api_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
ocr_service_url: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_superuser
|
||||
from app.core.security import get_password_hash, verify_password
|
||||
from app.models.user import SystemSettings, User
|
||||
from app.schemas import (
|
||||
AdminProfileUpdate,
|
||||
AdminUserCreate,
|
||||
AdminUserOut,
|
||||
AdminUserPasswordUpdate,
|
||||
AIProviderEnum,
|
||||
PublicSettingsOut,
|
||||
SystemSettingsOut,
|
||||
SystemSettingsUpdate,
|
||||
)
|
||||
from app.services.url_sanitize import sanitize_http_url, sanitize_model_name
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
|
||||
def settings_to_out(row: SystemSettings) -> SystemSettingsOut:
|
||||
return SystemSettingsOut(
|
||||
registration_enabled=row.registration_enabled,
|
||||
ai_review_enabled=getattr(row, "ai_review_enabled", True),
|
||||
ai_provider=AIProviderEnum(row.ai_provider or "ollama"),
|
||||
ollama_base_url=row.ollama_base_url,
|
||||
ollama_model=row.ollama_model,
|
||||
openai_base_url=row.openai_base_url,
|
||||
openai_model=row.openai_model,
|
||||
openai_api_key_set=bool(row.openai_api_key),
|
||||
ocr_service_url=row.ocr_service_url,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def get_or_create_settings(db: Session) -> SystemSettings:
|
||||
row = db.get(SystemSettings, 1)
|
||||
if row is None:
|
||||
row = SystemSettings(id=1, registration_enabled=True)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
@router.get("/settings", response_model=SystemSettingsOut)
|
||||
def get_settings(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_superuser),
|
||||
):
|
||||
return settings_to_out(get_or_create_settings(db))
|
||||
|
||||
|
||||
@router.patch("/settings", response_model=SystemSettingsOut)
|
||||
def update_settings(
|
||||
data: SystemSettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_superuser),
|
||||
):
|
||||
row = get_or_create_settings(db)
|
||||
if data.registration_enabled is not None:
|
||||
row.registration_enabled = data.registration_enabled
|
||||
if data.ai_review_enabled is not None:
|
||||
row.ai_review_enabled = data.ai_review_enabled
|
||||
if data.ai_provider is not None:
|
||||
row.ai_provider = data.ai_provider.value
|
||||
if data.ollama_base_url is not None:
|
||||
row.ollama_base_url = sanitize_http_url(data.ollama_base_url) or None
|
||||
if data.ollama_model is not None:
|
||||
row.ollama_model = sanitize_model_name(data.ollama_model) or None
|
||||
if data.openai_base_url is not None:
|
||||
row.openai_base_url = sanitize_http_url(data.openai_base_url) or None
|
||||
if data.openai_model is not None:
|
||||
row.openai_model = sanitize_model_name(data.openai_model) or None
|
||||
if data.openai_api_key is not None and data.openai_api_key.strip():
|
||||
row.openai_api_key = data.openai_api_key.strip()
|
||||
if data.ocr_service_url is not None:
|
||||
row.ocr_service_url = data.ocr_service_url.strip() or None
|
||||
row.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return settings_to_out(row)
|
||||
|
||||
|
||||
@router.patch("/profile", response_model=AdminUserOut)
|
||||
def update_profile(
|
||||
data: AdminProfileUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_superuser),
|
||||
):
|
||||
if not data.username and not data.password:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请填写要修改的内容")
|
||||
|
||||
if data.password:
|
||||
if not data.current_password:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="修改密码需提供当前密码")
|
||||
if not verify_password(data.current_password, current_user.password_hash):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前密码错误")
|
||||
|
||||
if data.username and data.username != current_user.username:
|
||||
if db.query(User).filter(User.username == data.username).first():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名已存在")
|
||||
current_user.username = data.username
|
||||
|
||||
if data.password:
|
||||
current_user.password_hash = get_password_hash(data.password)
|
||||
|
||||
db.commit()
|
||||
db.refresh(current_user)
|
||||
return current_user
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[AdminUserOut])
|
||||
def list_users(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_superuser),
|
||||
):
|
||||
return db.query(User).order_by(User.created_at).all()
|
||||
|
||||
|
||||
@router.post("/users", response_model=AdminUserOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_user(
|
||||
data: AdminUserCreate,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_superuser),
|
||||
):
|
||||
if db.query(User).filter(User.username == data.username).first():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名已存在")
|
||||
user = User(username=data.username, password_hash=get_password_hash(data.password))
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}", response_model=AdminUserOut)
|
||||
def reset_user_password(
|
||||
user_id: uuid.UUID,
|
||||
data: AdminUserPasswordUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_superuser),
|
||||
):
|
||||
user = db.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
if user.is_superuser and user.id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能修改其他超级管理员")
|
||||
user.password_hash = get_password_hash(data.password)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_user(
|
||||
user_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_superuser),
|
||||
):
|
||||
if user_id == current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能删除当前登录账号")
|
||||
user = db.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
if user.is_superuser:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能删除超级管理员")
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
@@ -13,14 +13,27 @@ from app.core.security import (
|
||||
get_password_hash,
|
||||
verify_password,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.models.user import SystemSettings, User
|
||||
from app.schemas import RefreshRequest, TokenResponse, UserLogin, UserOut, UserRegister
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
def get_or_create_settings(db: Session) -> SystemSettings:
|
||||
row = db.get(SystemSettings, 1)
|
||||
if row is None:
|
||||
row = SystemSettings(id=1, registration_enabled=True)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||
def register(data: UserRegister, db: Session = Depends(get_db)):
|
||||
settings_row = get_or_create_settings(db)
|
||||
if not settings_row.registration_enabled:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="注册已关闭,请联系管理员")
|
||||
if db.query(User).filter(User.username == data.username).first():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名已存在")
|
||||
user = User(username=data.username, password_hash=get_password_hash(data.password))
|
||||
|
||||
@@ -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": "数据已恢复,建议重启服务以确保缓存刷新"}
|
||||
@@ -0,0 +1,261 @@
|
||||
import re
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, UploadFile, status
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import SessionLocal, get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import Composition, CompositionInputMode, CompositionStatus, SystemSettings, User
|
||||
from app.schemas import CompositionCreate, CompositionInputModeEnum, CompositionOcrOut, CompositionOut
|
||||
from app.services import llm as llm_service
|
||||
from app.services import ocr as ocr_service
|
||||
from app.services.student_access import get_student_for_user
|
||||
|
||||
router = APIRouter(tags=["compositions"])
|
||||
|
||||
|
||||
def _ocr_service_url(db: Session) -> str | None:
|
||||
row = db.get(SystemSettings, 1)
|
||||
if row and row.ocr_service_url:
|
||||
return row.ocr_service_url.strip() or None
|
||||
return ocr_service.resolve_ocr_service_url()
|
||||
|
||||
|
||||
def _to_out(item: Composition) -> CompositionOut:
|
||||
return CompositionOut(
|
||||
id=item.id,
|
||||
student_id=item.student_id,
|
||||
topic=item.topic,
|
||||
input_mode=CompositionInputModeEnum(item.input_mode.value),
|
||||
writing_plan=item.writing_plan,
|
||||
sample_essay=item.sample_essay,
|
||||
error_message=item.error_message,
|
||||
status=item.status,
|
||||
created_at=item.created_at,
|
||||
updated_at=item.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _safe_filename(name: str) -> str:
|
||||
cleaned = re.sub(r'[\\/:*?"<>|]', "_", name).strip() or "composition"
|
||||
return cleaned[:40]
|
||||
|
||||
|
||||
async def _generate_composition(composition_id: uuid.UUID):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
item = (
|
||||
db.query(Composition)
|
||||
.options(joinedload(Composition.student))
|
||||
.filter(Composition.id == composition_id)
|
||||
.first()
|
||||
)
|
||||
if item is None:
|
||||
return
|
||||
student = item.student
|
||||
item.status = CompositionStatus.generating
|
||||
item.error_message = None
|
||||
db.commit()
|
||||
|
||||
ai_cfg = llm_service.load_ai_config(db)
|
||||
try:
|
||||
plan, essay = await llm_service.generate_composition(
|
||||
ai_cfg,
|
||||
item.topic,
|
||||
student.school_level if student else None,
|
||||
student.grade if student else None,
|
||||
)
|
||||
item.writing_plan = plan or None
|
||||
item.sample_essay = essay or None
|
||||
if not item.writing_plan and not item.sample_essay:
|
||||
raise ValueError("AI 未返回有效内容")
|
||||
item.status = CompositionStatus.done
|
||||
item.error_message = None
|
||||
except Exception as exc:
|
||||
item.status = CompositionStatus.failed
|
||||
item.error_message = str(exc)[:500]
|
||||
item.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/students/{student_id}/compositions", response_model=list[CompositionOut])
|
||||
def list_compositions(
|
||||
student_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
get_student_for_user(db, student_id, current_user.id)
|
||||
items = (
|
||||
db.query(Composition)
|
||||
.filter(Composition.student_id == student_id)
|
||||
.order_by(Composition.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return [_to_out(item) for item in items]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/students/{student_id}/compositions",
|
||||
response_model=CompositionOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_composition(
|
||||
student_id: uuid.UUID,
|
||||
data: CompositionCreate,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
get_student_for_user(db, student_id, current_user.id)
|
||||
item = Composition(
|
||||
student_id=student_id,
|
||||
topic=data.topic.strip(),
|
||||
input_mode=CompositionInputMode(data.input_mode.value),
|
||||
status=CompositionStatus.pending,
|
||||
)
|
||||
db.add(item)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
background_tasks.add_task(_generate_composition, item.id)
|
||||
return _to_out(item)
|
||||
|
||||
|
||||
@router.post("/students/{student_id}/compositions/ocr", response_model=CompositionOcrOut)
|
||||
async def ocr_composition_topic(
|
||||
student_id: uuid.UUID,
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
get_student_for_user(db, student_id, current_user.id)
|
||||
content = await file.read()
|
||||
if len(content) > settings.MAX_UPLOAD_SIZE:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文件超过10MB限制")
|
||||
|
||||
tmp_dir = Path(settings.UPLOAD_DIR) / "ocr-tmp"
|
||||
tmp_dir.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = tmp_dir / f"{uuid.uuid4()}.jpg"
|
||||
tmp_path.write_bytes(content)
|
||||
ocr_url = _ocr_service_url(db)
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
future = pool.submit(ocr_service.run_ocr_with_regions, str(tmp_path), ocr_url)
|
||||
result = future.result(timeout=settings.OCR_TIMEOUT_SECONDS)
|
||||
text = (result.get("text") or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="OCR 未识别到文字")
|
||||
return CompositionOcrOut(text=text)
|
||||
except FuturesTimeout:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
||||
detail=f"OCR 识别超时(>{settings.OCR_TIMEOUT_SECONDS}秒)",
|
||||
) from None
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"OCR 识别失败:{exc}",
|
||||
) from exc
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
@router.get("/compositions/{composition_id}", response_model=CompositionOut)
|
||||
def get_composition(
|
||||
composition_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
item = (
|
||||
db.query(Composition)
|
||||
.join(Composition.student)
|
||||
.filter(Composition.id == composition_id)
|
||||
.first()
|
||||
)
|
||||
if item is None or item.student.user_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="作文记录不存在")
|
||||
return _to_out(item)
|
||||
|
||||
|
||||
@router.post("/compositions/{composition_id}/regenerate", response_model=CompositionOut)
|
||||
async def regenerate_composition(
|
||||
composition_id: uuid.UUID,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
item = (
|
||||
db.query(Composition)
|
||||
.join(Composition.student)
|
||||
.filter(Composition.id == composition_id)
|
||||
.first()
|
||||
)
|
||||
if item is None or item.student.user_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="作文记录不存在")
|
||||
|
||||
item.status = CompositionStatus.pending
|
||||
item.error_message = None
|
||||
item.writing_plan = None
|
||||
item.sample_essay = None
|
||||
db.commit()
|
||||
background_tasks.add_task(_generate_composition, item.id)
|
||||
db.refresh(item)
|
||||
return _to_out(item)
|
||||
|
||||
|
||||
@router.get("/compositions/{composition_id}/download")
|
||||
def download_composition(
|
||||
composition_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
item = (
|
||||
db.query(Composition)
|
||||
.join(Composition.student)
|
||||
.filter(Composition.id == composition_id)
|
||||
.first()
|
||||
)
|
||||
if item is None or item.student.user_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="作文记录不存在")
|
||||
if item.status != CompositionStatus.done:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="作文尚未生成完成")
|
||||
|
||||
body = llm_service.composition_markdown(item.topic, item.writing_plan, item.sample_essay)
|
||||
filename = f"{_safe_filename(item.topic)}.md"
|
||||
from urllib.parse import quote
|
||||
|
||||
encoded = quote(filename)
|
||||
return Response(
|
||||
content=body.encode("utf-8"),
|
||||
media_type="text/markdown; charset=utf-8",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="composition.md"; filename*=UTF-8\'\'{encoded}'
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/compositions/{composition_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_composition(
|
||||
composition_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
item = (
|
||||
db.query(Composition)
|
||||
.join(Composition.student)
|
||||
.filter(Composition.id == composition_id)
|
||||
.first()
|
||||
)
|
||||
if item is None or item.student.user_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="作文记录不存在")
|
||||
db.delete(item)
|
||||
db.commit()
|
||||
+216
-10
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
@@ -5,13 +6,120 @@ from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import ExamRecord, SubjectScore, User
|
||||
from app.schemas import ExamCreate, ExamOut, ExamUpdate, ScoreOut, TrendResponse
|
||||
from app.models.user import ExamRecord, SubjectScore, SystemSettings, User
|
||||
from app.schemas import (
|
||||
ExamCreate,
|
||||
ExamOut,
|
||||
ExamReviewUpdate,
|
||||
ExamUpdate,
|
||||
ReviewInsightRequest,
|
||||
ReviewInsightResponse,
|
||||
ReviewStatusEnum,
|
||||
ScoreOut,
|
||||
TrendResponse,
|
||||
)
|
||||
from app.services import llm as llm_service
|
||||
from app.services.score_trend import build_trend
|
||||
from app.services.student_access import get_student_for_user
|
||||
|
||||
router = APIRouter(tags=["exams"])
|
||||
|
||||
_EXAM_TYPE_LABELS = {"weekly": "周考", "monthly": "月考", "final": "期末"}
|
||||
_REVIEW_STATUS_LABELS = {
|
||||
ReviewStatusEnum.careless: "粗心",
|
||||
ReviewStatusEnum.unknown: "不会",
|
||||
ReviewStatusEnum.nervous: "紧张",
|
||||
ReviewStatusEnum.normal: "正常发挥",
|
||||
}
|
||||
|
||||
|
||||
def _subject_display_name(score: SubjectScore) -> str:
|
||||
return score.subject.name if score.subject else f"科目{score.subject_id}"
|
||||
|
||||
|
||||
def _build_review_insight_context(exams: list[ExamRecord], subject_name: str) -> str:
|
||||
entries: list[tuple[ExamRecord, SubjectScore, list[ReviewStatusEnum]]] = []
|
||||
status_counts = {status: 0 for status in ReviewStatusEnum}
|
||||
|
||||
for exam in exams:
|
||||
for score in exam.scores:
|
||||
if _subject_display_name(score) != subject_name:
|
||||
continue
|
||||
statuses = _parse_review_statuses(score.review_statuses_json)
|
||||
if not statuses:
|
||||
continue
|
||||
entries.append((exam, score, statuses))
|
||||
for status in statuses:
|
||||
status_counts[status] += 1
|
||||
|
||||
if not entries:
|
||||
return ""
|
||||
|
||||
entries.sort(key=lambda item: (item[0].exam_date, item[0].created_at))
|
||||
total = len(entries)
|
||||
|
||||
summary_parts = [
|
||||
f"{_REVIEW_STATUS_LABELS[s]}{status_counts[s]}次"
|
||||
for s in ReviewStatusEnum
|
||||
if status_counts[s] > 0
|
||||
]
|
||||
lines = [
|
||||
f"科目:{subject_name}",
|
||||
f"共 {total} 次有复盘记录的考试",
|
||||
f"状态统计:{'、'.join(summary_parts)}",
|
||||
"",
|
||||
"【按考试日期从早到晚,请严格按此顺序解读,不得颠倒】",
|
||||
]
|
||||
|
||||
for index, (exam, score, statuses) in enumerate(entries, start=1):
|
||||
type_label = _EXAM_TYPE_LABELS.get(exam.exam_type.value, exam.exam_type.value)
|
||||
status_text = "、".join(_REVIEW_STATUS_LABELS.get(s, s.value) for s in statuses)
|
||||
obtained = float(score.obtained_score)
|
||||
total_score = float(score.total_score)
|
||||
ratio = float(score.ratio) * 100
|
||||
lost = total_score - obtained
|
||||
when = "(最近一次)" if index == total else ""
|
||||
line = (
|
||||
f"第{index}次{when} | 日期 {exam.exam_date} | {type_label} | "
|
||||
f"得分 {obtained:g}/{total_score:g}(得分率 {ratio:.1f}%,失分 {lost:g} 分)| "
|
||||
f"复盘状态:{status_text}"
|
||||
)
|
||||
if exam.title:
|
||||
line += f" | 备注:{exam.title}"
|
||||
lines.append(line)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _parse_review_statuses(raw: str | None) -> list[ReviewStatusEnum]:
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
result: list[ReviewStatusEnum] = []
|
||||
for item in data:
|
||||
try:
|
||||
result.append(ReviewStatusEnum(str(item)))
|
||||
except ValueError:
|
||||
continue
|
||||
return result
|
||||
|
||||
|
||||
def _serialize_review_statuses(statuses: list[ReviewStatusEnum] | None) -> str | None:
|
||||
if not statuses:
|
||||
return None
|
||||
values: list[str] = []
|
||||
for item in statuses:
|
||||
if isinstance(item, ReviewStatusEnum):
|
||||
values.append(item.value)
|
||||
else:
|
||||
values.append(str(item))
|
||||
return json.dumps(values, ensure_ascii=False)
|
||||
|
||||
|
||||
def _score_to_out(score: SubjectScore) -> ScoreOut:
|
||||
return ScoreOut(
|
||||
@@ -21,6 +129,7 @@ def _score_to_out(score: SubjectScore) -> ScoreOut:
|
||||
total_score=float(score.total_score),
|
||||
obtained_score=float(score.obtained_score),
|
||||
ratio=float(score.ratio),
|
||||
review_statuses=_parse_review_statuses(score.review_statuses_json),
|
||||
)
|
||||
|
||||
|
||||
@@ -36,17 +145,34 @@ def _exam_to_out(exam: ExamRecord) -> ExamOut:
|
||||
|
||||
|
||||
def _apply_scores(db: Session, exam: ExamRecord, scores_data):
|
||||
exam.scores.clear()
|
||||
existing_by_subject = {s.subject_id: s for s in list(exam.scores)}
|
||||
keep_subject_ids: set[int] = set()
|
||||
|
||||
for item in scores_data:
|
||||
keep_subject_ids.add(item.subject_id)
|
||||
ratio = round(item.obtained_score / item.total_score, 4)
|
||||
exam.scores.append(
|
||||
SubjectScore(
|
||||
subject_id=item.subject_id,
|
||||
total_score=item.total_score,
|
||||
obtained_score=item.obtained_score,
|
||||
ratio=ratio,
|
||||
review_json = _serialize_review_statuses(item.review_statuses)
|
||||
existing = existing_by_subject.get(item.subject_id)
|
||||
if existing is not None:
|
||||
existing.total_score = item.total_score
|
||||
existing.obtained_score = item.obtained_score
|
||||
existing.ratio = ratio
|
||||
existing.review_statuses_json = review_json
|
||||
else:
|
||||
exam.scores.append(
|
||||
SubjectScore(
|
||||
subject_id=item.subject_id,
|
||||
total_score=item.total_score,
|
||||
obtained_score=item.obtained_score,
|
||||
ratio=ratio,
|
||||
review_statuses_json=review_json,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
for subject_id, score in existing_by_subject.items():
|
||||
if subject_id not in keep_subject_ids:
|
||||
exam.scores.remove(score)
|
||||
db.delete(score)
|
||||
|
||||
|
||||
@router.get("/students/{student_id}/exams", response_model=list[ExamOut])
|
||||
@@ -150,6 +276,86 @@ def update_exam(
|
||||
return _exam_to_out(exam)
|
||||
|
||||
|
||||
@router.patch("/exams/{exam_id}/review", response_model=ExamOut)
|
||||
def update_exam_review(
|
||||
exam_id: uuid.UUID,
|
||||
data: ExamReviewUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
exam = (
|
||||
db.query(ExamRecord)
|
||||
.options(joinedload(ExamRecord.scores).joinedload(SubjectScore.subject))
|
||||
.join(ExamRecord.student)
|
||||
.filter(ExamRecord.id == exam_id)
|
||||
.first()
|
||||
)
|
||||
if exam is None or exam.student.user_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="考试记录不存在")
|
||||
|
||||
by_subject = {s.subject_id: s for s in exam.scores}
|
||||
for item in data.reviews:
|
||||
score = by_subject.get(item.subject_id)
|
||||
if score is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"科目 {item.subject_id} 不在该次考试中",
|
||||
)
|
||||
score.review_statuses_json = _serialize_review_statuses(item.review_statuses)
|
||||
|
||||
db.commit()
|
||||
db.refresh(exam)
|
||||
exam = (
|
||||
db.query(ExamRecord)
|
||||
.options(joinedload(ExamRecord.scores).joinedload(SubjectScore.subject))
|
||||
.filter(ExamRecord.id == exam.id)
|
||||
.first()
|
||||
)
|
||||
return _exam_to_out(exam)
|
||||
|
||||
|
||||
@router.post("/students/{student_id}/review-insight", response_model=ReviewInsightResponse)
|
||||
async def review_insight(
|
||||
student_id: uuid.UUID,
|
||||
data: ReviewInsightRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
student = get_student_for_user(db, student_id, current_user.id)
|
||||
settings_row = db.get(SystemSettings, 1)
|
||||
if settings_row is not None and not getattr(settings_row, "ai_review_enabled", True):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="AI 复盘解读已在系统设置中关闭")
|
||||
|
||||
exams = (
|
||||
db.query(ExamRecord)
|
||||
.options(joinedload(ExamRecord.scores).joinedload(SubjectScore.subject))
|
||||
.filter(ExamRecord.student_id == student_id)
|
||||
.all()
|
||||
)
|
||||
subject_name = data.subject_name.strip()
|
||||
records = _build_review_insight_context(exams, subject_name)
|
||||
if not records:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该科目暂无复盘数据",
|
||||
)
|
||||
|
||||
ai_cfg = llm_service.load_ai_config(db)
|
||||
try:
|
||||
insight = await llm_service.generate_review_insight(
|
||||
ai_cfg,
|
||||
subject_name,
|
||||
records,
|
||||
student.school_level,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"AI 调用失败: {exc}",
|
||||
) from exc
|
||||
return ReviewInsightResponse(insight=insight)
|
||||
|
||||
|
||||
@router.delete("/exams/{exam_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_exam(
|
||||
exam_id: uuid.UUID,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import csv
|
||||
import io
|
||||
import uuid
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.core.database import get_db
|
||||
@@ -34,10 +35,11 @@ def export_scores_csv(
|
||||
writer.writerow(["考试日期", "考试类型", "标题", "科目", "总分", "得分", "占比"])
|
||||
type_map = {"weekly": "周考", "monthly": "月考", "final": "期末"}
|
||||
for exam in exams:
|
||||
exam_type = exam.exam_type.value if hasattr(exam.exam_type, "value") else str(exam.exam_type)
|
||||
for score in exam.scores:
|
||||
writer.writerow([
|
||||
exam.exam_date.isoformat(),
|
||||
type_map.get(exam.exam_type.value, exam.exam_type.value),
|
||||
type_map.get(exam_type, exam_type),
|
||||
exam.title or "",
|
||||
score.subject.name if score.subject else "",
|
||||
float(score.total_score),
|
||||
@@ -45,10 +47,13 @@ def export_scores_csv(
|
||||
f"{float(score.ratio) * 100:.2f}%",
|
||||
])
|
||||
|
||||
output.seek(0)
|
||||
content = output.getvalue().encode("utf-8-sig")
|
||||
filename = f"{student.name}_scores.csv"
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue().encode("utf-8-sig")]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
encoded = quote(filename)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="scores.csv"; filename*=UTF-8\'\'{encoded}'
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import SystemSettings, User
|
||||
from app.schemas import AppFeaturesOut, PublicSettingsOut
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
|
||||
def get_or_create_settings(db: Session) -> SystemSettings:
|
||||
row = db.get(SystemSettings, 1)
|
||||
if row is None:
|
||||
row = SystemSettings(id=1, registration_enabled=True)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
@router.get("/public", response_model=PublicSettingsOut)
|
||||
def public_settings(db: Session = Depends(get_db)):
|
||||
row = get_or_create_settings(db)
|
||||
return PublicSettingsOut(registration_enabled=row.registration_enabled)
|
||||
|
||||
|
||||
@router.get("/app-features", response_model=AppFeaturesOut)
|
||||
def app_features(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
):
|
||||
row = get_or_create_settings(db)
|
||||
return AppFeaturesOut(ai_review_enabled=getattr(row, "ai_review_enabled", True))
|
||||
@@ -1,28 +1,37 @@
|
||||
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 app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import Student, User
|
||||
from app.schemas import StudentCreate, StudentOut, StudentUpdate
|
||||
from app.services.student_access import get_student_for_user
|
||||
from app.services.student_avatar import delete_avatar_file, save_avatar
|
||||
|
||||
router = APIRouter(prefix="/students", tags=["students"])
|
||||
|
||||
|
||||
def _to_out(student: Student) -> StudentOut:
|
||||
return StudentOut.from_student(student)
|
||||
|
||||
|
||||
@router.get("", response_model=list[StudentOut])
|
||||
def list_students(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
return (
|
||||
rows = (
|
||||
db.query(Student)
|
||||
.filter(Student.user_id == current_user.id)
|
||||
.order_by(Student.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return [_to_out(row) for row in rows]
|
||||
|
||||
|
||||
@router.post("", response_model=StudentOut, status_code=status.HTTP_201_CREATED)
|
||||
@@ -35,7 +44,7 @@ def create_student(
|
||||
db.add(student)
|
||||
db.commit()
|
||||
db.refresh(student)
|
||||
return student
|
||||
return _to_out(student)
|
||||
|
||||
|
||||
@router.get("/{student_id}", response_model=StudentOut)
|
||||
@@ -44,7 +53,8 @@ def get_student(
|
||||
db: Session = Depends(get_db),
|
||||
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)
|
||||
@@ -59,7 +69,7 @@ def update_student(
|
||||
setattr(student, key, value)
|
||||
db.commit()
|
||||
db.refresh(student)
|
||||
return student
|
||||
return _to_out(student)
|
||||
|
||||
|
||||
@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),
|
||||
):
|
||||
student = get_student_for_user(db, student_id, current_user.id)
|
||||
delete_avatar_file(student.avatar_path)
|
||||
db.delete(student)
|
||||
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")
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import json
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, Query, UploadFile, status
|
||||
@@ -8,32 +11,147 @@ from sqlalchemy.orm import Session, joinedload
|
||||
from app.core.config import settings
|
||||
from app.core.database import SessionLocal, get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import Subject, User, WrongQuestion, WrongQuestionStatus
|
||||
from app.schemas import WrongQuestionOut, WrongQuestionUpdate
|
||||
from app.models.user import Subject, SystemSettings, User, WrongQuestion, WrongQuestionCategory, WrongQuestionStatus
|
||||
from app.schemas import WrongQuestionCategoryEnum, WrongQuestionOut, WrongQuestionUpdate
|
||||
from app.services import annotation as annotation_service
|
||||
from app.services import llm as llm_service
|
||||
from app.services import ocr as ocr_service
|
||||
from app.services import ollama as ollama_service
|
||||
from app.services import ocr_filter as ocr_filter_service
|
||||
from app.services.student_access import get_student_for_user
|
||||
|
||||
router = APIRouter(tags=["wrong_questions"])
|
||||
|
||||
|
||||
def _short_error(exc: BaseException, prefix: str = "") -> str:
|
||||
msg = str(exc).strip() or type(exc).__name__
|
||||
if len(msg) > 500:
|
||||
msg = msg[:500] + "…"
|
||||
return f"{prefix}{msg}" if prefix else msg
|
||||
|
||||
|
||||
def _is_still_processing(wq: WrongQuestion) -> bool:
|
||||
if wq.status == WrongQuestionStatus.pending:
|
||||
return True
|
||||
if wq.status == WrongQuestionStatus.ocr_done and not wq.question_text and not wq.error_message:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _expire_stale_processing(wq: WrongQuestion, db: Session) -> None:
|
||||
if not _is_still_processing(wq):
|
||||
return
|
||||
created = wq.created_at
|
||||
if created.tzinfo is None:
|
||||
created = created.replace(tzinfo=timezone.utc)
|
||||
age = datetime.now(timezone.utc) - created
|
||||
if age <= timedelta(minutes=settings.PROCESS_STALE_MINUTES):
|
||||
return
|
||||
wq.status = WrongQuestionStatus.failed
|
||||
wq.error_message = f"处理超时(超过 {settings.PROCESS_STALE_MINUTES} 分钟),请点击「重新识别标注」重试"
|
||||
db.commit()
|
||||
|
||||
|
||||
def _ocr_service_url(db: Session) -> str | None:
|
||||
row = db.get(SystemSettings, 1)
|
||||
if row and row.ocr_service_url:
|
||||
return row.ocr_service_url.strip() or None
|
||||
return ocr_service.resolve_ocr_service_url()
|
||||
|
||||
|
||||
def _parse_mark_regions(raw: str | None) -> list[dict] | None:
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
return data if isinstance(data, list) else None
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def _wq_to_out(wq: WrongQuestion) -> WrongQuestionOut:
|
||||
return WrongQuestionOut(
|
||||
id=wq.id,
|
||||
student_id=wq.student_id,
|
||||
subject_id=wq.subject_id,
|
||||
subject_name=wq.subject.name if wq.subject else None,
|
||||
category=wq.category,
|
||||
image_path=wq.image_path,
|
||||
ocr_raw_text=wq.ocr_raw_text,
|
||||
question_text=wq.question_text,
|
||||
solution_approach=wq.solution_approach,
|
||||
solution_text=wq.solution_text,
|
||||
mark_regions=_parse_mark_regions(wq.mark_regions_json),
|
||||
has_annotated_image=bool(wq.annotated_image_path),
|
||||
has_cropped_image=bool(wq.cropped_image_path),
|
||||
error_message=wq.error_message,
|
||||
status=wq.status,
|
||||
created_at=wq.created_at,
|
||||
)
|
||||
|
||||
|
||||
async def _run_ai_pipeline(
|
||||
wq: WrongQuestion,
|
||||
db: Session,
|
||||
ocr_lines: list[dict],
|
||||
printed_text: str,
|
||||
*,
|
||||
hw_indices: list[int] | None = None,
|
||||
):
|
||||
import asyncio
|
||||
|
||||
subject_name = wq.subject.name if wq.subject else "综合"
|
||||
school_level = wq.student.school_level if wq.student else None
|
||||
olympiad = wq.category == WrongQuestionCategory.olympiad
|
||||
ai_cfg = llm_service.load_ai_config(db)
|
||||
image_full = str(Path(settings.UPLOAD_DIR) / wq.image_path)
|
||||
timeout = settings.AI_TIMEOUT_SECONDS
|
||||
|
||||
candidate_indices = hw_indices if hw_indices else list(range(len(ocr_lines)))
|
||||
candidate_lines = [ocr_lines[i] for i in candidate_indices if 0 <= i < len(ocr_lines)]
|
||||
if not candidate_lines:
|
||||
candidate_lines = ocr_lines
|
||||
candidate_indices = list(range(len(ocr_lines)))
|
||||
|
||||
try:
|
||||
detect_resp = await asyncio.wait_for(
|
||||
llm_service.detect_wrong_line_ids(ai_cfg, subject_name, candidate_lines, school_level),
|
||||
timeout=min(90, timeout),
|
||||
)
|
||||
local_wrong = annotation_service.parse_wrong_line_ids(detect_resp, candidate_lines)
|
||||
wrong_ids = [candidate_indices[i] for i in local_wrong if i < len(candidate_indices)]
|
||||
except Exception:
|
||||
local_wrong = annotation_service.heuristic_wrong_line_ids(candidate_lines)
|
||||
wrong_ids = [candidate_indices[i] for i in local_wrong if i < len(candidate_indices)]
|
||||
|
||||
regions = annotation_service.regions_from_lines(ocr_lines, wrong_ids)
|
||||
wq.mark_regions_json = json.dumps(regions, ensure_ascii=False)
|
||||
ann_rel = ocr_service.annotated_rel_path(wq.image_path)
|
||||
wq.annotated_image_path = annotation_service.draw_annotated_image(
|
||||
image_full, ocr_lines, wrong_ids, ann_rel
|
||||
)
|
||||
db.commit()
|
||||
|
||||
question_text = await asyncio.wait_for(
|
||||
llm_service.format_question(ai_cfg, subject_name, printed_text, school_level),
|
||||
timeout=timeout,
|
||||
)
|
||||
solution_full = await asyncio.wait_for(
|
||||
llm_service.generate_solution(
|
||||
ai_cfg, subject_name, question_text, school_level, olympiad=olympiad
|
||||
),
|
||||
timeout=timeout,
|
||||
)
|
||||
approach, solution_body = annotation_service.split_solution_sections(solution_full)
|
||||
wq.question_text = question_text
|
||||
wq.solution_approach = approach
|
||||
wq.solution_text = solution_body if approach else solution_full
|
||||
wq.status = WrongQuestionStatus.solved
|
||||
wq.error_message = None
|
||||
|
||||
|
||||
def _process_wrong_question(question_id: uuid.UUID):
|
||||
db = SessionLocal()
|
||||
wq = None
|
||||
try:
|
||||
wq = (
|
||||
db.query(WrongQuestion)
|
||||
@@ -44,42 +162,83 @@ def _process_wrong_question(question_id: uuid.UUID):
|
||||
if wq is None:
|
||||
return
|
||||
|
||||
wq.error_message = None
|
||||
image_full = Path(settings.UPLOAD_DIR) / wq.image_path
|
||||
ocr_url = _ocr_service_url(db)
|
||||
try:
|
||||
ocr_text = ocr_service.run_ocr(str(image_full))
|
||||
wq.ocr_raw_text = ocr_text or None
|
||||
wq.status = WrongQuestionStatus.ocr_done if ocr_text else WrongQuestionStatus.failed
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
future = pool.submit(
|
||||
ocr_service.run_ocr_with_regions, str(image_full), ocr_url
|
||||
)
|
||||
ocr_result = future.result(timeout=settings.OCR_TIMEOUT_SECONDS)
|
||||
ocr_text = ocr_result["text"]
|
||||
ocr_lines = ocr_result["lines"]
|
||||
img_height = int(ocr_result.get("height") or 0)
|
||||
printed_ids, hw_ids = ocr_filter_service.split_printed_handwriting(
|
||||
ocr_lines,
|
||||
img_height,
|
||||
answer_zone_ratio=settings.OCR_ANSWER_ZONE_RATIO,
|
||||
enabled=settings.OCR_FILTER_HANDWRITING,
|
||||
)
|
||||
printed_text = ocr_filter_service.text_from_indices(ocr_lines, printed_ids)
|
||||
if not printed_text:
|
||||
printed_text = ocr_text
|
||||
wq.ocr_raw_text = printed_text or None
|
||||
if not ocr_text:
|
||||
wq.status = WrongQuestionStatus.failed
|
||||
wq.error_message = "OCR 未识别到文字,请拍摄更清晰、光线充足的题目照片"
|
||||
db.commit()
|
||||
return
|
||||
wq.status = WrongQuestionStatus.ocr_done
|
||||
db.commit()
|
||||
except Exception:
|
||||
except FuturesTimeout:
|
||||
wq.status = WrongQuestionStatus.failed
|
||||
wq.error_message = (
|
||||
f"OCR 识别超时(>{settings.OCR_TIMEOUT_SECONDS}秒),请点「重新识别标注」重试"
|
||||
)
|
||||
db.commit()
|
||||
return
|
||||
except Exception as exc:
|
||||
wq.status = WrongQuestionStatus.failed
|
||||
msg = _short_error(exc, "OCR 识别失败:")
|
||||
if "libGL" in str(exc):
|
||||
msg += " 请在服务器执行: sudo bash deploy/install-ocr-deps.sh && systemctl restart grade-archive"
|
||||
elif ocr_url:
|
||||
if "OCR 服务" not in msg:
|
||||
msg += " 诊断: bash deploy/ocr-screen.sh status && bash deploy/ocr-worker/test-ocr.sh"
|
||||
wq.error_message = msg
|
||||
db.commit()
|
||||
return
|
||||
|
||||
if not ocr_text:
|
||||
return
|
||||
|
||||
subject_name = wq.subject.name if wq.subject else "综合"
|
||||
school_level = wq.student.school_level if wq.student else None
|
||||
import asyncio
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
question_text = loop.run_until_complete(
|
||||
ollama_service.format_question(subject_name, ocr_text, school_level)
|
||||
loop.run_until_complete(
|
||||
_run_ai_pipeline(
|
||||
wq,
|
||||
db,
|
||||
ocr_lines,
|
||||
printed_text,
|
||||
hw_indices=hw_ids,
|
||||
)
|
||||
)
|
||||
solution_text = loop.run_until_complete(
|
||||
ollama_service.generate_solution(subject_name, question_text, school_level)
|
||||
)
|
||||
wq.question_text = question_text
|
||||
wq.solution_text = solution_text
|
||||
wq.status = WrongQuestionStatus.solved
|
||||
db.commit()
|
||||
except Exception:
|
||||
wq.status = WrongQuestionStatus.ocr_done
|
||||
except Exception as exc:
|
||||
wq.status = WrongQuestionStatus.failed
|
||||
detail = _short_error(exc, "AI 处理失败:")
|
||||
if "Timeout" in type(exc).__name__ or "timeout" in str(exc).lower():
|
||||
detail = "AI 处理超时,请检查 Ollama/OpenAI 是否可用后重试"
|
||||
wq.error_message = detail
|
||||
db.commit()
|
||||
finally:
|
||||
loop.close()
|
||||
except Exception as exc:
|
||||
if wq is not None:
|
||||
wq.status = WrongQuestionStatus.failed
|
||||
wq.error_message = _short_error(exc, "处理失败:")
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -88,6 +247,7 @@ def _process_wrong_question(question_id: uuid.UUID):
|
||||
def list_wrong_questions(
|
||||
student_id: uuid.UUID,
|
||||
subject_id: int | None = Query(None),
|
||||
category: WrongQuestionCategoryEnum | None = Query(None),
|
||||
q: str | None = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
@@ -100,6 +260,8 @@ def list_wrong_questions(
|
||||
)
|
||||
if subject_id is not None:
|
||||
query = query.filter(WrongQuestion.subject_id == subject_id)
|
||||
if category is not None:
|
||||
query = query.filter(WrongQuestion.category == category.value)
|
||||
if q:
|
||||
pattern = f"%{q}%"
|
||||
query = query.filter(
|
||||
@@ -108,6 +270,8 @@ def list_wrong_questions(
|
||||
| (WrongQuestion.ocr_raw_text.ilike(pattern))
|
||||
)
|
||||
items = query.order_by(WrongQuestion.created_at.desc()).all()
|
||||
for w in items:
|
||||
_expire_stale_processing(w, db)
|
||||
return [_wq_to_out(w) for w in items]
|
||||
|
||||
|
||||
@@ -121,6 +285,7 @@ async def upload_wrong_question(
|
||||
background_tasks: BackgroundTasks,
|
||||
subject_id: int = Form(...),
|
||||
file: UploadFile = File(...),
|
||||
category: WrongQuestionCategoryEnum = Form(WrongQuestionCategoryEnum.regular),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
@@ -128,6 +293,8 @@ async def upload_wrong_question(
|
||||
subject = db.get(Subject, subject_id)
|
||||
if subject is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="科目不存在")
|
||||
if category == WrongQuestionCategoryEnum.olympiad and subject.name != "数学":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="奥数区仅支持数学科目")
|
||||
|
||||
content = await file.read()
|
||||
if len(content) > settings.MAX_UPLOAD_SIZE:
|
||||
@@ -137,6 +304,7 @@ async def upload_wrong_question(
|
||||
student_id=student_id,
|
||||
subject_id=subject_id,
|
||||
image_path="",
|
||||
category=WrongQuestionCategory(category.value),
|
||||
status=WrongQuestionStatus.pending,
|
||||
)
|
||||
db.add(wq)
|
||||
@@ -173,6 +341,7 @@ def get_wrong_question(
|
||||
)
|
||||
if wq is None or wq.student.user_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在")
|
||||
_expire_stale_processing(wq, db)
|
||||
return _wq_to_out(wq)
|
||||
|
||||
|
||||
@@ -200,6 +369,8 @@ def update_wrong_question(
|
||||
wq.question_text = data.question_text
|
||||
if data.solution_text is not None:
|
||||
wq.solution_text = data.solution_text
|
||||
if data.solution_approach is not None:
|
||||
wq.solution_approach = data.solution_approach
|
||||
|
||||
db.commit()
|
||||
db.refresh(wq)
|
||||
@@ -222,10 +393,16 @@ def delete_wrong_question(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在")
|
||||
|
||||
image_full = Path(settings.UPLOAD_DIR) / wq.image_path
|
||||
ann_full = Path(settings.UPLOAD_DIR) / wq.annotated_image_path if wq.annotated_image_path else None
|
||||
crop_full = Path(settings.UPLOAD_DIR) / wq.cropped_image_path if wq.cropped_image_path else None
|
||||
db.delete(wq)
|
||||
db.commit()
|
||||
if image_full.exists():
|
||||
image_full.unlink()
|
||||
if ann_full and ann_full.exists():
|
||||
ann_full.unlink()
|
||||
if crop_full and crop_full.exists():
|
||||
crop_full.unlink()
|
||||
|
||||
|
||||
@router.post("/wrong-questions/{question_id}/retry-ocr", response_model=WrongQuestionOut)
|
||||
@@ -245,6 +422,8 @@ def retry_ocr(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在")
|
||||
|
||||
wq.status = WrongQuestionStatus.pending
|
||||
wq.error_message = None
|
||||
wq.cropped_image_path = None
|
||||
db.commit()
|
||||
background_tasks.add_task(_process_wrong_question, wq.id)
|
||||
return _wq_to_out(wq)
|
||||
@@ -270,21 +449,30 @@ async def regenerate_solution(
|
||||
|
||||
subject_name = wq.subject.name if wq.subject else "综合"
|
||||
school_level = wq.student.school_level if wq.student else None
|
||||
olympiad = wq.category == WrongQuestionCategory.olympiad
|
||||
question_text = wq.question_text or wq.ocr_raw_text or ""
|
||||
ai_cfg = llm_service.load_ai_config(db)
|
||||
|
||||
try:
|
||||
if not wq.question_text and wq.ocr_raw_text:
|
||||
wq.question_text = await ollama_service.format_question(
|
||||
subject_name, wq.ocr_raw_text, school_level
|
||||
wq.question_text = await llm_service.format_question(
|
||||
ai_cfg, subject_name, wq.ocr_raw_text, school_level
|
||||
)
|
||||
question_text = wq.question_text
|
||||
wq.solution_text = await ollama_service.generate_solution(
|
||||
subject_name, question_text, school_level
|
||||
solution_full = await llm_service.generate_solution(
|
||||
ai_cfg,
|
||||
subject_name,
|
||||
question_text,
|
||||
school_level,
|
||||
olympiad=olympiad,
|
||||
)
|
||||
approach, solution_body = annotation_service.split_solution_sections(solution_full)
|
||||
wq.solution_approach = approach
|
||||
wq.solution_text = solution_body if approach else solution_full
|
||||
wq.status = WrongQuestionStatus.solved
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Ollama 调用失败: {exc}"
|
||||
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"AI 调用失败: {exc}"
|
||||
) from exc
|
||||
|
||||
db.commit()
|
||||
@@ -311,3 +499,49 @@ def get_wrong_question_image(
|
||||
if not image_full.exists():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
|
||||
return FileResponse(image_full)
|
||||
|
||||
|
||||
@router.get("/wrong-questions/{question_id}/annotated-image")
|
||||
def get_wrong_question_annotated_image(
|
||||
question_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
wq = (
|
||||
db.query(WrongQuestion)
|
||||
.options(joinedload(WrongQuestion.student))
|
||||
.filter(WrongQuestion.id == question_id)
|
||||
.first()
|
||||
)
|
||||
if wq is None or wq.student.user_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在")
|
||||
if not wq.annotated_image_path:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="标注图尚未生成")
|
||||
|
||||
image_full = Path(settings.UPLOAD_DIR) / wq.annotated_image_path
|
||||
if not image_full.exists():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="标注图不存在")
|
||||
return FileResponse(image_full)
|
||||
|
||||
|
||||
@router.get("/wrong-questions/{question_id}/cropped-image")
|
||||
def get_wrong_question_cropped_image(
|
||||
question_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
wq = (
|
||||
db.query(WrongQuestion)
|
||||
.options(joinedload(WrongQuestion.student))
|
||||
.filter(WrongQuestion.id == question_id)
|
||||
.first()
|
||||
)
|
||||
if wq is None or wq.student.user_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在")
|
||||
if not wq.cropped_image_path:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="裁剪图尚未生成")
|
||||
|
||||
image_full = Path(settings.UPLOAD_DIR) / wq.cropped_image_path
|
||||
if not image_full.exists():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="裁剪图不存在")
|
||||
return FileResponse(image_full)
|
||||
|
||||
@@ -11,6 +11,16 @@ class ExamTypeEnum(str, Enum):
|
||||
final = "final"
|
||||
|
||||
|
||||
class ReviewStatusEnum(str, Enum):
|
||||
careless = "careless"
|
||||
unknown = "unknown"
|
||||
nervous = "nervous"
|
||||
normal = "normal"
|
||||
|
||||
|
||||
REVIEW_STATUS_VALUES = {s.value for s in ReviewStatusEnum}
|
||||
|
||||
|
||||
class WrongQuestionStatusEnum(str, Enum):
|
||||
pending = "pending"
|
||||
ocr_done = "ocr_done"
|
||||
@@ -18,6 +28,16 @@ class WrongQuestionStatusEnum(str, Enum):
|
||||
failed = "failed"
|
||||
|
||||
|
||||
class WrongQuestionCategoryEnum(str, Enum):
|
||||
regular = "regular"
|
||||
olympiad = "olympiad"
|
||||
|
||||
|
||||
class AIProviderEnum(str, Enum):
|
||||
ollama = "ollama"
|
||||
openai = "openai"
|
||||
|
||||
|
||||
class SchoolLevelEnum(str, Enum):
|
||||
junior_high = "junior_high"
|
||||
senior_high = "senior_high"
|
||||
@@ -46,6 +66,66 @@ class RefreshRequest(BaseModel):
|
||||
class UserOut(BaseModel):
|
||||
id: UUID
|
||||
username: str
|
||||
is_superuser: bool = False
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PublicSettingsOut(BaseModel):
|
||||
registration_enabled: bool
|
||||
|
||||
|
||||
class AppFeaturesOut(BaseModel):
|
||||
ai_review_enabled: bool
|
||||
|
||||
|
||||
class SystemSettingsOut(BaseModel):
|
||||
registration_enabled: bool
|
||||
ai_review_enabled: bool = True
|
||||
ai_provider: AIProviderEnum
|
||||
ollama_base_url: str | None = None
|
||||
ollama_model: str | None = None
|
||||
openai_base_url: str | None = None
|
||||
openai_model: str | None = None
|
||||
openai_api_key_set: bool = False
|
||||
ocr_service_url: str | None = None
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SystemSettingsUpdate(BaseModel):
|
||||
registration_enabled: bool | None = None
|
||||
ai_review_enabled: bool | None = None
|
||||
ai_provider: AIProviderEnum | None = None
|
||||
ollama_base_url: str | None = None
|
||||
ollama_model: str | None = None
|
||||
openai_base_url: str | None = None
|
||||
openai_model: str | None = None
|
||||
openai_api_key: str | None = None
|
||||
ocr_service_url: str | None = None
|
||||
|
||||
|
||||
class AdminProfileUpdate(BaseModel):
|
||||
username: str | None = Field(default=None, min_length=3, max_length=64)
|
||||
current_password: str | None = None
|
||||
password: str | None = Field(default=None, min_length=6, max_length=128)
|
||||
|
||||
|
||||
class AdminUserCreate(BaseModel):
|
||||
username: str = Field(min_length=3, max_length=64)
|
||||
password: str = Field(min_length=6, max_length=128)
|
||||
|
||||
|
||||
class AdminUserPasswordUpdate(BaseModel):
|
||||
password: str = Field(min_length=6, max_length=128)
|
||||
|
||||
|
||||
class AdminUserOut(BaseModel):
|
||||
id: UUID
|
||||
username: str
|
||||
is_superuser: bool
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -56,6 +136,7 @@ class StudentCreate(BaseModel):
|
||||
school_level: SchoolLevelEnum = SchoolLevelEnum.junior_high
|
||||
grade: str | None = None
|
||||
class_name: str | None = None
|
||||
school_name: str | None = Field(default=None, max_length=128)
|
||||
|
||||
|
||||
class StudentUpdate(BaseModel):
|
||||
@@ -63,6 +144,7 @@ class StudentUpdate(BaseModel):
|
||||
school_level: SchoolLevelEnum | None = None
|
||||
grade: str | None = None
|
||||
class_name: str | None = None
|
||||
school_name: str | None = Field(default=None, max_length=128)
|
||||
|
||||
|
||||
class StudentOut(BaseModel):
|
||||
@@ -71,10 +153,25 @@ class StudentOut(BaseModel):
|
||||
school_level: SchoolLevelEnum
|
||||
grade: str | None
|
||||
class_name: str | None
|
||||
school_name: str | None
|
||||
has_avatar: bool = False
|
||||
created_at: datetime
|
||||
|
||||
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):
|
||||
id: int
|
||||
@@ -87,6 +184,7 @@ class ScoreInput(BaseModel):
|
||||
subject_id: int
|
||||
total_score: float
|
||||
obtained_score: float
|
||||
review_statuses: list[ReviewStatusEnum] = []
|
||||
|
||||
@field_validator("total_score")
|
||||
@classmethod
|
||||
@@ -105,6 +203,18 @@ class ScoreInput(BaseModel):
|
||||
raise ValueError("得分不能为负")
|
||||
return v
|
||||
|
||||
@field_validator("review_statuses")
|
||||
@classmethod
|
||||
def validate_review_statuses(cls, v: list[ReviewStatusEnum]) -> list[ReviewStatusEnum]:
|
||||
seen: set[str] = set()
|
||||
unique: list[ReviewStatusEnum] = []
|
||||
for item in v:
|
||||
key = item.value if isinstance(item, ReviewStatusEnum) else str(item)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(item if isinstance(item, ReviewStatusEnum) else ReviewStatusEnum(key))
|
||||
return unique
|
||||
|
||||
|
||||
class ScoreOut(BaseModel):
|
||||
id: UUID
|
||||
@@ -113,6 +223,7 @@ class ScoreOut(BaseModel):
|
||||
total_score: float
|
||||
obtained_score: float
|
||||
ratio: float
|
||||
review_statuses: list[ReviewStatusEnum] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -131,6 +242,23 @@ class ExamUpdate(BaseModel):
|
||||
scores: list[ScoreInput] | None = None
|
||||
|
||||
|
||||
class ReviewScoreInput(BaseModel):
|
||||
subject_id: int
|
||||
review_statuses: list[ReviewStatusEnum] = []
|
||||
|
||||
|
||||
class ExamReviewUpdate(BaseModel):
|
||||
reviews: list[ReviewScoreInput] = []
|
||||
|
||||
|
||||
class ReviewInsightRequest(BaseModel):
|
||||
subject_name: str = Field(..., min_length=1, max_length=32)
|
||||
|
||||
|
||||
class ReviewInsightResponse(BaseModel):
|
||||
insight: str
|
||||
|
||||
|
||||
class ExamOut(BaseModel):
|
||||
id: UUID
|
||||
exam_type: ExamTypeEnum
|
||||
@@ -167,10 +295,16 @@ class WrongQuestionOut(BaseModel):
|
||||
student_id: UUID
|
||||
subject_id: int
|
||||
subject_name: str | None = None
|
||||
category: WrongQuestionCategoryEnum
|
||||
image_path: str
|
||||
ocr_raw_text: str | None
|
||||
question_text: str | None
|
||||
solution_approach: str | None = None
|
||||
solution_text: str | None
|
||||
mark_regions: list[dict] | None = None
|
||||
has_annotated_image: bool = False
|
||||
has_cropped_image: bool = False
|
||||
error_message: str | None = None
|
||||
status: WrongQuestionStatusEnum
|
||||
created_at: datetime
|
||||
|
||||
@@ -179,5 +313,48 @@ class WrongQuestionOut(BaseModel):
|
||||
|
||||
class WrongQuestionUpdate(BaseModel):
|
||||
question_text: str | None = None
|
||||
solution_approach: str | None = None
|
||||
solution_text: str | None = None
|
||||
subject_id: int | None = None
|
||||
|
||||
|
||||
class CompositionStatusEnum(str, Enum):
|
||||
pending = "pending"
|
||||
generating = "generating"
|
||||
done = "done"
|
||||
failed = "failed"
|
||||
|
||||
|
||||
class CompositionInputModeEnum(str, Enum):
|
||||
manual = "manual"
|
||||
ocr = "ocr"
|
||||
|
||||
|
||||
class CompositionCreate(BaseModel):
|
||||
topic: str = Field(..., min_length=1, max_length=4000)
|
||||
input_mode: CompositionInputModeEnum = CompositionInputModeEnum.manual
|
||||
|
||||
|
||||
class CompositionOcrOut(BaseModel):
|
||||
text: str
|
||||
|
||||
|
||||
class CompositionOut(BaseModel):
|
||||
id: UUID
|
||||
student_id: UUID
|
||||
topic: str
|
||||
input_mode: CompositionInputModeEnum
|
||||
writing_plan: str | None = None
|
||||
sample_essay: str | None = None
|
||||
error_message: str | None = None
|
||||
status: CompositionStatusEnum
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class BackupInfoOut(BaseModel):
|
||||
filename: str
|
||||
size_bytes: int
|
||||
created_at: datetime
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def _parse_llm_json(text: str) -> dict | None:
|
||||
text = text.strip()
|
||||
match = re.search(r"\{[\s\S]*\}", text)
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
return json.loads(match.group())
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def heuristic_wrong_line_ids(lines: list[dict]) -> list[int]:
|
||||
wrong: list[int] = []
|
||||
for i, line in enumerate(lines):
|
||||
t = line.get("text", "")
|
||||
if any(c in t for c in ("×", "✗", "❌", "错")):
|
||||
wrong.append(i)
|
||||
continue
|
||||
if re.search(r"[×xX]\s*$", t.strip()):
|
||||
wrong.append(i)
|
||||
if wrong:
|
||||
return wrong
|
||||
# 单题照片:标注最后几行作答区域
|
||||
if len(lines) == 1:
|
||||
return [0]
|
||||
if len(lines) <= 4:
|
||||
return list(range(max(0, len(lines) - 2), len(lines)))
|
||||
return list(range(len(lines) - 2, len(lines)))
|
||||
|
||||
|
||||
def parse_wrong_line_ids(llm_response: str, lines: list[dict]) -> list[int]:
|
||||
data = _parse_llm_json(llm_response)
|
||||
if data and isinstance(data.get("wrong_line_ids"), list):
|
||||
ids = [int(x) for x in data["wrong_line_ids"] if isinstance(x, (int, float, str))]
|
||||
ids = [i for i in ids if 0 <= i < len(lines)]
|
||||
if ids:
|
||||
return ids
|
||||
return heuristic_wrong_line_ids(lines)
|
||||
|
||||
|
||||
def regions_from_lines(lines: list[dict], wrong_ids: list[int]) -> list[dict]:
|
||||
regions = []
|
||||
for i in wrong_ids:
|
||||
if i >= len(lines):
|
||||
continue
|
||||
line = lines[i]
|
||||
bbox = line.get("bbox") or [0, 0, 0, 0]
|
||||
regions.append(
|
||||
{
|
||||
"line_id": i,
|
||||
"text": line.get("text", ""),
|
||||
"bbox": bbox,
|
||||
"type": "wrong",
|
||||
"label": "错",
|
||||
}
|
||||
)
|
||||
return regions
|
||||
|
||||
|
||||
def draw_annotated_image(
|
||||
src_path: str,
|
||||
lines: list[dict],
|
||||
wrong_ids: list[int],
|
||||
dest_rel_path: str,
|
||||
) -> str:
|
||||
img = Image.open(src_path).convert("RGBA")
|
||||
overlay = Image.new("RGBA", img.size, (255, 255, 255, 0))
|
||||
draw = ImageDraw.Draw(overlay)
|
||||
|
||||
try:
|
||||
font = ImageFont.truetype("DejaVuSans-Bold.ttf", max(14, img.size[0] // 40))
|
||||
except OSError:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
for i in wrong_ids:
|
||||
if i >= len(lines):
|
||||
continue
|
||||
bbox = lines[i].get("bbox") or [0, 0, 0, 0]
|
||||
x1, y1, x2, y2 = bbox
|
||||
pad = 6
|
||||
box = [x1 - pad, y1 - pad, x2 + pad, y2 + pad]
|
||||
draw.rounded_rectangle(box, radius=4, fill=(255, 59, 48, 55), outline=(255, 59, 48, 220), width=3)
|
||||
draw.text((x1, max(0, y1 - 18)), "×", fill=(255, 59, 48, 255), font=font)
|
||||
|
||||
combined = Image.alpha_composite(img, overlay).convert("RGB")
|
||||
full_path = Path(settings.UPLOAD_DIR) / dest_rel_path
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
combined.save(full_path, format="JPEG", quality=92)
|
||||
return dest_rel_path
|
||||
|
||||
|
||||
def split_solution_sections(text: str) -> tuple[str | None, str]:
|
||||
if "## 解题思路" not in text:
|
||||
return None, text
|
||||
parts = re.split(r"\n##\s*", text, maxsplit=1)
|
||||
if len(parts) < 2:
|
||||
return None, text
|
||||
approach = parts[0].replace("## 解题思路", "").strip()
|
||||
rest = "## " + parts[1]
|
||||
return approach or None, rest.strip()
|
||||
|
||||
|
||||
def union_bbox(bboxes: list[list[float]], img_w: int, img_h: int, padding_ratio: float = 0.06) -> list[int]:
|
||||
if not bboxes:
|
||||
return [0, 0, img_w, img_h]
|
||||
x1 = min(b[0] for b in bboxes)
|
||||
y1 = min(b[1] for b in bboxes)
|
||||
x2 = max(b[2] for b in bboxes)
|
||||
y2 = max(b[3] for b in bboxes)
|
||||
pad_x = max(8, (x2 - x1) * padding_ratio)
|
||||
pad_y = max(8, (y2 - y1) * padding_ratio)
|
||||
return [
|
||||
int(max(0, x1 - pad_x)),
|
||||
int(max(0, y1 - pad_y)),
|
||||
int(min(img_w, x2 + pad_x)),
|
||||
int(min(img_h, y2 + pad_y)),
|
||||
]
|
||||
|
||||
|
||||
def cropped_rel_path(original_rel: str) -> str:
|
||||
p = Path(original_rel)
|
||||
return str(p.parent / f"{p.stem}_crop.jpg")
|
||||
|
||||
|
||||
def crop_wrong_region(
|
||||
src_path: str,
|
||||
lines: list[dict],
|
||||
wrong_ids: list[int],
|
||||
dest_rel_path: str,
|
||||
img_width: int,
|
||||
img_height: int,
|
||||
) -> str | None:
|
||||
if not wrong_ids:
|
||||
return None
|
||||
bboxes = [lines[i].get("bbox") or [0, 0, 0, 0] for i in wrong_ids if i < len(lines)]
|
||||
if not bboxes:
|
||||
return None
|
||||
box = union_bbox(bboxes, img_width, img_height, padding_ratio=0.12)
|
||||
x1, y1, x2, y2 = box
|
||||
if x2 <= x1 or y2 <= y1:
|
||||
return None
|
||||
|
||||
img = Image.open(src_path).convert("RGB")
|
||||
cropped = img.crop((x1, y1, x2, y2))
|
||||
full_path = Path(settings.UPLOAD_DIR) / dest_rel_path
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cropped.save(full_path, format="JPEG", quality=92)
|
||||
return dest_rel_path
|
||||
@@ -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)
|
||||
@@ -0,0 +1,386 @@
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings as app_settings
|
||||
from app.models.user import SchoolLevel, SystemSettings
|
||||
from app.services.school_level import school_level_label
|
||||
from app.services.url_sanitize import sanitize_http_url, sanitize_model_name
|
||||
|
||||
CURRICULUM_JUNIOR = """初中课程标准:代数、几何(全等/相似/勾股)、一次函数与简单二次函数、基础概率统计。
|
||||
严禁使用:高中导数、向量、解析几何、排列组合进阶、复数、微积分、大学线性代数等。"""
|
||||
|
||||
CURRICULUM_SENIOR = """高中课程标准:课内函数、三角、向量、解析几何、概率统计、导数(课内范围)等。
|
||||
严禁使用:大学数学分析、抽象代数、高等几何、超出课内的竞赛高阶技巧。"""
|
||||
|
||||
CURRICULUM_JUNIOR_OLYMPIAD = """初中奥数培优范围:整数/整除、因数分解、简单数论、代数恒等变形、几何辅助线与全等相似、简单组合计数。
|
||||
严禁使用:高中及以上方法(导数、向量、解析几何、微积分、复数运算等)。"""
|
||||
|
||||
CURRICULUM_SENIOR_OLYMPIAD = """高中奥数/竞赛入门范围:课内知识+常规竞赛技巧(不等式、构造、归纳、简单数论等)。
|
||||
严禁使用:大学数学、超出高中奥数培优体系的 IMO 高阶理论。"""
|
||||
|
||||
|
||||
def _curriculum_block(level: SchoolLevel | str | None, olympiad: bool) -> str:
|
||||
label = school_level_label(level)
|
||||
is_senior = level == SchoolLevel.senior_high or level == "senior_high"
|
||||
if olympiad:
|
||||
return CURRICULUM_SENIOR_OLYMPIAD if is_senior else CURRICULUM_JUNIOR_OLYMPIAD
|
||||
return CURRICULUM_SENIOR if is_senior else CURRICULUM_JUNIOR
|
||||
|
||||
|
||||
QUESTION_PROMPT = """你是一位{stage}老师。以下是从试卷 OCR 识别出的文字,可能含有噪声。
|
||||
科目:{subject}
|
||||
请整理出清晰的题目内容(保留题号、选项、公式),只输出题目正文,不要解释。
|
||||
|
||||
OCR 原文:
|
||||
{ocr_text}
|
||||
"""
|
||||
|
||||
SOLUTION_PROMPT = """你是一位耐心的{stage}{subject}老师。请像「作业帮」一样,先讲清楚解题思路,再给出完整解答。
|
||||
|
||||
【学段要求 — 严禁超纲】
|
||||
{curriculum}
|
||||
|
||||
题目:
|
||||
{question_text}
|
||||
|
||||
请严格按以下 Markdown 结构输出:
|
||||
|
||||
## 解题思路
|
||||
(2-5 句话:这题考什么、从哪里入手、关键一步是什么,让学生先懂「怎么想」)
|
||||
|
||||
## 详细解答
|
||||
(分步骤完整推导,每步说明依据)
|
||||
|
||||
## 易错点
|
||||
(指出常见错误及正确做法)
|
||||
|
||||
严禁使用超纲方法;若原题超纲,请给出{stage}课内可理解的解法。
|
||||
"""
|
||||
|
||||
OLYMPIAD_SOLUTION_PROMPT = """你是一位{stage}奥数教练。请像优秀辅导老师一样,先讲解题思路,再完整解答。
|
||||
|
||||
【奥数学段要求 — 严禁超纲】
|
||||
{curriculum}
|
||||
|
||||
题目:
|
||||
{question_text}
|
||||
|
||||
请严格按以下 Markdown 结构输出:
|
||||
|
||||
## 解题思路
|
||||
(点明题型、突破口、{stage}奥数常用技巧)
|
||||
|
||||
## 详细解答
|
||||
(完整步骤)
|
||||
|
||||
## 关键技巧
|
||||
(总结,仅限{stage}奥数范围)
|
||||
|
||||
严禁超纲;过难题给出{stage}可接受的培优思路。
|
||||
"""
|
||||
|
||||
ERROR_DETECT_PROMPT = """你是{stage}{subject}老师。以下是试卷/作业 OCR 识别结果,每行前有编号。
|
||||
请找出「学生答错的部分」:错误答案、被打叉的作答、明显不正确的计算结果等。
|
||||
|
||||
{numbered_lines}
|
||||
|
||||
只输出 JSON,不要其他文字:
|
||||
{{"wrong_line_ids": [行编号整数列表]}}
|
||||
若整张图就是一道错题,请标注含有错误答案或作答的行;找不到则标注最后作答行。
|
||||
"""
|
||||
|
||||
REVIEW_INSIGHT_PROMPT = """你是一位{stage}{subject}学习顾问。请仅根据下方「复盘数据」做分析,不得编造未出现的考试或状态。
|
||||
|
||||
【学段】{stage}
|
||||
【科目】{subject}(所有建议必须贴合本科目,禁止套用其他科目的说法)
|
||||
|
||||
【复盘数据】
|
||||
{review_records}
|
||||
|
||||
【状态含义(结合本科目理解)】
|
||||
- 粗心:{careless_hint}
|
||||
- 不会:该科知识点/题型尚未掌握
|
||||
- 紧张:心态影响,发挥低于平时水平
|
||||
- 正常发挥:状态稳定
|
||||
|
||||
【科目建议方向】
|
||||
{subject_hints}
|
||||
|
||||
【必须遵守】
|
||||
1. 解读时必须写清具体考试日期(如 2026-06-21),按时间从早到晚分析,不得把「第1次」说成最近一场
|
||||
2. 得分率 = 得分÷总分;95% 以上才可称「接近满分」,85% 左右应如实描述为「良好但仍有失分空间」,禁止夸大
|
||||
3. 改进建议必须针对 {subject},禁止出现与本科目无关的表述(如英语科禁止写「计算验算」)
|
||||
4. 只分析数据中列出的复盘状态,不要臆测未勾选的原因
|
||||
|
||||
请用 Markdown 输出:
|
||||
|
||||
## 情况解读
|
||||
(2-4 句:按时间顺序说明每次考试得分率、失分与复盘状态的关系,以及是否有改善或反复)
|
||||
|
||||
## 改进建议
|
||||
(3-5 条,针对出现最多的问题状态,具体可操作,仅限 {stage}{subject} 范围)
|
||||
|
||||
## 近期重点
|
||||
(1-2 条本周可落实的小目标)
|
||||
|
||||
语气务实,不要空泛鸡汤。
|
||||
"""
|
||||
|
||||
SUBJECT_REVIEW_HINTS: dict[str, dict[str, str]] = {
|
||||
"语文": {
|
||||
"careless": "看错题干、漏读要求、作文偏题或漏写要点",
|
||||
"hints": "阅读审题、文言文/语言运用、作文结构与素材积累",
|
||||
},
|
||||
"数学": {
|
||||
"careless": "审题不清、计算或抄错、步骤跳步",
|
||||
"hints": "错题归类、计算验算、典型题型归纳与限时练习",
|
||||
},
|
||||
"英语": {
|
||||
"careless": "看错词义/时态、漏读题干、拼写与语法笔误",
|
||||
"hints": "词汇语法、阅读完形、听力与写作模板,禁止建议计算类训练",
|
||||
},
|
||||
"物理": {
|
||||
"careless": "审题漏条件、公式代错、单位换算失误",
|
||||
"hints": "概念理解、建模分析、实验题与计算规范",
|
||||
},
|
||||
"化学": {
|
||||
"careless": "方程式配平/条件遗漏、计算失误",
|
||||
"hints": "方程式、物质性质、实验与推断题",
|
||||
},
|
||||
"生物": {
|
||||
"careless": "概念混淆、漏答得分点",
|
||||
"hints": "教材概念、图表分析、实验设计表述",
|
||||
},
|
||||
"历史": {
|
||||
"careless": "材料题漏读、时间/人物混淆",
|
||||
"hints": "时间线、材料分析、论述题答题模板",
|
||||
},
|
||||
"地理": {
|
||||
"careless": "读图漏信息、术语使用不当",
|
||||
"hints": "地图判读、区域分析、综合题答题条理",
|
||||
},
|
||||
"政治": {
|
||||
"careless": "漏答采分点、概念表述不准",
|
||||
"hints": "时政结合、材料分析、观点表述规范",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _subject_review_hints(subject: str) -> tuple[str, str]:
|
||||
block = SUBJECT_REVIEW_HINTS.get(subject)
|
||||
if block:
|
||||
return block["careless"], block["hints"]
|
||||
return "审题或作答细节失误", f"针对{subject}常见失分点制定练习与错题巩固"
|
||||
|
||||
class AIConfig:
|
||||
def __init__(
|
||||
self,
|
||||
provider: str,
|
||||
ollama_base_url: str,
|
||||
ollama_model: str,
|
||||
openai_base_url: str,
|
||||
openai_model: str,
|
||||
openai_api_key: str | None,
|
||||
):
|
||||
self.provider = provider
|
||||
self.ollama_base_url = ollama_base_url
|
||||
self.ollama_model = ollama_model
|
||||
self.openai_base_url = openai_base_url
|
||||
self.openai_model = openai_model
|
||||
self.openai_api_key = openai_api_key
|
||||
|
||||
|
||||
def load_ai_config(db: Session) -> AIConfig:
|
||||
row = db.get(SystemSettings, 1)
|
||||
if row is None:
|
||||
return AIConfig(
|
||||
provider="ollama",
|
||||
ollama_base_url=sanitize_http_url(app_settings.OLLAMA_BASE_URL),
|
||||
ollama_model=sanitize_model_name(app_settings.OLLAMA_MODEL),
|
||||
openai_base_url=sanitize_http_url(app_settings.OPENAI_BASE_URL),
|
||||
openai_model=sanitize_model_name(app_settings.OPENAI_MODEL),
|
||||
openai_api_key=None,
|
||||
)
|
||||
return AIConfig(
|
||||
provider=row.ai_provider or "ollama",
|
||||
ollama_base_url=sanitize_http_url(row.ollama_base_url or app_settings.OLLAMA_BASE_URL),
|
||||
ollama_model=sanitize_model_name(row.ollama_model or app_settings.OLLAMA_MODEL),
|
||||
openai_base_url=sanitize_http_url(row.openai_base_url or app_settings.OPENAI_BASE_URL),
|
||||
openai_model=sanitize_model_name(row.openai_model or app_settings.OPENAI_MODEL),
|
||||
openai_api_key=row.openai_api_key,
|
||||
)
|
||||
|
||||
|
||||
async def _ollama_generate(prompt: str, cfg: AIConfig, *, temperature: float = 0.3) -> str:
|
||||
url = f"{cfg.ollama_base_url.rstrip('/')}/api/generate"
|
||||
payload = {
|
||||
"model": cfg.ollama_model,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"options": {"temperature": temperature},
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=180.0) as client:
|
||||
response = await client.post(url, json=payload)
|
||||
response.raise_for_status()
|
||||
return (response.json().get("response") or "").strip()
|
||||
|
||||
|
||||
async def _openai_generate(prompt: str, cfg: AIConfig, *, temperature: float = 0.3) -> str:
|
||||
if not cfg.openai_api_key:
|
||||
raise ValueError("未配置 OpenAI API Key")
|
||||
url = f"{cfg.openai_base_url.rstrip('/')}/chat/completions"
|
||||
headers = {"Authorization": f"Bearer {cfg.openai_api_key}"}
|
||||
payload = {
|
||||
"model": cfg.openai_model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": temperature,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=180.0) as client:
|
||||
response = await client.post(url, json=payload, headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return (data["choices"][0]["message"]["content"] or "").strip()
|
||||
|
||||
|
||||
async def generate_text(prompt: str, cfg: AIConfig, *, temperature: float = 0.3) -> str:
|
||||
if cfg.provider == "openai":
|
||||
return await _openai_generate(prompt, cfg, temperature=temperature)
|
||||
return await _ollama_generate(prompt, cfg, temperature=temperature)
|
||||
|
||||
|
||||
async def format_question(
|
||||
cfg: AIConfig,
|
||||
subject: str,
|
||||
ocr_text: str,
|
||||
school_level=None,
|
||||
) -> str:
|
||||
stage = school_level_label(school_level)
|
||||
prompt = QUESTION_PROMPT.format(stage=stage, subject=subject, ocr_text=ocr_text)
|
||||
return await generate_text(prompt, cfg)
|
||||
|
||||
|
||||
async def generate_solution(
|
||||
cfg: AIConfig,
|
||||
subject: str,
|
||||
question_text: str,
|
||||
school_level=None,
|
||||
*,
|
||||
olympiad: bool = False,
|
||||
) -> str:
|
||||
stage = school_level_label(school_level)
|
||||
curriculum = _curriculum_block(school_level, olympiad)
|
||||
template = OLYMPIAD_SOLUTION_PROMPT if olympiad else SOLUTION_PROMPT
|
||||
prompt = template.format(
|
||||
stage=stage,
|
||||
subject=subject,
|
||||
curriculum=curriculum,
|
||||
question_text=question_text,
|
||||
)
|
||||
return await generate_text(prompt, cfg)
|
||||
|
||||
|
||||
async def detect_wrong_line_ids(
|
||||
cfg: AIConfig,
|
||||
subject: str,
|
||||
ocr_lines: list[dict],
|
||||
school_level=None,
|
||||
) -> str:
|
||||
stage = school_level_label(school_level)
|
||||
numbered = "\n".join(f"[{i}] {line.get('text', '')}" for i, line in enumerate(ocr_lines))
|
||||
prompt = ERROR_DETECT_PROMPT.format(stage=stage, subject=subject, numbered_lines=numbered)
|
||||
return await generate_text(prompt, cfg)
|
||||
|
||||
|
||||
async def generate_review_insight(
|
||||
cfg: AIConfig,
|
||||
subject: str,
|
||||
review_records: str,
|
||||
school_level=None,
|
||||
) -> str:
|
||||
stage = school_level_label(school_level)
|
||||
careless_hint, subject_hints = _subject_review_hints(subject)
|
||||
prompt = REVIEW_INSIGHT_PROMPT.format(
|
||||
stage=stage,
|
||||
subject=subject,
|
||||
review_records=review_records,
|
||||
careless_hint=careless_hint,
|
||||
subject_hints=subject_hints,
|
||||
)
|
||||
return await generate_text(prompt, cfg, temperature=0.2)
|
||||
|
||||
|
||||
CURRICULUM_CHINESE_JUNIOR = """初中作文:记叙文、写人记事、简单议论文为主,通常 600-800 字。
|
||||
语言平实,素材来自课内与日常生活,禁止成人化腔调与超纲典故堆砌。"""
|
||||
|
||||
CURRICULUM_CHINESE_SENIOR = """高中作文:记叙、议论、材料作文为主,通常 800-1000 字。
|
||||
可适度展开论证,仍须符合课内要求,禁止大学论文式写法与超纲理论。"""
|
||||
|
||||
COMPOSITION_PROMPT = """你是一位{stage}语文老师,正在辅导{grade_text}学生完成作文。
|
||||
|
||||
【学段年级 — 严禁超纲】
|
||||
{curriculum}
|
||||
|
||||
作文题目:
|
||||
{topic}
|
||||
|
||||
请严格按以下 Markdown 结构输出(不要增加其他一级标题):
|
||||
|
||||
## 写作方案
|
||||
(审题、立意、结构提纲、段落安排、可用素材方向,分条列出,贴合{grade_text}水平)
|
||||
|
||||
## 范文
|
||||
(完整作文一篇,字数与语言风格必须符合{grade_text}课内要求,禁止超纲)
|
||||
|
||||
注意:范文必须是可直接参考的学生习作水准,不要写成评论或教案。
|
||||
"""
|
||||
|
||||
|
||||
def _chinese_curriculum(level, grade: str | None) -> str:
|
||||
is_senior = level == SchoolLevel.senior_high or level == "senior_high"
|
||||
return CURRICULUM_CHINESE_SENIOR if is_senior else CURRICULUM_CHINESE_JUNIOR
|
||||
|
||||
|
||||
def _grade_text(grade: str | None) -> str:
|
||||
if grade and grade.strip():
|
||||
return grade.strip()
|
||||
return "该学段学生"
|
||||
|
||||
|
||||
def split_composition_sections(text: str) -> tuple[str, str]:
|
||||
import re
|
||||
|
||||
text = text.strip()
|
||||
if "## 范文" not in text:
|
||||
return text.replace("## 写作方案", "").strip(), ""
|
||||
parts = re.split(r"\n##\s*范文\s*\n", text, maxsplit=1)
|
||||
plan = parts[0].replace("## 写作方案", "").strip()
|
||||
essay = parts[1].strip() if len(parts) > 1 else ""
|
||||
return plan, essay
|
||||
|
||||
|
||||
async def generate_composition(
|
||||
cfg: AIConfig,
|
||||
topic: str,
|
||||
school_level=None,
|
||||
grade: str | None = None,
|
||||
) -> tuple[str, str]:
|
||||
stage = school_level_label(school_level)
|
||||
grade_text = _grade_text(grade)
|
||||
curriculum = _chinese_curriculum(school_level, grade)
|
||||
prompt = COMPOSITION_PROMPT.format(
|
||||
stage=stage,
|
||||
grade_text=grade_text,
|
||||
curriculum=curriculum,
|
||||
topic=topic.strip(),
|
||||
)
|
||||
full = await generate_text(prompt, cfg, temperature=0.35)
|
||||
return split_composition_sections(full)
|
||||
|
||||
|
||||
def composition_markdown(topic: str, writing_plan: str | None, sample_essay: str | None) -> str:
|
||||
parts = [f"# 作文题目\n\n{topic.strip()}", ""]
|
||||
if writing_plan:
|
||||
parts.extend(["## 写作方案", "", writing_plan.strip(), ""])
|
||||
if sample_essay:
|
||||
parts.extend(["## 范文", "", sample_essay.strip(), ""])
|
||||
return "\n".join(parts).strip() + "\n"
|
||||
@@ -1,12 +1,14 @@
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import engine
|
||||
|
||||
|
||||
def run_migrations() -> None:
|
||||
"""Apply lightweight schema updates for existing databases."""
|
||||
inspector = inspect(engine)
|
||||
if "students" not in inspector.get_table_names():
|
||||
tables = set(inspector.get_table_names())
|
||||
if "students" not in tables:
|
||||
return
|
||||
|
||||
columns = {col["name"] for col in inspector.get_columns("students")}
|
||||
@@ -18,3 +20,95 @@ def run_migrations() -> None:
|
||||
"NOT NULL DEFAULT 'junior_high'"
|
||||
)
|
||||
)
|
||||
|
||||
if "users" in tables:
|
||||
user_columns = {col["name"] for col in inspector.get_columns("users")}
|
||||
if "is_superuser" not in user_columns:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text("ALTER TABLE users ADD COLUMN is_superuser BOOLEAN NOT NULL DEFAULT FALSE")
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
f"UPDATE users SET is_superuser = TRUE "
|
||||
f"WHERE username = '{settings.ADMIN_DEFAULT_USERNAME}'"
|
||||
)
|
||||
)
|
||||
|
||||
if "wrong_questions" in tables:
|
||||
wq_columns = {col["name"] for col in inspector.get_columns("wrong_questions")}
|
||||
if "category" not in wq_columns:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"ALTER TABLE wrong_questions ADD COLUMN category VARCHAR(32) "
|
||||
"NOT NULL DEFAULT 'regular'"
|
||||
)
|
||||
)
|
||||
|
||||
if "system_settings" in tables:
|
||||
ss_columns = {col["name"] for col in inspector.get_columns("system_settings")}
|
||||
alters: list[str] = []
|
||||
if "ai_provider" not in ss_columns:
|
||||
alters.append("ADD COLUMN ai_provider VARCHAR(16) NOT NULL DEFAULT 'ollama'")
|
||||
if "ollama_base_url" not in ss_columns:
|
||||
alters.append("ADD COLUMN ollama_base_url VARCHAR(256)")
|
||||
if "ollama_model" not in ss_columns:
|
||||
alters.append("ADD COLUMN ollama_model VARCHAR(128)")
|
||||
if "openai_base_url" not in ss_columns:
|
||||
alters.append("ADD COLUMN openai_base_url VARCHAR(256)")
|
||||
if "openai_model" not in ss_columns:
|
||||
alters.append("ADD COLUMN openai_model VARCHAR(128)")
|
||||
if "openai_api_key" not in ss_columns:
|
||||
alters.append("ADD COLUMN openai_api_key VARCHAR(512)")
|
||||
if "ocr_service_url" not in ss_columns:
|
||||
alters.append("ADD COLUMN ocr_service_url VARCHAR(256)")
|
||||
if alters:
|
||||
with engine.begin() as conn:
|
||||
for clause in alters:
|
||||
conn.execute(text(f"ALTER TABLE system_settings {clause}"))
|
||||
|
||||
if "wrong_questions" in tables:
|
||||
wq_columns = {col["name"] for col in inspector.get_columns("wrong_questions")}
|
||||
wq_alters: list[str] = []
|
||||
if "solution_approach" not in wq_columns:
|
||||
wq_alters.append("ADD COLUMN solution_approach TEXT")
|
||||
if "mark_regions_json" not in wq_columns:
|
||||
wq_alters.append("ADD COLUMN mark_regions_json TEXT")
|
||||
if "annotated_image_path" not in wq_columns:
|
||||
wq_alters.append("ADD COLUMN annotated_image_path VARCHAR(512)")
|
||||
if "cropped_image_path" not in wq_columns:
|
||||
wq_alters.append("ADD COLUMN cropped_image_path VARCHAR(512)")
|
||||
if "error_message" not in wq_columns:
|
||||
wq_alters.append("ADD COLUMN error_message TEXT")
|
||||
if wq_alters:
|
||||
with engine.begin() as conn:
|
||||
for clause in wq_alters:
|
||||
conn.execute(text(f"ALTER TABLE wrong_questions {clause}"))
|
||||
|
||||
if "subject_scores" in tables:
|
||||
ss_columns = {col["name"] for col in inspector.get_columns("subject_scores")}
|
||||
if "review_statuses_json" not in ss_columns:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("ALTER TABLE subject_scores ADD COLUMN review_statuses_json TEXT"))
|
||||
|
||||
if "system_settings" in tables:
|
||||
ss_columns = {col["name"] for col in inspector.get_columns("system_settings")}
|
||||
if "ai_review_enabled" not in ss_columns:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"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}"))
|
||||
|
||||
+186
-13
@@ -1,8 +1,22 @@
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from PIL import Image
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# 无图形界面服务器:避免 OpenCV/Paddle 依赖 X11
|
||||
os.environ.setdefault("OPENCV_IO_ENABLE_OPENEXR", "0")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ocr_engine = None
|
||||
_ocr_warmup_started = False
|
||||
|
||||
|
||||
def get_ocr_engine():
|
||||
@@ -10,22 +24,175 @@ def get_ocr_engine():
|
||||
if _ocr_engine is None:
|
||||
from paddleocr import PaddleOCR
|
||||
|
||||
_ocr_engine = PaddleOCR(use_angle_cls=True, lang="ch", show_log=False)
|
||||
use_gpu = settings.OCR_USE_GPU
|
||||
_ocr_engine = PaddleOCR(
|
||||
use_angle_cls=False,
|
||||
lang="ch",
|
||||
show_log=False,
|
||||
use_gpu=use_gpu,
|
||||
enable_mkldnn=not use_gpu,
|
||||
det_limit_side_len=min(settings.OCR_MAX_SIDE, 1280),
|
||||
rec_batch_num=8,
|
||||
)
|
||||
return _ocr_engine
|
||||
|
||||
|
||||
def run_ocr(image_path: str) -> str:
|
||||
def resolve_ocr_service_url(service_url: str | None = None) -> str | None:
|
||||
url = (service_url or settings.OCR_SERVICE_URL or "").strip()
|
||||
return url or None
|
||||
|
||||
|
||||
def uses_remote_ocr(service_url: str | None = None) -> bool:
|
||||
return resolve_ocr_service_url(service_url) is not None
|
||||
|
||||
|
||||
def warmup_ocr_engine() -> None:
|
||||
"""后台预加载 OCR 模型,避免首张图片等待数分钟。"""
|
||||
global _ocr_warmup_started
|
||||
if _ocr_warmup_started or not settings.OCR_WARMUP or uses_remote_ocr():
|
||||
return
|
||||
_ocr_warmup_started = True
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
engine = get_ocr_engine()
|
||||
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
|
||||
Image.new("RGB", (120, 40), color=(255, 255, 255)).save(tmp.name, format="JPEG")
|
||||
tmp_path = tmp.name
|
||||
try:
|
||||
engine.ocr(tmp_path, cls=False)
|
||||
logger.info("OCR engine warmed up")
|
||||
finally:
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
except Exception as exc:
|
||||
logger.warning("OCR warmup failed: %s", exc)
|
||||
|
||||
threading.Thread(target=_run, daemon=True, name="ocr-warmup").start()
|
||||
|
||||
|
||||
def _bbox_from_box(box: list) -> list[float]:
|
||||
xs = [float(p[0]) for p in box]
|
||||
ys = [float(p[1]) for p in box]
|
||||
return [min(xs), min(ys), max(xs), max(ys)]
|
||||
|
||||
|
||||
def _scale_bbox(bbox: list[float], scale_x: float, scale_y: float) -> list[float]:
|
||||
return [bbox[0] * scale_x, bbox[1] * scale_y, bbox[2] * scale_x, bbox[3] * scale_y]
|
||||
|
||||
|
||||
def _scale_box(box: list, scale_x: float, scale_y: float) -> list:
|
||||
return [[float(p[0]) * scale_x, float(p[1]) * scale_y] for p in box]
|
||||
|
||||
|
||||
def _normalize_image_bytes(content: bytes, max_side: int) -> bytes:
|
||||
with Image.open(BytesIO(content)) as img:
|
||||
img = img.convert("RGB")
|
||||
width, height = img.size
|
||||
longest = max(width, height)
|
||||
if longest > max_side:
|
||||
ratio = max_side / longest
|
||||
img = img.resize((int(width * ratio), int(height * ratio)), Image.Resampling.LANCZOS)
|
||||
buf = BytesIO()
|
||||
img.save(buf, format="JPEG", quality=88, optimize=True)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _prepare_ocr_image(image_path: str) -> tuple[str, float, float, int, int, Path | None]:
|
||||
"""若图片过大则生成临时缩小图供 OCR,返回缩放比例与原始尺寸。"""
|
||||
with Image.open(image_path) as img:
|
||||
orig_w, orig_h = img.size
|
||||
|
||||
max_side = settings.OCR_MAX_SIDE
|
||||
longest = max(orig_w, orig_h)
|
||||
if longest <= max_side:
|
||||
return image_path, 1.0, 1.0, orig_w, orig_h, None
|
||||
|
||||
with Image.open(image_path) as img:
|
||||
img = img.convert("RGB")
|
||||
ratio = max_side / longest
|
||||
new_w = max(1, int(orig_w * ratio))
|
||||
new_h = max(1, int(orig_h * ratio))
|
||||
resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
||||
tmp = Path(tempfile.gettempdir()) / f"ocr_{Path(image_path).stem}_{os.getpid()}.jpg"
|
||||
resized.save(tmp, format="JPEG", quality=85, optimize=True)
|
||||
|
||||
scale_x = orig_w / new_w
|
||||
scale_y = orig_h / new_h
|
||||
return str(tmp), scale_x, scale_y, orig_w, orig_h, tmp
|
||||
|
||||
|
||||
def _run_remote_ocr(service_url: str, image_path: str) -> dict:
|
||||
url = f"{service_url.rstrip('/')}/api/ocr/regions"
|
||||
headers: dict[str, str] = {}
|
||||
if settings.OCR_API_KEY:
|
||||
headers["X-OCR-Key"] = settings.OCR_API_KEY
|
||||
with open(image_path, "rb") as handle:
|
||||
files = {"file": (Path(image_path).name, handle, "image/jpeg")}
|
||||
with httpx.Client(timeout=settings.OCR_TIMEOUT_SECONDS) as client:
|
||||
resp = client.post(url, files=files, headers=headers)
|
||||
if resp.status_code >= 400:
|
||||
detail = resp.text
|
||||
try:
|
||||
body = resp.json()
|
||||
if isinstance(body.get("detail"), str):
|
||||
detail = body["detail"]
|
||||
elif isinstance(body.get("detail"), list):
|
||||
detail = str(body["detail"])
|
||||
except Exception:
|
||||
pass
|
||||
raise RuntimeError(f"OCR 服务 {resp.status_code}: {detail}")
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _run_local_ocr(image_path: str) -> dict:
|
||||
engine = get_ocr_engine()
|
||||
result = engine.ocr(image_path, cls=True)
|
||||
if not result or not result[0]:
|
||||
return ""
|
||||
lines = []
|
||||
for line in result[0]:
|
||||
if line and len(line) >= 2:
|
||||
text = line[1][0]
|
||||
if text:
|
||||
lines.append(text)
|
||||
return "\n".join(lines)
|
||||
ocr_path, scale_x, scale_y, orig_w, orig_h, tmp_path = _prepare_ocr_image(image_path)
|
||||
try:
|
||||
result = engine.ocr(ocr_path, cls=False)
|
||||
finally:
|
||||
if tmp_path is not None:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
lines: list[dict] = []
|
||||
if result and result[0]:
|
||||
for item in result[0]:
|
||||
if not item or len(item) < 2:
|
||||
continue
|
||||
box, rec = item[0], item[1]
|
||||
text = rec[0] if rec else ""
|
||||
conf = float(rec[1]) if rec and len(rec) > 1 else 0.0
|
||||
if not text:
|
||||
continue
|
||||
if scale_x != 1.0 or scale_y != 1.0:
|
||||
box = _scale_box(box, scale_x, scale_y)
|
||||
bbox = _bbox_from_box(box)
|
||||
lines.append(
|
||||
{
|
||||
"text": text,
|
||||
"confidence": conf,
|
||||
"box": box,
|
||||
"bbox": bbox,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"text": "\n".join(line["text"] for line in lines),
|
||||
"lines": lines,
|
||||
"width": orig_w,
|
||||
"height": orig_h,
|
||||
}
|
||||
|
||||
|
||||
def run_ocr_with_regions(image_path: str, service_url: str | None = None) -> dict:
|
||||
"""Return OCR text plus line-level bounding boxes for annotation."""
|
||||
remote = resolve_ocr_service_url(service_url)
|
||||
if remote:
|
||||
return _run_remote_ocr(remote, image_path)
|
||||
return _run_local_ocr(image_path)
|
||||
|
||||
|
||||
def run_ocr(image_path: str) -> str:
|
||||
return run_ocr_with_regions(image_path)["text"]
|
||||
|
||||
|
||||
def save_upload_file(user_id: str, question_id: str, filename: str, content: bytes) -> str:
|
||||
@@ -36,5 +203,11 @@ def save_upload_file(user_id: str, question_id: str, filename: str, content: byt
|
||||
user_dir.mkdir(parents=True, exist_ok=True)
|
||||
rel_path = f"{user_id}/{question_id}{ext}"
|
||||
full_path = Path(settings.UPLOAD_DIR) / rel_path
|
||||
full_path.write_bytes(content)
|
||||
normalized = _normalize_image_bytes(content, settings.UPLOAD_MAX_SIDE)
|
||||
full_path.write_bytes(normalized)
|
||||
return rel_path
|
||||
|
||||
|
||||
def annotated_rel_path(original_rel: str) -> str:
|
||||
p = Path(original_rel)
|
||||
return str(p.parent / f"{p.stem}_marked.jpg")
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""OCR 行分类:区分印刷题干与手写作答。"""
|
||||
import re
|
||||
|
||||
# 印刷体/题干常见特征
|
||||
_PRINTED_RE = re.compile(
|
||||
r"(第\s*[0-9一二三四五六七八九十百]+题|"
|
||||
r"[((]\s*[0-9一二三四五六七八九十]+\s*[))]|"
|
||||
r"^\s*[0-9]{1,2}\s*[\..、\)]|"
|
||||
r"^[A-Da-d]\s*[\..、]|"
|
||||
r"选择题|填空题|解答题|证明题|计算题|应用题|"
|
||||
r"下列|以下|正确|错误|不正确|单选|多选|"
|
||||
r"已知|求证|设|若|求|如图|如图所示)",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
# 手写作答常见特征(算式、短碎片)
|
||||
_HANDWRITE_RE = re.compile(
|
||||
r"^[0-9\s+\-×÷*/=≈<>()\[\].,,、%°]+$|"
|
||||
r"^[xXyYzZ]\s*[==]|"
|
||||
r"^\s*\d+\s*[\..]\s*\d*\s*$"
|
||||
)
|
||||
|
||||
|
||||
def _line_center_y(line: dict) -> float:
|
||||
bbox = line.get("bbox") or [0, 0, 0, 0]
|
||||
return (float(bbox[1]) + float(bbox[3])) / 2.0
|
||||
|
||||
|
||||
def _looks_printed(text: str) -> bool:
|
||||
t = text.strip()
|
||||
if len(t) >= 12 and _PRINTED_RE.search(t):
|
||||
return True
|
||||
if _PRINTED_RE.match(t):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _looks_handwritten(text: str, confidence: float) -> bool:
|
||||
t = text.strip()
|
||||
if not t:
|
||||
return True
|
||||
if _looks_printed(t):
|
||||
return False
|
||||
if _HANDWRITE_RE.match(t):
|
||||
return True
|
||||
if len(t) <= 6 and confidence < 0.92:
|
||||
return True
|
||||
digit_ratio = sum(c.isdigit() or c in "+-×÷*/=≈.%" for c in t) / max(len(t), 1)
|
||||
if digit_ratio > 0.55 and len(t) < 20:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def split_printed_handwriting(
|
||||
lines: list[dict],
|
||||
img_height: int,
|
||||
*,
|
||||
answer_zone_ratio: float = 0.45,
|
||||
enabled: bool = True,
|
||||
) -> tuple[list[int], list[int]]:
|
||||
"""
|
||||
返回 (印刷题干行编号, 手写作答行编号),编号为 lines 列表下标。
|
||||
answer_zone_ratio: 图片高度比例,低于此 y 中心视为题干区,高于视为作答区。
|
||||
"""
|
||||
if not lines or not enabled or img_height <= 0:
|
||||
return list(range(len(lines))), []
|
||||
|
||||
split_y = img_height * answer_zone_ratio
|
||||
printed_ids: list[int] = []
|
||||
handwriting_ids: list[int] = []
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
text = line.get("text", "")
|
||||
conf = float(line.get("confidence") or 0.0)
|
||||
cy = _line_center_y(line)
|
||||
|
||||
if _looks_printed(text):
|
||||
printed_ids.append(i)
|
||||
continue
|
||||
|
||||
in_answer_zone = cy >= split_y
|
||||
if in_answer_zone and _looks_handwritten(text, conf):
|
||||
handwriting_ids.append(i)
|
||||
elif not in_answer_zone:
|
||||
printed_ids.append(i)
|
||||
elif in_answer_zone:
|
||||
handwriting_ids.append(i)
|
||||
|
||||
if not printed_ids and lines:
|
||||
printed_ids = list(range(min(3, len(lines))))
|
||||
|
||||
if not handwriting_ids and len(lines) >= 2:
|
||||
handwriting_ids = list(range(max(0, len(lines) - 3), len(lines)))
|
||||
|
||||
return printed_ids, handwriting_ids
|
||||
|
||||
|
||||
def lines_by_indices(lines: list[dict], indices: list[int]) -> list[dict]:
|
||||
return [lines[i] for i in indices if 0 <= i < len(lines)]
|
||||
|
||||
|
||||
def text_from_indices(lines: list[dict], indices: list[int]) -> str:
|
||||
return "\n".join(lines[i].get("text", "") for i in indices if 0 <= i < len(lines)).strip()
|
||||
@@ -1,47 +1,5 @@
|
||||
import httpx
|
||||
"""Backward-compatible wrapper; prefer app.services.llm."""
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.school_level import school_level_label
|
||||
from app.services.llm import format_question, generate_solution, load_ai_config
|
||||
|
||||
QUESTION_PROMPT = """你是一位{stage}老师。以下是从试卷 OCR 识别出的文字,可能含有噪声。
|
||||
科目:{subject}
|
||||
请整理出清晰的题目内容(保留题号、选项、公式),只输出题目正文,不要解释。
|
||||
|
||||
OCR 原文:
|
||||
{ocr_text}
|
||||
"""
|
||||
|
||||
SOLUTION_PROMPT = """你是一位耐心的{stage}{subject}老师。请为以下题目给出详细解法。
|
||||
要求:步骤清晰,语言适合{stage}学生理解,指出考点和易错点。
|
||||
|
||||
题目:
|
||||
{question_text}
|
||||
"""
|
||||
|
||||
|
||||
async def ollama_generate(prompt: str) -> str:
|
||||
url = f"{settings.OLLAMA_BASE_URL.rstrip('/')}/api/generate"
|
||||
payload = {
|
||||
"model": settings.OLLAMA_MODEL,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
response = await client.post(url, json=payload)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return (data.get("response") or "").strip()
|
||||
|
||||
|
||||
async def format_question(subject: str, ocr_text: str, school_level=None) -> str:
|
||||
stage = school_level_label(school_level)
|
||||
prompt = QUESTION_PROMPT.format(stage=stage, subject=subject, ocr_text=ocr_text)
|
||||
return await ollama_generate(prompt)
|
||||
|
||||
|
||||
async def generate_solution(subject: str, question_text: str, school_level=None) -> str:
|
||||
stage = school_level_label(school_level)
|
||||
prompt = SOLUTION_PROMPT.format(
|
||||
stage=stage, subject=subject, question_text=question_text
|
||||
)
|
||||
return await ollama_generate(prompt)
|
||||
__all__ = ["format_question", "generate_solution", "load_ai_config"]
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.user import Subject
|
||||
from app.core.config import settings
|
||||
from app.core.security import get_password_hash
|
||||
from app.models.user import Subject, SystemSettings, User
|
||||
|
||||
DEFAULT_SUBJECTS = [
|
||||
"语文",
|
||||
@@ -21,3 +23,47 @@ def seed_subjects(db: Session) -> None:
|
||||
if name not in existing:
|
||||
db.add(Subject(name=name))
|
||||
db.commit()
|
||||
|
||||
|
||||
def seed_admin_and_settings(db: Session) -> None:
|
||||
row = db.get(SystemSettings, 1)
|
||||
if row is None:
|
||||
db.add(
|
||||
SystemSettings(
|
||||
id=1,
|
||||
registration_enabled=True,
|
||||
ai_provider="ollama",
|
||||
ollama_base_url=settings.OLLAMA_BASE_URL,
|
||||
ollama_model=settings.OLLAMA_MODEL,
|
||||
openai_base_url=settings.OPENAI_BASE_URL,
|
||||
openai_model=settings.OPENAI_MODEL,
|
||||
ocr_service_url=settings.OCR_SERVICE_URL or None,
|
||||
)
|
||||
)
|
||||
else:
|
||||
if not row.ollama_base_url:
|
||||
row.ollama_base_url = settings.OLLAMA_BASE_URL
|
||||
if not row.ollama_model:
|
||||
row.ollama_model = settings.OLLAMA_MODEL
|
||||
if not row.openai_base_url:
|
||||
row.openai_base_url = settings.OPENAI_BASE_URL
|
||||
if not row.openai_model:
|
||||
row.openai_model = settings.OPENAI_MODEL
|
||||
if not row.ocr_service_url and settings.OCR_SERVICE_URL:
|
||||
row.ocr_service_url = settings.OCR_SERVICE_URL
|
||||
if not row.ai_provider:
|
||||
row.ai_provider = "ollama"
|
||||
|
||||
admin = db.query(User).filter(User.username == settings.ADMIN_DEFAULT_USERNAME).first()
|
||||
if admin is None:
|
||||
db.add(
|
||||
User(
|
||||
username=settings.ADMIN_DEFAULT_USERNAME,
|
||||
password_hash=get_password_hash(settings.ADMIN_DEFAULT_PASSWORD),
|
||||
is_superuser=True,
|
||||
)
|
||||
)
|
||||
elif not admin.is_superuser:
|
||||
admin.is_superuser = True
|
||||
|
||||
db.commit()
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,24 @@
|
||||
import re
|
||||
|
||||
# ANSI 颜色/光标控制序列(粘贴终端输出时常见)
|
||||
_ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07]*\x07|\x1b[@-Z\\-_]")
|
||||
|
||||
# 其它不可见控制字符(保留普通 URL 字符)
|
||||
_CTRL_CHARS = re.compile(r"[\x00-\x1f\x7f]")
|
||||
|
||||
|
||||
def sanitize_http_url(url: str | None) -> str:
|
||||
"""去掉 URL 中的 ANSI/控制字符,避免 httpx Invalid non-printable ASCII character。"""
|
||||
if not url:
|
||||
return ""
|
||||
cleaned = _ANSI_ESCAPE.sub("", url)
|
||||
cleaned = _CTRL_CHARS.sub("", cleaned)
|
||||
return cleaned.strip()
|
||||
|
||||
|
||||
def sanitize_model_name(name: str | None) -> str:
|
||||
if not name:
|
||||
return ""
|
||||
cleaned = _ANSI_ESCAPE.sub("", name)
|
||||
cleaned = _CTRL_CHARS.sub("", cleaned)
|
||||
return cleaned.strip()
|
||||
+25
-7
@@ -1,9 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# 中学成绩档案 — 数据备份(数据库 + uploads,统一 tar.gz)
|
||||
set -euo pipefail
|
||||
|
||||
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)
|
||||
ARCHIVE="${BACKUP_DIR}/grade-archive_${TIMESTAMP}.tar.gz"
|
||||
WORK=$(mktemp -d)
|
||||
|
||||
cleanup() {
|
||||
rm -rf "${WORK}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
cd "${INSTALL_DIR}"
|
||||
# shellcheck disable=SC1090
|
||||
@@ -12,11 +20,21 @@ mkdir -p "${BACKUP_DIR}"
|
||||
|
||||
echo "[INFO] 备份数据库…"
|
||||
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…"
|
||||
tar -czf "${BACKUP_DIR}/uploads_${TIMESTAMP}.tar.gz" -C "${INSTALL_DIR}" uploads/
|
||||
cat > "${WORK}/manifest.json" <<EOF
|
||||
{
|
||||
"app": "secondary-school-grade-archive",
|
||||
"created_at": "$(date -Iseconds)",
|
||||
"database": "${POSTGRES_DB}"
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "[INFO] 完成:"
|
||||
echo " ${BACKUP_DIR}/db_${TIMESTAMP}.sql"
|
||||
echo " ${BACKUP_DIR}/uploads_${TIMESTAMP}.tar.gz"
|
||||
echo "[INFO] 打包 uploads…"
|
||||
tar -czf "${ARCHIVE}" -C "${WORK}" database.sql manifest.json -C "${INSTALL_DIR}" uploads/
|
||||
|
||||
echo "[INFO] 完成: ${ARCHIVE}"
|
||||
|
||||
# 清理 30 天前的备份
|
||||
find "${BACKUP_DIR}" -name 'grade-archive_*.tar.gz' -mtime +30 -delete 2>/dev/null || true
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# 本地构建前端并提示提交 dist(开发机执行,服务器不构建)
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
|
||||
Set-Location (Join-Path $Root "frontend")
|
||||
|
||||
Write-Host "[INFO] 安装依赖并构建前端…"
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
if (-not (Test-Path "dist\index.html")) {
|
||||
Write-Error "构建失败:未生成 dist\index.html"
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "构建完成。请提交并推送:"
|
||||
Write-Host " git add frontend/dist"
|
||||
Write-Host ' git commit -m "build: update frontend dist"'
|
||||
Write-Host " git push"
|
||||
Write-Host ""
|
||||
Write-Host "服务器更新:"
|
||||
Write-Host " bash /opt/secondary-school-grade-archive/deploy/update.sh"
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# 本地构建前端并提示提交 dist(开发机执行,服务器不构建)
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "${ROOT}/frontend"
|
||||
|
||||
echo "[INFO] 安装依赖并构建前端…"
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
if [[ ! -f dist/index.html ]]; then
|
||||
echo "[ERROR] 构建失败:未生成 dist/index.html"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "构建完成。请提交并推送:"
|
||||
echo " git add frontend/dist"
|
||||
echo " git commit -m \"build: update frontend dist\""
|
||||
echo " git push"
|
||||
echo ""
|
||||
echo "服务器更新:"
|
||||
echo " bash /opt/secondary-school-grade-archive/deploy/update.sh"
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
# 部署脚本共用函数
|
||||
INSTALL_DIR="${INSTALL_DIR:-/opt/secondary-school-grade-archive}"
|
||||
WEB_PORT="${WEB_PORT:-23566}"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
log_info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
||||
|
||||
require_root() {
|
||||
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
|
||||
log_error "请使用 root 运行: sudo bash $*"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
load_env_port() {
|
||||
if [[ -f "${INSTALL_DIR}/.env" ]]; then
|
||||
# shellcheck disable=SC1090
|
||||
set -a && source "${INSTALL_DIR}/.env" && set +a
|
||||
fi
|
||||
WEB_PORT="${WEB_PORT:-23566}"
|
||||
}
|
||||
|
||||
stop_legacy_pm2() {
|
||||
if command -v pm2 &>/dev/null; then
|
||||
pm2 delete grade-api grade-web 2>/dev/null || true
|
||||
pm2 save 2>/dev/null || true
|
||||
log_info "已停止旧版 PM2 进程"
|
||||
fi
|
||||
}
|
||||
|
||||
setup_systemd_service() {
|
||||
log_info "配置 systemd 服务 grade-archive…"
|
||||
sed "s|/opt/secondary-school-grade-archive|${INSTALL_DIR}|g" \
|
||||
"${INSTALL_DIR}/deploy/grade-archive.service" > /etc/systemd/system/grade-archive.service
|
||||
systemctl daemon-reload
|
||||
systemctl enable grade-archive
|
||||
}
|
||||
|
||||
restart_grade_service() {
|
||||
load_env_port
|
||||
mkdir -p "${INSTALL_DIR}/uploads" "${INSTALL_DIR}/backups"
|
||||
find "${INSTALL_DIR}/deploy" -name "*.sh" -exec chmod +x {} + 2>/dev/null || true
|
||||
log_info "重启 grade-archive(端口 ${WEB_PORT})…"
|
||||
systemctl restart grade-archive
|
||||
}
|
||||
|
||||
wait_healthy() {
|
||||
load_env_port
|
||||
local i
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf "http://127.0.0.1:${WEB_PORT}/api/health" >/dev/null 2>&1; then
|
||||
log_info "健康检查通过 — http://127.0.0.1:${WEB_PORT}"
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
log_error "服务未响应,请查看日志:"
|
||||
journalctl -u grade-archive -n 40 --no-pager || true
|
||||
return 1
|
||||
}
|
||||
|
||||
install_ocr_deps_safe() {
|
||||
if [[ -x "${INSTALL_DIR}/deploy/install-ocr-deps.sh" ]]; then
|
||||
bash "${INSTALL_DIR}/deploy/install-ocr-deps.sh" || log_warn "OCR 依赖安装跳过(可稍后重试)"
|
||||
fi
|
||||
}
|
||||
|
||||
show_service_status() {
|
||||
echo ""
|
||||
systemctl status grade-archive --no-pager -l 2>/dev/null || true
|
||||
echo ""
|
||||
ss -tlnp 2>/dev/null | grep -E ":${WEB_PORT}\s" || echo "端口 ${WEB_PORT} 未监听"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=Secondary School Grade Archive
|
||||
After=network.target postgresql.service
|
||||
Wants=postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/secondary-school-grade-archive
|
||||
ExecStart=/opt/secondary-school-grade-archive/deploy/start.sh
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStartSec=120
|
||||
EnvironmentFile=-/opt/secondary-school-grade-archive/.env
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# PaddleOCR / OpenCV 在无图形界面服务器上所需的系统库
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
|
||||
echo "请使用 root 运行: sudo bash deploy/install-ocr-deps.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
NC='\033[0m'
|
||||
log_info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
||||
|
||||
log_info "安装 OCR 依赖(libGL 等)…"
|
||||
apt-get update -qq
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
|
||||
libgl1 \
|
||||
libglx-mesa0 \
|
||||
libgbm1 \
|
||||
libgomp1 \
|
||||
libglib2.0-0 \
|
||||
libsm6 \
|
||||
libxrender1 \
|
||||
libxext6 \
|
||||
libxcb1 \
|
||||
libfontconfig1
|
||||
|
||||
log_info "OCR 系统依赖已就绪"
|
||||
+171
-90
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# 中学成绩档案系统 — Ubuntu PM2 一键部署
|
||||
# 中学成绩档案系统 — 一键部署
|
||||
# 架构:主程序 + OCR(GPU/screen 同机) | Ollama(其他电脑局域网)
|
||||
# 版权所有 (c) 马建军 微信: dekun03 手机: 18364911125
|
||||
#
|
||||
set -euo pipefail
|
||||
@@ -8,11 +9,12 @@ set -euo pipefail
|
||||
REPO_URL="${REPO_URL:-https://git.bz121.com/dekun/secondary-school-grade-archive.git}"
|
||||
INSTALL_DIR="${INSTALL_DIR:-/opt/secondary-school-grade-archive}"
|
||||
WEB_PORT="${WEB_PORT:-23566}"
|
||||
API_PORT="${API_PORT:-8000}"
|
||||
OCR_PORT="${OCR_PORT:-23567}"
|
||||
BRANCH="${BRANCH:-main}"
|
||||
NODE_MAJOR="${NODE_MAJOR:-20}"
|
||||
PIP_MIRROR="${PIP_MIRROR:-https://pypi.tuna.tsinghua.edu.cn/simple}"
|
||||
NPM_REGISTRY="${NPM_REGISTRY:-https://registry.npmmirror.com}"
|
||||
# Ollama 在其他电脑时安装前指定,例如: OLLAMA_BASE_URL=http://192.168.8.100:11434
|
||||
OLLAMA_BASE_URL="${OLLAMA_BASE_URL:-}"
|
||||
OLLAMA_MODEL="${OLLAMA_MODEL:-qwen2.5:7b}"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
@@ -23,6 +25,11 @@ log_info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
||||
|
||||
# shellcheck source=proxy.sh
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/proxy.sh"
|
||||
# shellcheck source=ocr-common.sh
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/ocr-common.sh"
|
||||
|
||||
require_root() {
|
||||
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
|
||||
log_error "请使用 root 用户运行: sudo bash deploy/install.sh"
|
||||
@@ -41,37 +48,46 @@ check_os() {
|
||||
}
|
||||
|
||||
check_port() {
|
||||
if command -v ss &>/dev/null && ss -tln | grep -q ":${WEB_PORT} "; then
|
||||
log_error "端口 ${WEB_PORT} 已被占用"
|
||||
exit 1
|
||||
for p in "${WEB_PORT}" "${OCR_PORT}"; do
|
||||
if command -v ss &>/dev/null && ss -tln | grep -q ":${p} "; then
|
||||
if [[ "${p}" == "${OCR_PORT}" ]] && ocr_screen_running 2>/dev/null; then
|
||||
log_warn "端口 ${OCR_PORT} 已被占用(可能已有 OCR Worker)"
|
||||
elif [[ "${p}" == "${WEB_PORT}" ]]; then
|
||||
log_error "端口 ${WEB_PORT} 已被占用"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
log_info "Web 端口 ${WEB_PORT} / OCR 端口 ${OCR_PORT} 检查完成"
|
||||
}
|
||||
|
||||
prompt_ollama_url() {
|
||||
if [[ -n "${OLLAMA_BASE_URL}" ]]; then
|
||||
log_info "Ollama 地址: ${OLLAMA_BASE_URL}"
|
||||
return
|
||||
fi
|
||||
log_info "端口 ${WEB_PORT} 可用"
|
||||
if [[ -t 0 ]]; then
|
||||
echo ""
|
||||
echo "Ollama 部署在【其他电脑】上,请填写局域网地址。"
|
||||
read -rp "Ollama 地址 [http://192.168.8.100:11434]: " input
|
||||
OLLAMA_BASE_URL="${input:-http://192.168.8.100:11434}"
|
||||
else
|
||||
OLLAMA_BASE_URL="http://127.0.0.1:11434"
|
||||
log_warn "未设置 OLLAMA_BASE_URL,默认 ${OLLAMA_BASE_URL}(可在 .env 或系统设置中修改)"
|
||||
fi
|
||||
log_info "Ollama 地址: ${OLLAMA_BASE_URL}"
|
||||
}
|
||||
|
||||
install_base_packages() {
|
||||
log_info "安装系统依赖…"
|
||||
apt-get update -qq
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
|
||||
git curl ca-certificates openssl \
|
||||
git curl ca-certificates openssl screen \
|
||||
python3 python3-venv python3-pip python3-dev \
|
||||
build-essential libpq-dev \
|
||||
postgresql postgresql-contrib \
|
||||
libgomp1 libglib2.0-0 libsm6 libxrender1 libxext6
|
||||
}
|
||||
|
||||
install_node_pm2() {
|
||||
if ! command -v node &>/dev/null || [[ "$(node -v | cut -d. -f1 | tr -d v)" -lt "${NODE_MAJOR}" ]]; then
|
||||
log_info "安装 Node.js ${NODE_MAJOR}.x…"
|
||||
curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash -
|
||||
apt-get install -y -qq nodejs
|
||||
fi
|
||||
log_info "Node: $(node -v) npm: $(npm -v)"
|
||||
|
||||
if ! command -v pm2 &>/dev/null; then
|
||||
log_info "安装 PM2…"
|
||||
npm install -g pm2
|
||||
fi
|
||||
log_info "PM2: $(pm2 -v)"
|
||||
libgl1 libglx-mesa0 libgbm1 \
|
||||
libgomp1 libglib2.0-0 libsm6 libxrender1 libxext6 libxcb1 libfontconfig1
|
||||
}
|
||||
|
||||
clone_or_update_repo() {
|
||||
@@ -86,6 +102,27 @@ clone_or_update_repo() {
|
||||
git clone --branch "${BRANCH}" "${REPO_URL}" "${INSTALL_DIR}" || git clone "${REPO_URL}" "${INSTALL_DIR}"
|
||||
fi
|
||||
find "${INSTALL_DIR}" -name "*.sh" -exec sed -i 's/\r$//' {} +
|
||||
chmod +x "${INSTALL_DIR}/deploy/start.sh" \
|
||||
"${INSTALL_DIR}/deploy/ocr-screen.sh" \
|
||||
"${INSTALL_DIR}/deploy/ocr-worker/"*.sh 2>/dev/null || true
|
||||
}
|
||||
|
||||
verify_frontend_dist() {
|
||||
if [[ ! -f "${INSTALL_DIR}/frontend/dist/index.html" ]]; then
|
||||
log_error "未找到 frontend/dist/index.html"
|
||||
log_error "仓库应已包含预构建前端;若缺失请在开发机 npm run build 后推送"
|
||||
exit 1
|
||||
fi
|
||||
log_info "前端静态资源已就绪"
|
||||
}
|
||||
|
||||
env_set_or_append() {
|
||||
local key="$1" val="$2" file="${INSTALL_DIR}/.env"
|
||||
if grep -q "^${key}=" "${file}" 2>/dev/null; then
|
||||
sed -i "s|^${key}=.*|${key}=${val}|" "${file}"
|
||||
else
|
||||
echo "${key}=${val}" >> "${file}"
|
||||
fi
|
||||
}
|
||||
|
||||
generate_env() {
|
||||
@@ -94,36 +131,47 @@ generate_env() {
|
||||
server_ip=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||
server_ip="${server_ip:-127.0.0.1}"
|
||||
|
||||
if [[ -f "${env_file}" ]]; then
|
||||
log_info "保留已有 .env"
|
||||
# shellcheck disable=SC1090
|
||||
set -a && source "${env_file}" && set +a
|
||||
return
|
||||
fi
|
||||
prompt_ollama_url
|
||||
|
||||
local secret pg_pass pg_user
|
||||
secret=$(openssl rand -hex 32)
|
||||
pg_pass=$(openssl rand -hex 16)
|
||||
pg_user="gradeapp"
|
||||
if [[ ! -f "${env_file}" ]]; then
|
||||
local secret pg_pass pg_user
|
||||
secret=$(openssl rand -hex 32)
|
||||
pg_pass=$(openssl rand -hex 16)
|
||||
pg_user="gradeapp"
|
||||
|
||||
cat > "${env_file}" <<EOF
|
||||
cat > "${env_file}" <<EOF
|
||||
# generated by deploy/install.sh — $(date -Iseconds)
|
||||
WEB_PORT=${WEB_PORT}
|
||||
API_PORT=${API_PORT}
|
||||
API_TARGET=http://127.0.0.1:${API_PORT}
|
||||
FRONTEND_DIST=${INSTALL_DIR}/frontend/dist
|
||||
SECRET_KEY=${secret}
|
||||
POSTGRES_USER=${pg_user}
|
||||
POSTGRES_PASSWORD=${pg_pass}
|
||||
POSTGRES_DB=student_archive
|
||||
DATABASE_URL=postgresql://${pg_user}:${pg_pass}@127.0.0.1:5432/student_archive
|
||||
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}
|
||||
OLLAMA_BASE_URL=http://127.0.0.1:11434
|
||||
OLLAMA_MODEL=qwen2.5:7b
|
||||
# OCR 同机 GPU Worker(screen 常驻)
|
||||
OCR_SERVICE_URL=http://127.0.0.1:${OCR_PORT}
|
||||
OCR_PORT=${OCR_PORT}
|
||||
# Ollama 在其他电脑(局域网)
|
||||
OLLAMA_BASE_URL=${OLLAMA_BASE_URL}
|
||||
OLLAMA_MODEL=${OLLAMA_MODEL}
|
||||
FLUCTUATION_THRESHOLD=0.08
|
||||
ADMIN_DEFAULT_USERNAME=admin
|
||||
ADMIN_DEFAULT_PASSWORD=admin123
|
||||
EOF
|
||||
chmod 600 "${env_file}"
|
||||
log_info ".env 已生成"
|
||||
chmod 600 "${env_file}"
|
||||
log_info ".env 已生成(默认 admin / admin123)"
|
||||
else
|
||||
log_info "更新 .env 中的 OCR / Ollama 配置…"
|
||||
env_set_or_append "OCR_SERVICE_URL" "http://127.0.0.1:${OCR_PORT}"
|
||||
env_set_or_append "OCR_PORT" "${OCR_PORT}"
|
||||
env_set_or_append "OLLAMA_BASE_URL" "${OLLAMA_BASE_URL}"
|
||||
env_set_or_append "OLLAMA_MODEL" "${OLLAMA_MODEL}"
|
||||
fi
|
||||
}
|
||||
|
||||
setup_postgresql() {
|
||||
@@ -146,9 +194,11 @@ setup_postgresql() {
|
||||
}
|
||||
|
||||
setup_backend() {
|
||||
log_info "安装 Python 依赖(显示完整进度,Paddle 包较大,约 10–30 分钟)…"
|
||||
log_info "安装主程序 Python 依赖…"
|
||||
cd "${INSTALL_DIR}/backend"
|
||||
python3 -m venv venv
|
||||
if [[ ! -d venv ]]; then
|
||||
python3 -m venv venv
|
||||
fi
|
||||
# shellcheck disable=SC1091
|
||||
source venv/bin/activate
|
||||
pip install --upgrade pip --progress-bar on -i "${PIP_MIRROR}"
|
||||
@@ -156,91 +206,122 @@ setup_backend() {
|
||||
deactivate
|
||||
}
|
||||
|
||||
setup_frontend() {
|
||||
log_info "构建前端…"
|
||||
cd "${INSTALL_DIR}/frontend"
|
||||
npm config set registry "${NPM_REGISTRY}"
|
||||
npm ci
|
||||
npm run build
|
||||
}
|
||||
|
||||
setup_gateway() {
|
||||
log_info "安装 Web 网关依赖…"
|
||||
cd "${INSTALL_DIR}/deploy/pm2"
|
||||
npm config set registry "${NPM_REGISTRY}"
|
||||
npm ci
|
||||
}
|
||||
|
||||
setup_pm2_startup() {
|
||||
log_info "配置 PM2 开机自启…"
|
||||
local startup_cmd
|
||||
startup_cmd=$(pm2 startup systemd -u root --hp /root 2>&1 | grep -E '^sudo ' | tail -1 || true)
|
||||
if [[ -n "${startup_cmd}" ]]; then
|
||||
# shellcheck disable=SC2086
|
||||
eval ${startup_cmd} || log_warn "PM2 开机自启配置失败,可稍后手动执行: pm2 startup"
|
||||
setup_ocr_gpu() {
|
||||
if command -v nvidia-smi >/dev/null; then
|
||||
log_info "检测到 NVIDIA GPU,OCR 将常驻显存 (screen)"
|
||||
nvidia-smi --query-gpu=name,memory.total --format=csv,noheader 2>/dev/null || true
|
||||
else
|
||||
log_warn "未获取 PM2 startup 命令,重启后需手动 pm2 resurrect"
|
||||
log_warn "未检测到 NVIDIA GPU,OCR 将使用 CPU(较慢)"
|
||||
fi
|
||||
if [[ -x "${INSTALL_DIR}/deploy/install-ocr-deps.sh" ]]; then
|
||||
bash "${INSTALL_DIR}/deploy/install-ocr-deps.sh" || log_warn "OCR 系统库安装跳过"
|
||||
fi
|
||||
install_ocr_worker
|
||||
start_ocr_screen
|
||||
wait_ocr_healthy 30 || log_warn "OCR 后台加载中,继续安装主程序…"
|
||||
}
|
||||
|
||||
stop_legacy_pm2() {
|
||||
if command -v pm2 &>/dev/null; then
|
||||
pm2 delete grade-api grade-web 2>/dev/null || true
|
||||
pm2 save 2>/dev/null || true
|
||||
log_info "已停止旧版 PM2 进程"
|
||||
fi
|
||||
}
|
||||
|
||||
start_pm2() {
|
||||
log_info "启动 PM2 服务…"
|
||||
setup_systemd() {
|
||||
log_info "配置 systemd 服务 grade-archive(主程序)…"
|
||||
sed "s|/opt/secondary-school-grade-archive|${INSTALL_DIR}|g" \
|
||||
"${INSTALL_DIR}/deploy/grade-archive.service" > /etc/systemd/system/grade-archive.service
|
||||
systemctl daemon-reload
|
||||
systemctl enable grade-archive
|
||||
}
|
||||
|
||||
start_service() {
|
||||
log_info "启动主程序…"
|
||||
cd "${INSTALL_DIR}"
|
||||
mkdir -p uploads backups
|
||||
pm2 delete grade-api grade-web 2>/dev/null || true
|
||||
pm2 start deploy/pm2/ecosystem.config.cjs
|
||||
pm2 save
|
||||
setup_pm2_startup
|
||||
mkdir -p uploads backups /root/grade-archive-backups
|
||||
chmod +x deploy/backup.sh deploy/restore.sh 2>/dev/null || true
|
||||
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() {
|
||||
local i
|
||||
log_info "等待主程序就绪(最多 2 分钟)…"
|
||||
for i in $(seq 1 40); do
|
||||
if curl -sf "http://127.0.0.1:${WEB_PORT}/api/health" >/dev/null; then
|
||||
log_info "健康检查通过"
|
||||
log_info "主程序健康检查通过"
|
||||
return 0
|
||||
fi
|
||||
if (( i % 5 == 0 )); then
|
||||
echo -ne "\r${YELLOW}[INFO]${NC} 等待主程序… ${i}/40"
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
log_warn "健康检查超时,请查看: pm2 logs"
|
||||
echo ""
|
||||
log_warn "主程序健康检查超时: journalctl -u grade-archive -f"
|
||||
}
|
||||
|
||||
print_summary() {
|
||||
# shellcheck disable=SC1090
|
||||
source "${INSTALL_DIR}/.env"
|
||||
local ip
|
||||
ip=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||
ip="${ip:-127.0.0.1}"
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " 中学成绩档案系统 PM2 部署完成"
|
||||
echo " 中学成绩档案 — 一键部署完成"
|
||||
echo " 版权所有 (c) 马建军"
|
||||
echo "=========================================="
|
||||
echo " 访问: http://${ip}:${WEB_PORT}"
|
||||
echo " 目录: ${INSTALL_DIR}"
|
||||
echo " 访问: http://${ip}:${WEB_PORT}"
|
||||
echo " 管理员: admin / admin123(请立即修改)"
|
||||
echo ""
|
||||
echo " pm2 status"
|
||||
echo " pm2 logs"
|
||||
echo " bash ${INSTALL_DIR}/deploy/update.sh"
|
||||
echo " bash ${INSTALL_DIR}/deploy/backup.sh"
|
||||
echo " 【同机 OCR — GPU 常驻 screen】"
|
||||
echo " OCR 地址: http://127.0.0.1:${OCR_PORT}"
|
||||
echo " 状态: bash ${INSTALL_DIR}/deploy/ocr-screen.sh status"
|
||||
echo " 进入终端: screen -r ocr-worker (Ctrl+A D 退出)"
|
||||
echo " 重启 OCR: bash ${INSTALL_DIR}/deploy/ocr-screen.sh restart"
|
||||
echo ""
|
||||
echo " 反向代理请自行配置,本项目不包含"
|
||||
echo " 【Ollama — 其他电脑】"
|
||||
echo " 地址: ${OLLAMA_BASE_URL}"
|
||||
echo " 模型: ${OLLAMA_MODEL}"
|
||||
echo " 可在「系统设置 → AI 模型」修改"
|
||||
echo ""
|
||||
echo " 主程序: systemctl status grade-archive"
|
||||
echo " 更新: sudo bash ${INSTALL_DIR}/deploy/update.sh"
|
||||
echo " 备份说明: docs/BACKUP.md"
|
||||
echo " 卸载: sudo bash ${INSTALL_DIR}/deploy/uninstall.sh"
|
||||
echo " 微信 dekun03 手机 18364911125"
|
||||
echo "=========================================="
|
||||
}
|
||||
|
||||
main() {
|
||||
log_info "PM2 一键部署开始"
|
||||
log_info "一键部署开始(主程序 + OCR/GPU/screen | Ollama 外置)"
|
||||
require_root
|
||||
setup_deploy_proxy
|
||||
check_os
|
||||
clone_or_update_repo
|
||||
verify_frontend_dist
|
||||
check_port
|
||||
install_base_packages
|
||||
install_node_pm2
|
||||
clone_or_update_repo
|
||||
generate_env
|
||||
setup_postgresql
|
||||
setup_backend
|
||||
setup_frontend
|
||||
setup_gateway
|
||||
start_pm2
|
||||
setup_ocr_gpu
|
||||
stop_legacy_pm2
|
||||
setup_systemd
|
||||
setup_backup_cron
|
||||
start_service
|
||||
wait_healthy
|
||||
print_summary
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env bash
|
||||
# OCR Worker 共用函数:同机 GPU 识别 + screen 常驻
|
||||
OCR_SCREEN_NAME="${OCR_SCREEN_NAME:-ocr-worker}"
|
||||
OCR_PORT="${OCR_PORT:-23567}"
|
||||
|
||||
ocr_worker_dir() {
|
||||
echo "${INSTALL_DIR}/deploy/ocr-worker"
|
||||
}
|
||||
|
||||
detect_ocr_use_gpu() {
|
||||
if [[ "${OCR_USE_GPU:-auto}" == "false" ]]; then
|
||||
echo "false"
|
||||
return
|
||||
fi
|
||||
if [[ "${OCR_USE_GPU:-auto}" == "true" ]]; then
|
||||
if ldconfig -p 2>/dev/null | grep -q libcudnn; then
|
||||
echo "true"
|
||||
else
|
||||
log_warn "未检测到 cuDNN 库,GPU OCR 不可用,改用 CPU"
|
||||
echo "false"
|
||||
fi
|
||||
return
|
||||
fi
|
||||
# auto
|
||||
if command -v nvidia-smi >/dev/null && ldconfig -p 2>/dev/null | grep -q libcudnn; then
|
||||
echo "true"
|
||||
else
|
||||
echo "false"
|
||||
fi
|
||||
}
|
||||
|
||||
install_ocr_worker() {
|
||||
local worker_dir
|
||||
worker_dir="$(ocr_worker_dir)"
|
||||
if [[ ! -d "${worker_dir}" ]]; then
|
||||
log_error "未找到 ${worker_dir}"
|
||||
return 1
|
||||
fi
|
||||
log_info "安装/更新 OCR Worker (RapidOCR/ONNX)…"
|
||||
chmod +x "${worker_dir}"/*.sh 2>/dev/null || true
|
||||
OCR_PORT="${OCR_PORT}" bash "${worker_dir}/install.sh"
|
||||
}
|
||||
|
||||
ocr_screen_running() {
|
||||
screen -list 2>/dev/null | grep -q "\.${OCR_SCREEN_NAME}[[:space:]]"
|
||||
}
|
||||
|
||||
start_ocr_screen() {
|
||||
local worker_dir log_file
|
||||
worker_dir="$(ocr_worker_dir)"
|
||||
if [[ ! -x "${worker_dir}/.venv/bin/uvicorn" ]]; then
|
||||
log_warn "OCR Worker 未安装,跳过 screen 启动"
|
||||
return 1
|
||||
fi
|
||||
if ! command -v screen >/dev/null; then
|
||||
log_error "未安装 screen,请 apt install screen"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "启动 OCR Worker → screen「${OCR_SCREEN_NAME}」(RapidOCR, 端口 ${OCR_PORT})"
|
||||
if ocr_screen_running; then
|
||||
screen -S "${OCR_SCREEN_NAME}" -X quit 2>/dev/null || true
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
mkdir -p "${INSTALL_DIR}/logs" 2>/dev/null || true
|
||||
log_file="${INSTALL_DIR}/logs/ocr-worker.log"
|
||||
|
||||
screen -dmS "${OCR_SCREEN_NAME}" bash -c "
|
||||
cd '${worker_dir}' &&
|
||||
export OCR_PORT='${OCR_PORT}' OCR_HOST=0.0.0.0 &&
|
||||
exec bash run.sh >> '${log_file}' 2>&1
|
||||
"
|
||||
sleep 2
|
||||
log_info "OCR 日志: ${log_file}"
|
||||
}
|
||||
|
||||
stop_ocr_screen() {
|
||||
if ocr_screen_running; then
|
||||
log_info "停止 OCR screen 会话…"
|
||||
screen -S "${OCR_SCREEN_NAME}" -X quit 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
wait_ocr_healthy() {
|
||||
local max="${1:-30}"
|
||||
local i
|
||||
log_info "等待 OCR 就绪(最多 ${max}×2 秒,首次加载模型较慢)…"
|
||||
for i in $(seq 1 "${max}"); do
|
||||
if curl -sf "http://127.0.0.1:${OCR_PORT}/health" >/dev/null 2>&1; then
|
||||
log_info "OCR 健康检查通过 — http://127.0.0.1:${OCR_PORT}/health"
|
||||
return 0
|
||||
fi
|
||||
if (( i % 5 == 0 )); then
|
||||
echo -ne "\r${YELLOW}[INFO]${NC} 仍在等待 OCR… ${i}/${max}(可另开终端: tail -f ${INSTALL_DIR}/logs/ocr-worker.log)"
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo ""
|
||||
log_warn "OCR 尚未响应(可能仍在下载/加载模型,主程序可先继续)"
|
||||
log_warn "稍后执行: bash ${INSTALL_DIR}/deploy/ocr-screen.sh status"
|
||||
return 1
|
||||
}
|
||||
|
||||
show_ocr_status() {
|
||||
echo ""
|
||||
echo "--- OCR Worker (screen: ${OCR_SCREEN_NAME}) ---"
|
||||
if ocr_screen_running; then
|
||||
echo "screen: 运行中"
|
||||
screen -list 2>/dev/null | grep "${OCR_SCREEN_NAME}" || true
|
||||
else
|
||||
echo "screen: 未运行"
|
||||
fi
|
||||
if curl -sf "http://127.0.0.1:${OCR_PORT}/health" 2>/dev/null; then
|
||||
echo ""
|
||||
else
|
||||
echo "health: 无响应 (http://127.0.0.1:${OCR_PORT}/health)"
|
||||
fi
|
||||
if command -v nvidia-smi >/dev/null; then
|
||||
nvidia-smi --query-gpu=name,memory.used,memory.total,utilization.gpu --format=csv,noheader 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
# OCR Worker screen 管理(主程序同机部署时使用)
|
||||
set -euo pipefail
|
||||
|
||||
INSTALL_DIR="${INSTALL_DIR:-/opt/secondary-school-grade-archive}"
|
||||
|
||||
# shellcheck source=common.sh
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh"
|
||||
# shellcheck source=ocr-common.sh
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/ocr-common.sh"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
用法: bash deploy/ocr-screen.sh <命令>
|
||||
|
||||
start 启动 OCR Worker(screen 后台,GPU 常驻)
|
||||
stop 停止 screen 会话
|
||||
restart 重启
|
||||
status 查看 screen / health / GPU
|
||||
attach 进入 screen 终端(Ctrl+A D 退出)
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
cmd="${1:-status}"
|
||||
|
||||
case "${cmd}" in
|
||||
start)
|
||||
start_ocr_screen
|
||||
wait_ocr_healthy || true
|
||||
;;
|
||||
stop)
|
||||
stop_ocr_screen
|
||||
;;
|
||||
restart)
|
||||
stop_ocr_screen
|
||||
sleep 1
|
||||
start_ocr_screen
|
||||
wait_ocr_healthy || true
|
||||
;;
|
||||
status)
|
||||
show_ocr_status
|
||||
;;
|
||||
attach)
|
||||
if ocr_screen_running; then
|
||||
screen -r "${OCR_SCREEN_NAME}"
|
||||
else
|
||||
log_error "screen 未运行,请先: bash deploy/ocr-screen.sh start"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,127 @@
|
||||
"""局域网 OCR 服务:RapidOCR(ONNX),不依赖 Paddle,避免 SIGILL/cuDNN 问题。"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, File, Header, HTTPException, UploadFile
|
||||
from PIL import Image
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
logger = logging.getLogger("ocr-worker")
|
||||
|
||||
OCR_MAX_SIDE = int(os.getenv("OCR_MAX_SIDE", "1280"))
|
||||
OCR_API_KEY = os.getenv("OCR_API_KEY", "").strip()
|
||||
|
||||
app = FastAPI(title="Grade Archive OCR Worker", version="2.0.0")
|
||||
_engine = None
|
||||
|
||||
|
||||
def _check_key(key: str | None) -> None:
|
||||
if OCR_API_KEY and key != OCR_API_KEY:
|
||||
raise HTTPException(status_code=401, detail="Invalid OCR API key")
|
||||
|
||||
|
||||
def get_engine():
|
||||
global _engine
|
||||
if _engine is None:
|
||||
from rapidocr_onnxruntime import RapidOCR
|
||||
|
||||
logger.info("Loading RapidOCR (ONNX CPU)…")
|
||||
_engine = RapidOCR()
|
||||
logger.info("RapidOCR ready")
|
||||
return _engine
|
||||
|
||||
|
||||
def _bbox_from_box(box: list) -> list[float]:
|
||||
xs = [float(p[0]) for p in box]
|
||||
ys = [float(p[1]) for p in box]
|
||||
return [min(xs), min(ys), max(xs), max(ys)]
|
||||
|
||||
|
||||
def _scale_box(box: list, scale_x: float, scale_y: float) -> list:
|
||||
return [[float(p[0]) * scale_x, float(p[1]) * scale_y] for p in box]
|
||||
|
||||
|
||||
def _prepare_image_bytes(content: bytes) -> tuple[bytes, float, float, int, int]:
|
||||
with Image.open(BytesIO(content)) as img:
|
||||
img = img.convert("RGB")
|
||||
orig_w, orig_h = img.size
|
||||
longest = max(orig_w, orig_h)
|
||||
if longest <= OCR_MAX_SIDE:
|
||||
buf = BytesIO()
|
||||
img.save(buf, format="JPEG", quality=88)
|
||||
return buf.getvalue(), 1.0, 1.0, orig_w, orig_h
|
||||
|
||||
ratio = OCR_MAX_SIDE / longest
|
||||
new_w = max(1, int(orig_w * ratio))
|
||||
new_h = max(1, int(orig_h * ratio))
|
||||
resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
||||
buf = BytesIO()
|
||||
resized.save(buf, format="JPEG", quality=85)
|
||||
scale_x = orig_w / new_w
|
||||
scale_y = orig_h / new_h
|
||||
return buf.getvalue(), scale_x, scale_y, orig_w, orig_h
|
||||
|
||||
|
||||
def run_ocr_on_bytes(content: bytes) -> dict:
|
||||
engine = get_engine()
|
||||
image_bytes, scale_x, scale_y, orig_w, orig_h = _prepare_image_bytes(content)
|
||||
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
|
||||
tmp.write(image_bytes)
|
||||
tmp_path = tmp.name
|
||||
try:
|
||||
result, _elapsed = engine(tmp_path)
|
||||
finally:
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
|
||||
lines: list[dict] = []
|
||||
if result:
|
||||
for item in result:
|
||||
if not item or len(item) < 2:
|
||||
continue
|
||||
box, text = item[0], item[1]
|
||||
conf = float(item[2]) if len(item) > 2 else 0.0
|
||||
if not text:
|
||||
continue
|
||||
if scale_x != 1.0 or scale_y != 1.0:
|
||||
box = _scale_box(box, scale_x, scale_y)
|
||||
lines.append(
|
||||
{
|
||||
"text": str(text),
|
||||
"confidence": conf,
|
||||
"box": box,
|
||||
"bbox": _bbox_from_box(box),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"text": "\n".join(line["text"] for line in lines),
|
||||
"lines": lines,
|
||||
"width": orig_w,
|
||||
"height": orig_h,
|
||||
"engine_mode": "rapidocr-onnx",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok", "engine": "rapidocr-onnxruntime"}
|
||||
|
||||
|
||||
@app.post("/api/ocr/regions")
|
||||
async def ocr_regions(
|
||||
file: UploadFile = File(...),
|
||||
x_ocr_key: str | None = Header(default=None, alias="X-OCR-Key"),
|
||||
):
|
||||
_check_key(x_ocr_key)
|
||||
content = await file.read()
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="Empty image")
|
||||
try:
|
||||
return run_ocr_on_bytes(content)
|
||||
except Exception as exc:
|
||||
logger.exception("OCR failed")
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
# OCR Worker 一键诊断
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
PORT="${OCR_PORT:-23567}"
|
||||
VENV="${ROOT}/.venv"
|
||||
|
||||
echo "========== OCR Worker 诊断 =========="
|
||||
echo "目录: ${ROOT}"
|
||||
echo "端口: ${PORT}"
|
||||
echo ""
|
||||
|
||||
echo "--- 1. 虚拟环境 ---"
|
||||
if [[ -d "${VENV}" ]]; then
|
||||
echo "OK .venv 存在"
|
||||
else
|
||||
echo "FAIL 未安装,请运行: bash install.sh"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "--- 2. 端口监听 ---"
|
||||
if command -v ss >/dev/null; then
|
||||
if ss -tln | grep -q ":${PORT} "; then
|
||||
echo "OK 端口 ${PORT} 正在监听"
|
||||
ss -tlnp | grep ":${PORT} " || true
|
||||
else
|
||||
echo "FAIL 端口 ${PORT} 无服务 — 请先启动:"
|
||||
echo " OCR_USE_GPU=true bash start.sh"
|
||||
echo " 或: sudo bash install-service.sh"
|
||||
fi
|
||||
else
|
||||
netstat -tln 2>/dev/null | grep ":${PORT} " || echo "FAIL 端口 ${PORT} 无服务"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "--- 3. HTTP 健康检查 ---"
|
||||
if command -v curl >/dev/null; then
|
||||
resp="$(curl -sS -m 3 "http://127.0.0.1:${PORT}/health" 2>&1)" || true
|
||||
if [[ -n "${resp}" ]]; then
|
||||
echo "OK ${resp}"
|
||||
else
|
||||
echo "FAIL curl 无响应(服务未启动或启动失败)"
|
||||
fi
|
||||
else
|
||||
echo "跳过(无 curl)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "--- 4. GPU ---"
|
||||
if command -v nvidia-smi >/dev/null; then
|
||||
nvidia-smi --query-gpu=name,memory.used,memory.total --format=csv,noheader
|
||||
else
|
||||
echo "未检测到 nvidia-smi"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "--- 5. Python 依赖 ---"
|
||||
if [[ -d "${VENV}" ]]; then
|
||||
# shellcheck disable=SC1091
|
||||
source "${VENV}/bin/activate"
|
||||
python3 - <<'PY' || true
|
||||
try:
|
||||
import paddle
|
||||
print("OK paddle", paddle.__version__)
|
||||
except Exception as e:
|
||||
print("FAIL paddle:", e)
|
||||
try:
|
||||
import paddleocr
|
||||
print("OK paddleocr")
|
||||
except Exception as e:
|
||||
print("FAIL paddleocr:", e)
|
||||
try:
|
||||
import fastapi, uvicorn
|
||||
print("OK fastapi/uvicorn")
|
||||
except Exception as e:
|
||||
print("FAIL fastapi:", e)
|
||||
PY
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "--- 6. systemd ---"
|
||||
if systemctl is-active ocr-worker &>/dev/null; then
|
||||
systemctl status ocr-worker --no-pager -l | head -15
|
||||
elif [[ -f /etc/systemd/system/ocr-worker.service ]]; then
|
||||
echo "服务已安装但未运行: sudo systemctl start ocr-worker"
|
||||
else
|
||||
echo "未安装 systemd 服务(可选): sudo bash install-service.sh"
|
||||
fi
|
||||
|
||||
echo "===================================="
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
# 注册 OCR Worker 为 systemd 服务(后台常驻)
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
PORT="${OCR_PORT:-23567}"
|
||||
|
||||
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
|
||||
echo "请使用 root 运行: sudo bash install-service.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "${ROOT}/.venv" ]]; then
|
||||
echo "请先运行: bash ${ROOT}/install.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
chmod +x "${ROOT}/start.sh" "${ROOT}/check.sh" "${ROOT}/install.sh"
|
||||
|
||||
cat > /etc/systemd/system/ocr-worker.service <<EOF
|
||||
[Unit]
|
||||
Description=Grade Archive OCR Worker (PaddleOCR)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=${ROOT}
|
||||
Environment=OCR_USE_GPU=true
|
||||
Environment=OCR_PORT=${PORT}
|
||||
Environment=OCR_HOST=0.0.0.0
|
||||
ExecStart=${ROOT}/.venv/bin/uvicorn app:app --host 0.0.0.0 --port ${PORT} --log-level info
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable ocr-worker
|
||||
systemctl restart ocr-worker
|
||||
|
||||
sleep 2
|
||||
echo ""
|
||||
echo "==> 服务状态:"
|
||||
systemctl status ocr-worker --no-pager -l | head -20
|
||||
|
||||
echo ""
|
||||
echo "==> 健康检查:"
|
||||
curl -sS "http://127.0.0.1:${PORT}/health" || echo "(尚未就绪,请稍等 30 秒后重试: bash check.sh)"
|
||||
echo ""
|
||||
echo "局域网地址: http://$(hostname -I 2>/dev/null | awk '{print $1}'):${PORT}"
|
||||
echo "成绩档案系统设置 OCR 地址填: http://192.168.8.6:${PORT} (按实际 IP 修改)"
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# OCR Worker 安装(RapidOCR / ONNX,无需 Paddle/GPU)
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
VENV="${ROOT}/.venv"
|
||||
PORT="${OCR_PORT:-23567}"
|
||||
PIP_MIRROR="${PIP_MIRROR:-https://pypi.tuna.tsinghua.edu.cn/simple}"
|
||||
|
||||
echo "==> OCR Worker 安装目录: ${ROOT}"
|
||||
echo "==> 引擎: RapidOCR (ONNX CPU,无需 cuDNN/GPU)"
|
||||
|
||||
if ! command -v python3 >/dev/null; then
|
||||
echo "错误: 请先安装 python3"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -d "${VENV}" ]]; then
|
||||
echo "==> 已有虚拟环境"
|
||||
else
|
||||
python3 -m venv "${VENV}"
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source "${VENV}/bin/activate"
|
||||
pip install -U pip wheel -i "${PIP_MIRROR}"
|
||||
pip uninstall -y paddlepaddle paddlepaddle-gpu paddleocr 2>/dev/null || true
|
||||
pip install -r "${ROOT}/requirements.txt" -i "${PIP_MIRROR}"
|
||||
chmod +x "${ROOT}/run.sh" "${ROOT}/start.sh" 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
python3 -c "from rapidocr_onnxruntime import RapidOCR; print('RapidOCR OK')"
|
||||
echo ""
|
||||
echo "==> 安装完成。管理: bash $(dirname "$ROOT")/ocr-screen.sh status"
|
||||
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=Grade Archive OCR Worker (PaddleOCR GPU)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/ocr-worker
|
||||
Environment=OCR_USE_GPU=true
|
||||
Environment=OCR_PORT=23567
|
||||
Environment=OCR_HOST=0.0.0.0
|
||||
# Environment=OCR_API_KEY=请设置随机密钥
|
||||
ExecStart=/opt/ocr-worker/.venv/bin/uvicorn app:app --host 0.0.0.0 --port 23567
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,6 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
python-multipart==0.0.20
|
||||
Pillow==11.0.0
|
||||
rapidocr-onnxruntime>=1.3.0
|
||||
onnxruntime>=1.16.0
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# 供 screen 会话调用:OCR 模型常驻 GPU,异常退出自动重启
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
VENV="${ROOT}/.venv"
|
||||
PORT="${OCR_PORT:-23567}"
|
||||
|
||||
export OCR_USE_GPU="${OCR_USE_GPU:-false}"
|
||||
export OCR_HOST="${OCR_HOST:-0.0.0.0}"
|
||||
|
||||
if [[ ! -d "${VENV}" ]]; then
|
||||
echo "错误: 请先运行 bash install.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source "${VENV}/bin/activate"
|
||||
cd "${ROOT}"
|
||||
|
||||
echo "=========================================="
|
||||
echo " OCR Worker | GPU=${OCR_USE_GPU} | :${PORT}"
|
||||
echo " $(date -Iseconds)"
|
||||
echo " 退出 screen: Ctrl+A 然后 D"
|
||||
echo "=========================================="
|
||||
|
||||
while true; do
|
||||
uvicorn app:app --host "${OCR_HOST}" --port "${PORT}" --log-level info
|
||||
code=$?
|
||||
echo "[$(date -Iseconds)] uvicorn 退出 code=${code},5 秒后重启…"
|
||||
sleep 5
|
||||
done
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
VENV="${ROOT}/.venv"
|
||||
HOST="${OCR_HOST:-0.0.0.0}"
|
||||
PORT="${OCR_PORT:-23567}"
|
||||
|
||||
export OCR_USE_GPU="${OCR_USE_GPU:-true}"
|
||||
export OCR_HOST="${HOST}"
|
||||
export OCR_PORT="${PORT}"
|
||||
|
||||
if [[ ! -d "${VENV}" ]]; then
|
||||
echo "未找到虚拟环境,请先在本目录运行: bash install.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source "${VENV}/bin/activate"
|
||||
cd "${ROOT}"
|
||||
|
||||
echo "==> 启动 OCR Worker: http://${HOST}:${PORT} (GPU=${OCR_USE_GPU})"
|
||||
echo " 按 Ctrl+C 停止。后台运行请用: sudo bash install-service.sh"
|
||||
echo ""
|
||||
|
||||
exec uvicorn app:app --host "${HOST}" --port "${PORT}" --log-level info
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
PORT="${OCR_PORT:-23567}"
|
||||
TMP="/tmp/ocr-test-$$.jpg"
|
||||
|
||||
if [[ ! -d "${ROOT}/.venv" ]]; then
|
||||
echo "请先 bash install.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source "${ROOT}/.venv/bin/activate"
|
||||
python3 -c "from PIL import Image; Image.new('RGB',(200,80),(255,255,255)).save('${TMP}')"
|
||||
|
||||
echo "==> GET /health"
|
||||
curl -sS "http://127.0.0.1:${PORT}/health" || { echo "FAIL: OCR 未启动"; exit 1; }
|
||||
echo ""
|
||||
|
||||
echo "==> POST /api/ocr/regions"
|
||||
curl -sS -w "\nHTTP %{http_code}\n" -F "file=@${TMP};type=image/jpeg" \
|
||||
"http://127.0.0.1:${PORT}/api/ocr/regions"
|
||||
rm -f "${TMP}"
|
||||
@@ -27,7 +27,7 @@ function loadEnv() {
|
||||
|
||||
const env = loadEnv()
|
||||
const webPort = env.WEB_PORT || '23566'
|
||||
const apiPort = env.API_PORT || '8000'
|
||||
const apiPort = env.API_PORT || '23568'
|
||||
const venvPython = path.join(root, 'backend', 'venv', 'bin', 'python')
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -9,7 +9,7 @@ if (fs.existsSync(envPath)) {
|
||||
}
|
||||
|
||||
const PORT = Number(process.env.WEB_PORT || 23566)
|
||||
const API_TARGET = process.env.API_TARGET || 'http://127.0.0.1:8000'
|
||||
const API_TARGET = process.env.API_TARGET || 'http://127.0.0.1:23568'
|
||||
const STATIC_ROOT = path.join(__dirname, '../../frontend/dist')
|
||||
|
||||
const app = express()
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
# 部署脚本共用:读取 HTTP_PROXY / http_proxy 并配置 apt、git、pip、curl
|
||||
# 用法(install.sh / update.sh 前):
|
||||
# export http_proxy=http://192.168.8.246:10810
|
||||
# export https_proxy=http://192.168.8.246:10810
|
||||
# export HTTP_PROXY="$http_proxy"
|
||||
# export HTTPS_PROXY="$https_proxy"
|
||||
|
||||
setup_deploy_proxy() {
|
||||
local proxy="${HTTP_PROXY:-${http_proxy:-}}"
|
||||
if [[ -z "$proxy" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$proxy" != http://* && "$proxy" != https://* && "$proxy" != socks5://* ]]; then
|
||||
proxy="http://${proxy}"
|
||||
fi
|
||||
|
||||
export http_proxy="$proxy"
|
||||
export https_proxy="${HTTPS_PROXY:-${https_proxy:-$proxy}}"
|
||||
export HTTP_PROXY="$http_proxy"
|
||||
export HTTPS_PROXY="$https_proxy"
|
||||
export ALL_PROXY="${ALL_PROXY:-$http_proxy}"
|
||||
|
||||
if [[ -n "${log_info:-}" ]]; then
|
||||
log_info "使用网络代理: ${http_proxy}"
|
||||
else
|
||||
echo "[INFO] 使用网络代理: ${http_proxy}"
|
||||
fi
|
||||
|
||||
mkdir -p /etc/apt/apt.conf.d
|
||||
cat > /etc/apt/apt.conf.d/95grade-archive-proxy <<EOF
|
||||
Acquire::http::Proxy "${http_proxy}";
|
||||
Acquire::https::Proxy "${https_proxy}";
|
||||
EOF
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# 服务无法访问(连接被拒绝)时一键修复
|
||||
# 用法: sudo bash deploy/repair.sh
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
INSTALL_DIR="${INSTALL_DIR:-/opt/secondary-school-grade-archive}"
|
||||
|
||||
# shellcheck source=common.sh
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh"
|
||||
# shellcheck source=ocr-common.sh
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/ocr-common.sh"
|
||||
|
||||
require_root "deploy/repair.sh"
|
||||
|
||||
cd "${INSTALL_DIR}" || exit 1
|
||||
find "${INSTALL_DIR}" -name "*.sh" -exec sed -i 's/\r$//' {} +
|
||||
|
||||
log_info "开始修复 grade-archive 服务…"
|
||||
|
||||
stop_legacy_pm2
|
||||
install_ocr_deps_safe
|
||||
if [[ -x "${INSTALL_DIR}/deploy/ocr-worker/.venv/bin/uvicorn" ]]; then
|
||||
start_ocr_screen || log_warn "OCR screen 启动失败"
|
||||
wait_ocr_healthy || log_warn "OCR 未就绪"
|
||||
fi
|
||||
setup_systemd_service
|
||||
restart_grade_service
|
||||
|
||||
if wait_healthy; then
|
||||
ip=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||
ip="${ip:-127.0.0.1}"
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " 修复完成,请访问: http://${ip}:${WEB_PORT}"
|
||||
echo "=========================================="
|
||||
else
|
||||
show_service_status
|
||||
exit 1
|
||||
fi
|
||||
@@ -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] 恢复完成"
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "${ROOT}/backend"
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source venv/bin/activate
|
||||
|
||||
if [[ -f "${ROOT}/.env" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "${ROOT}/.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
export FRONTEND_DIST="${FRONTEND_DIST:-${ROOT}/frontend/dist}"
|
||||
export UPLOAD_DIR="${UPLOAD_DIR:-${ROOT}/uploads}"
|
||||
export OPENCV_IO_ENABLE_OPENEXR=0
|
||||
|
||||
exec python -m uvicorn app.main:app --host 0.0.0.0 --port "${WEB_PORT:-23566}"
|
||||
+118
-5
@@ -1,11 +1,124 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# 卸载中学成绩档案系统(停止服务 + 可选删除数据/目录)
|
||||
# 用法:
|
||||
# sudo bash deploy/uninstall.sh # 停止服务,保留代码/数据库/上传
|
||||
# sudo bash deploy/uninstall.sh --purge # 额外删除 /opt/secondary-school-grade-archive
|
||||
# sudo bash deploy/uninstall.sh --purge-db # 额外删除 PostgreSQL 库与用户
|
||||
# sudo bash deploy/uninstall.sh --purge --purge-db # 完全清除后重装
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
INSTALL_DIR="${INSTALL_DIR:-/opt/secondary-school-grade-archive}"
|
||||
OCR_SCREEN_NAME="${OCR_SCREEN_NAME:-ocr-worker}"
|
||||
|
||||
cd "${INSTALL_DIR}" || exit 1
|
||||
pm2 delete grade-api grade-web 2>/dev/null || true
|
||||
pm2 save --force
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo "PM2 服务已停止。PostgreSQL 数据与 ${INSTALL_DIR}/uploads 仍保留。"
|
||||
echo "如需删除源码: rm -rf ${INSTALL_DIR}"
|
||||
log_info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
||||
|
||||
PURGE_DIR=0
|
||||
PURGE_DB=0
|
||||
for arg in "$@"; do
|
||||
case "${arg}" in
|
||||
--purge) PURGE_DIR=1 ;;
|
||||
--purge-db) PURGE_DB=1 ;;
|
||||
-h|--help)
|
||||
sed -n '2,8p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
|
||||
log_error "请使用 root: sudo bash deploy/uninstall.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -f "${INSTALL_DIR}/.env" ]]; then
|
||||
# shellcheck disable=SC1090
|
||||
set -a && source "${INSTALL_DIR}/.env" && set +a
|
||||
fi
|
||||
|
||||
POSTGRES_USER="${POSTGRES_USER:-gradeapp}"
|
||||
POSTGRES_DB="${POSTGRES_DB:-student_archive}"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " 卸载中学成绩档案系统"
|
||||
echo "=========================================="
|
||||
echo " 安装目录: ${INSTALL_DIR}"
|
||||
echo " 删除目录: $([[ ${PURGE_DIR} -eq 1 ]] && echo 是 || echo 否(保留))"
|
||||
echo " 删除数据库: $([[ ${PURGE_DB} -eq 1 ]] && echo 是 || echo 否(保留))"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
if [[ -t 0 ]]; then
|
||||
read -rp "确认继续? [y/N] " confirm
|
||||
if [[ "${confirm}" != [yY] ]]; then
|
||||
echo "已取消"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
log_info "停止主程序 systemd…"
|
||||
systemctl stop grade-archive 2>/dev/null || true
|
||||
systemctl disable grade-archive 2>/dev/null || true
|
||||
rm -f /etc/systemd/system/grade-archive.service
|
||||
systemctl daemon-reload
|
||||
|
||||
log_info "停止 OCR Worker systemd(若曾安装)…"
|
||||
systemctl stop ocr-worker 2>/dev/null || true
|
||||
systemctl disable ocr-worker 2>/dev/null || true
|
||||
rm -f /etc/systemd/system/ocr-worker.service
|
||||
systemctl daemon-reload
|
||||
|
||||
log_info "停止 OCR screen 会话…"
|
||||
if command -v screen >/dev/null; then
|
||||
screen -S "${OCR_SCREEN_NAME}" -X quit 2>/dev/null || true
|
||||
fi
|
||||
|
||||
log_info "停止旧版 PM2(若存在)…"
|
||||
if command -v pm2 >/dev/null; then
|
||||
pm2 delete grade-api grade-web 2>/dev/null || true
|
||||
pm2 save --force 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [[ ${PURGE_DB} -eq 1 ]]; then
|
||||
if command -v psql >/dev/null && systemctl is-active postgresql &>/dev/null; then
|
||||
log_info "删除 PostgreSQL 数据库 ${POSTGRES_DB}…"
|
||||
sudo -u postgres psql -c "DROP DATABASE IF EXISTS \"${POSTGRES_DB}\";" 2>/dev/null || true
|
||||
sudo -u postgres psql -c "DROP USER IF EXISTS \"${POSTGRES_USER}\";" 2>/dev/null || true
|
||||
else
|
||||
log_warn "PostgreSQL 未运行,跳过删库"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ${PURGE_DIR} -eq 1 ]]; then
|
||||
log_info "删除安装目录 ${INSTALL_DIR}…"
|
||||
rm -rf "${INSTALL_DIR}"
|
||||
else
|
||||
log_info "保留安装目录(含 uploads、.env、数据库文件若未 --purge-db)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " 卸载完成"
|
||||
echo "=========================================="
|
||||
if [[ ${PURGE_DIR} -eq 0 ]]; then
|
||||
echo " 代码仍在: ${INSTALL_DIR}"
|
||||
echo " 重新部署:"
|
||||
echo " cd ${INSTALL_DIR} && git pull"
|
||||
echo " export OLLAMA_BASE_URL=http://Ollama电脑IP:11434"
|
||||
echo " sudo bash deploy/install.sh"
|
||||
else
|
||||
echo " 重新部署(全新):"
|
||||
echo " export OLLAMA_BASE_URL=http://Ollama电脑IP:11434"
|
||||
echo " sudo bash deploy/install.sh"
|
||||
echo " (install.sh 会自动 git clone 到 ${INSTALL_DIR})"
|
||||
fi
|
||||
echo "=========================================="
|
||||
|
||||
+35
-19
@@ -4,39 +4,55 @@ set -euo pipefail
|
||||
INSTALL_DIR="${INSTALL_DIR:-/opt/secondary-school-grade-archive}"
|
||||
BRANCH="${BRANCH:-main}"
|
||||
PIP_MIRROR="${PIP_MIRROR:-https://pypi.tuna.tsinghua.edu.cn/simple}"
|
||||
NPM_REGISTRY="${NPM_REGISTRY:-https://registry.npmmirror.com}"
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
NC='\033[0m'
|
||||
log_info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
||||
# shellcheck source=common.sh
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh"
|
||||
# shellcheck source=proxy.sh
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/proxy.sh"
|
||||
# shellcheck source=ocr-common.sh
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/ocr-common.sh"
|
||||
|
||||
require_root "deploy/update.sh"
|
||||
|
||||
cd "${INSTALL_DIR}" || exit 1
|
||||
find "${INSTALL_DIR}" -name "*.sh" -exec sed -i 's/\r$//' {} +
|
||||
chmod +x deploy/start.sh deploy/ocr-screen.sh deploy/install-ocr-deps.sh deploy/repair.sh
|
||||
chmod +x deploy/ocr-worker/*.sh 2>/dev/null || true
|
||||
|
||||
setup_deploy_proxy
|
||||
|
||||
log_info "拉取最新代码…"
|
||||
git fetch origin
|
||||
git checkout "${BRANCH}" 2>/dev/null || true
|
||||
git pull origin "${BRANCH}"
|
||||
|
||||
log_info "更新后端依赖…"
|
||||
if [[ ! -f "${INSTALL_DIR}/frontend/dist/index.html" ]]; then
|
||||
log_error "未找到 frontend/dist/index.html"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_info "更新主程序依赖…"
|
||||
cd backend
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt --progress-bar on -i "${PIP_MIRROR}"
|
||||
deactivate
|
||||
|
||||
log_info "重建前端…"
|
||||
cd ../frontend
|
||||
npm config set registry "${NPM_REGISTRY}"
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
log_info "更新网关…"
|
||||
cd ../deploy/pm2
|
||||
npm ci
|
||||
|
||||
log_info "重启 PM2…"
|
||||
cd "${INSTALL_DIR}"
|
||||
pm2 reload deploy/pm2/ecosystem.config.cjs --update-env || pm2 start deploy/pm2/ecosystem.config.cjs
|
||||
pm2 save
|
||||
|
||||
log_info "更新 OCR Worker 并重启 screen…"
|
||||
install_ocr_worker
|
||||
stop_ocr_screen
|
||||
start_ocr_screen
|
||||
wait_ocr_healthy || log_warn "OCR 加载中,稍后: bash deploy/ocr-screen.sh status"
|
||||
|
||||
stop_legacy_pm2
|
||||
install_ocr_deps_safe
|
||||
setup_systemd_service
|
||||
restart_grade_service
|
||||
|
||||
if ! wait_healthy; then
|
||||
log_error "更新后主程序异常,可尝试: sudo bash ${INSTALL_DIR}/deploy/repair.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_info "更新完成"
|
||||
show_ocr_status
|
||||
|
||||
+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 或首页卡片 **修改** 中维护;首页卡片支持 **删除**。
|
||||
+186
-93
@@ -1,4 +1,4 @@
|
||||
# Ubuntu PM2 部署文档
|
||||
# Ubuntu 零 Node 部署文档
|
||||
|
||||
> **中学成绩档案系统** · 版权所有 © 马建军 · 微信 **dekun03** · 手机 **18364911125**
|
||||
> 仓库:[https://git.bz121.com/dekun/secondary-school-grade-archive.git](https://git.bz121.com/dekun/secondary-school-grade-archive.git)
|
||||
@@ -9,41 +9,114 @@
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 方式 | **PM2**(非 Docker) |
|
||||
| 方式 | **systemd + FastAPI 单进程**(服务器**无需** Node.js / npm) |
|
||||
| 系统 | Ubuntu 20.04 / 22.04 / 24.04 |
|
||||
| 用户 | **root** |
|
||||
| 目录 | `/opt/secondary-school-grade-archive` |
|
||||
| 端口 | **23566**(Web + API 统一入口) |
|
||||
| 反向代理 | **不包含**,请自行配置 |
|
||||
| 端口 | **23566**(API + 前端静态资源同一端口) |
|
||||
|
||||
### 架构
|
||||
|
||||
```
|
||||
浏览器 → :23566 (PM2: grade-web, Express 静态 + /api 反代)
|
||||
└──→ 127.0.0.1:8000 (PM2: grade-api, Uvicorn)
|
||||
浏览器 → :23566 (systemd: grade-archive, Uvicorn)
|
||||
├── /api/* → FastAPI 接口
|
||||
└── /* → frontend/dist 静态文件
|
||||
└──→ PostgreSQL (本机)
|
||||
└──→ uploads/
|
||||
└──→ Ollama (本机可选, :11434)
|
||||
```
|
||||
|
||||
PM2 进程:
|
||||
### 为何不在服务器构建前端?
|
||||
|
||||
| 名称 | 说明 |
|
||||
|------|------|
|
||||
| `grade-api` | FastAPI / Uvicorn |
|
||||
| `grade-web` | 前端静态资源 + `/api` 反向代理 |
|
||||
前端 `npm ci && npm run build` 会占用大量磁盘与内存,且需在服务器安装 Node.js。
|
||||
因此采用 **开发机构建 → 推送 `frontend/dist` → 服务器只拉取** 的方式,服务器仅需 Python + PostgreSQL。
|
||||
|
||||
---
|
||||
|
||||
## 2. 环境要求
|
||||
## 2. 代码修改与发布流程(重要)
|
||||
|
||||
- CPU 2 核+,内存 4 GB+(OCR 建议 8 GB)
|
||||
- 磁盘 15 GB+
|
||||
- 可访问 Git 仓库与 npm / PyPI
|
||||
每次修改代码后,按改动范围在**开发机**操作,再推送到远端仓库,最后在服务器执行 `update.sh`。
|
||||
|
||||
> **服务器需代理时**:在 `update.sh` 前设置与 [§3 一键部署](#3-一键部署新服务器) 相同的 `export http_proxy=...` 命令。
|
||||
|
||||
### 流程总览
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌──────────────────┐ ┌─────────────┐ ┌─────────────────┐
|
||||
│ 开发机改代码 │ → │ 本地构建(如需) │ → │ git push 远端 │ → │ 服务器 update.sh │
|
||||
└─────────────┘ └──────────────────┘ └─────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
### 2.1 仅修改后端(`backend/`)
|
||||
|
||||
不涉及前端页面时,**无需** `npm run build`:
|
||||
|
||||
```bash
|
||||
git add backend/
|
||||
git commit -m "fix: 说明本次改动"
|
||||
git push
|
||||
```
|
||||
|
||||
服务器:
|
||||
|
||||
```bash
|
||||
bash /opt/secondary-school-grade-archive/deploy/update.sh
|
||||
```
|
||||
|
||||
`update.sh` 会:`git pull` → `pip install` → `systemctl restart grade-archive`。
|
||||
|
||||
### 2.2 修改前端(`frontend/src` 等)或同时改前后端
|
||||
|
||||
**必须先在开发机构建**,并将构建产物 `frontend/dist/` 提交到仓库:
|
||||
|
||||
```powershell
|
||||
# Windows
|
||||
.\deploy\build-frontend.ps1
|
||||
```
|
||||
|
||||
```bash
|
||||
# Linux / macOS
|
||||
bash deploy/build-frontend.sh
|
||||
```
|
||||
|
||||
上述脚本等价于 `cd frontend && npm ci && npm run build`,并检查 `dist/index.html` 是否生成。
|
||||
|
||||
然后提交并推送(**务必包含 `frontend/dist`**):
|
||||
|
||||
```bash
|
||||
git add frontend/ frontend/dist
|
||||
git commit -m "feat: 说明本次改动"
|
||||
git push
|
||||
```
|
||||
|
||||
服务器:
|
||||
|
||||
```bash
|
||||
bash /opt/secondary-school-grade-archive/deploy/update.sh
|
||||
```
|
||||
|
||||
### 2.3 常见错误
|
||||
|
||||
| 现象 | 原因 | 处理 |
|
||||
|------|------|------|
|
||||
| 服务器页面没变化 | 只 push 了源码,未 push `frontend/dist` | 本地 `npm run build` 后重新提交 dist |
|
||||
| `install.sh` 报错找不到 dist | 仓库里没有预构建的 dist | 开发机构建并 push 后再部署 |
|
||||
| API 正常但页面 404 | `FRONTEND_DIST` 路径不对 | 检查 `.env` 中 `FRONTEND_DIST` |
|
||||
|
||||
---
|
||||
|
||||
## 3. 一键部署
|
||||
## 3. 一键部署(新服务器)
|
||||
|
||||
若服务器访问 Git / apt / PyPI 需走代理,**在 `bash deploy/install.sh` 之前**设置:
|
||||
|
||||
```bash
|
||||
export http_proxy=http://192.168.8.246:10810
|
||||
export https_proxy=http://192.168.8.246:10810
|
||||
export HTTP_PROXY="$http_proxy"
|
||||
export HTTPS_PROXY="$https_proxy"
|
||||
```
|
||||
|
||||
完整安装命令:
|
||||
|
||||
```bash
|
||||
git clone https://git.bz121.com/dekun/secondary-school-grade-archive.git /opt/secondary-school-grade-archive
|
||||
@@ -52,121 +125,141 @@ chmod +x deploy/*.sh
|
||||
bash deploy/install.sh
|
||||
```
|
||||
|
||||
> 脚本会读取上述环境变量,并自动配置 `apt` 代理。`git pull`、`pip install` 同样生效。内网直连时可不设代理。
|
||||
|
||||
脚本自动完成:
|
||||
|
||||
1. 检测 root、Ubuntu、端口 23566
|
||||
2. 安装 PostgreSQL、Python3、Node.js 20、PM2
|
||||
3. 克隆/更新代码
|
||||
4. 生成 `.env`(随机密钥、数据库密码)
|
||||
5. 创建 PostgreSQL 用户与数据库
|
||||
6. Python 虚拟环境 + `pip install`
|
||||
7. 前端 `npm ci && npm run build`
|
||||
8. `pm2 start` 并设置开机自启
|
||||
1. 安装 PostgreSQL、Python 依赖
|
||||
2. 检查 `frontend/dist/index.html` 是否存在
|
||||
3. 生成 `.env`、创建数据库
|
||||
4. 注册并启动 systemd 服务 `grade-archive`
|
||||
|
||||
**前提:** 仓库中已包含 `frontend/dist/`(开发机构建后推送)。
|
||||
|
||||
部署成功后访问:**`http://<服务器IP>:23566`**
|
||||
|
||||
默认超级管理员:**admin / admin123**(登录后请在「系统设置」中修改)
|
||||
|
||||
---
|
||||
|
||||
## 4. 环境变量(`.env`)
|
||||
|
||||
| 变量 | 默认 | 说明 |
|
||||
|------|------|------|
|
||||
| `WEB_PORT` | 23566 | 对外 Web 端口 |
|
||||
| `API_PORT` | 8000 | 内部 API 端口 |
|
||||
| `WEB_PORT` | 23566 | 对外端口 |
|
||||
| `FRONTEND_DIST` | `.../frontend/dist` | 前端静态目录(绝对路径) |
|
||||
| `DATABASE_URL` | 自动生成 | PostgreSQL 连接 |
|
||||
| `SECRET_KEY` | 自动生成 | JWT 密钥 |
|
||||
| `UPLOAD_DIR` | `.../uploads` | 错题图片目录 |
|
||||
| `OLLAMA_BASE_URL` | `http://127.0.0.1:11434` | 本地 Ollama |
|
||||
| `ADMIN_DEFAULT_USERNAME` | admin | 首次安装默认管理员用户名 |
|
||||
| `ADMIN_DEFAULT_PASSWORD` | admin123 | 首次安装默认管理员密码 |
|
||||
|
||||
修改后重启:
|
||||
|
||||
```bash
|
||||
cd /opt/secondary-school-grade-archive
|
||||
pm2 reload deploy/pm2/ecosystem.config.cjs --update-env
|
||||
```
|
||||
示例见仓库根目录 [.env.example](../.env.example)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 常用命令
|
||||
|
||||
```bash
|
||||
cd /opt/secondary-school-grade-archive
|
||||
# 服务状态
|
||||
systemctl status grade-archive
|
||||
|
||||
pm2 status # 进程状态
|
||||
pm2 logs # 全部日志
|
||||
pm2 logs grade-api # 后端日志
|
||||
pm2 logs grade-web # 网关日志
|
||||
# 实时日志
|
||||
journalctl -u grade-archive -f
|
||||
|
||||
bash deploy/update.sh # 拉代码 + 重建 + 重启
|
||||
bash deploy/backup.sh # 备份数据库与 uploads
|
||||
bash deploy/uninstall.sh # 停止 PM2 服务
|
||||
# 拉代码并重启(日常更新;需代理时先 export,见 §3)
|
||||
export http_proxy=http://192.168.8.246:10810
|
||||
export https_proxy=http://192.168.8.246:10810
|
||||
export HTTP_PROXY="$http_proxy"
|
||||
export HTTPS_PROXY="$https_proxy"
|
||||
bash /opt/secondary-school-grade-archive/deploy/update.sh
|
||||
|
||||
# 备份数据库与 uploads
|
||||
bash /opt/secondary-school-grade-archive/deploy/backup.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Ollama(可选)
|
||||
## 6. 从旧版 PM2 迁移
|
||||
|
||||
```bash
|
||||
curl -fsSL https://ollama.com/install.sh | sh
|
||||
ollama pull qwen2.5:7b
|
||||
```
|
||||
若之前使用 `grade-api` + `grade-web`(PM2 + Express),执行 `deploy/update.sh` 会:
|
||||
|
||||
- 停止并删除 PM2 进程 `grade-api`、`grade-web`
|
||||
- 重启 systemd 服务 `grade-archive`
|
||||
|
||||
`.env` 调整建议:
|
||||
|
||||
- 保留 `WEB_PORT=23566`
|
||||
- 添加 `FRONTEND_DIST=/opt/secondary-school-grade-archive/frontend/dist`
|
||||
- 可删除 `API_PORT`、`API_TARGET`(已不再使用)
|
||||
|
||||
---
|
||||
|
||||
## 7. 反向代理(自行配置)
|
||||
## 7. 超级管理员
|
||||
|
||||
将域名/HTTPS 流量转发到 **`http://127.0.0.1:23566`** 即可。
|
||||
使用 HTTPS 后请更新 `.env` 中 `CORS_ORIGINS`。
|
||||
|
||||
---
|
||||
|
||||
## 8. 防火墙
|
||||
|
||||
```bash
|
||||
ufw allow 22/tcp
|
||||
ufw allow 23566/tcp
|
||||
ufw enable
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 故障排查
|
||||
|
||||
| 现象 | 处理 |
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 无法访问 | `pm2 status` · `ss -tlnp \| grep 23566` |
|
||||
| 502 / API 错误 | `pm2 logs grade-api` |
|
||||
| 数据库连接失败 | `systemctl status postgresql` · 检查 `.env` 中 `DATABASE_URL` |
|
||||
| 前端空白 | 确认 `frontend/dist` 存在 · `pm2 logs grade-web` |
|
||||
| 默认账号 | **admin / admin123**(首次安装后请立即修改) |
|
||||
| 系统设置 | 超级管理员可修改自己的用户名、密码 |
|
||||
| 注册开关 | 可开启/关闭登录页公开注册 |
|
||||
| 用户管理 | 注册关闭时,由管理员添加用户并重置密码 |
|
||||
| 普通用户 | **不能**自行修改用户名和密码 |
|
||||
|
||||
使用说明见 [USAGE.md](./USAGE.md)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 自定义参数
|
||||
## 8. 反向代理(用户自行配置)
|
||||
|
||||
```bash
|
||||
WEB_PORT=23566 INSTALL_DIR=/opt/secondary-school-grade-archive bash deploy/install.sh
|
||||
|
||||
# 使用官方 PyPI(海外服务器可去掉国内镜像)
|
||||
PIP_MIRROR=https://pypi.org/simple bash deploy/install.sh
|
||||
```
|
||||
|
||||
### 网络代理(可选,脚本内不内置)
|
||||
|
||||
一键部署**不会**自动配置代理。无代理环境可直接运行,不会因代理报错。
|
||||
|
||||
若本机网络需走代理,请在**执行安装前**手动 export(仅当前终端生效):
|
||||
|
||||
```bash
|
||||
export HTTP_PROXY=http://你的代理地址:端口
|
||||
export HTTPS_PROXY=http://你的代理地址:端口
|
||||
bash deploy/install.sh
|
||||
```
|
||||
|
||||
不设置则不走代理。`pip install` 会**显示完整下载/安装进度**,不再静默。
|
||||
本项目**不包含** Nginx / Caddy 等反向代理配置。若需 HTTPS 或域名访问,请在服务器上自行配置,将流量转发到 `127.0.0.1:23566`。
|
||||
|
||||
---
|
||||
|
||||
## 11. 版权
|
||||
## 9. OCR 报错 libGL.so.1
|
||||
|
||||
见 [LICENSE](../LICENSE) · [COPYRIGHT.md](../COPYRIGHT.md)
|
||||
技术支持:微信 **dekun03** · 手机 **18364911125**
|
||||
若错题识别失败并提示 `libGL.so.1: cannot open shared object file`,说明服务器缺少 OpenGL 运行库(PaddleOCR/OpenCV 需要)。
|
||||
|
||||
在服务器执行:
|
||||
|
||||
```bash
|
||||
cd /opt/secondary-school-grade-archive
|
||||
git pull
|
||||
sudo bash deploy/install-ocr-deps.sh
|
||||
sudo systemctl restart grade-archive
|
||||
```
|
||||
|
||||
或执行完整更新(已包含 OCR 依赖检查):
|
||||
|
||||
```bash
|
||||
sudo bash deploy/update.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 更新后无法访问(连接被拒绝)
|
||||
|
||||
若浏览器提示 **ERR_CONNECTION_REFUSED**,通常是 `update.sh` 停止了旧 PM2,但 **systemd 服务未成功启动**。
|
||||
|
||||
在服务器执行一键修复:
|
||||
|
||||
```bash
|
||||
cd /opt/secondary-school-grade-archive
|
||||
git pull
|
||||
sudo bash deploy/repair.sh
|
||||
```
|
||||
|
||||
手动排查:
|
||||
|
||||
```bash
|
||||
systemctl status grade-archive
|
||||
journalctl -u grade-archive -n 50 --no-pager
|
||||
ss -tlnp | grep 23566
|
||||
```
|
||||
|
||||
> **注意:** `update.sh` / `repair.sh` 必须用 **root** 运行(`sudo bash ...`)。
|
||||
|
||||
---
|
||||
|
||||
## 11. 技术支持
|
||||
|
||||
微信 **dekun03** · 手机 **18364911125**
|
||||
|
||||
+84
-19
@@ -16,7 +16,7 @@
|
||||
- 错题库:拍照上传 → OCR 识别 → AI 生成解法(可编辑)
|
||||
- 成绩 CSV 导出
|
||||
|
||||
部署方式见 [DEPLOY.md](./DEPLOY.md)(PM2,端口 23566)。
|
||||
部署与代码发布见 [DEPLOY.md](./DEPLOY.md)(systemd 零 Node,端口 **23566**)。
|
||||
|
||||
---
|
||||
|
||||
@@ -25,12 +25,23 @@
|
||||
### 2.1 登录与注册
|
||||
|
||||
1. 浏览器打开 `http://<服务器IP>:23566`
|
||||
2. 首次使用点击 **注册**,设置用户名(≥3 字符)和密码(≥6 字符)
|
||||
3. 注册成功后自动登录
|
||||
2. **首次部署**默认超级管理员:**admin / admin123**(请登录后立即修改)
|
||||
3. 若管理员已**开放注册**,可在登录页 **注册** 新账号(用户名 ≥3 字符,密码 ≥6 字符)
|
||||
4. 若管理员已**关闭注册**,登录页不显示注册入口,需联系管理员在「系统设置 → 用户管理」中添加账号
|
||||
|
||||
> 系统无默认管理员账号,首个注册用户即为普通用户,数据仅本人可见。
|
||||
> 普通用户数据仅本人可见;普通用户**不能**自行修改用户名或密码。
|
||||
|
||||
### 2.2 添加学生
|
||||
### 2.2 系统设置(超级管理员)
|
||||
|
||||
首页右上角 **系统设置**(仅超级管理员可见):
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 注册开关 | 开启后用户可自行注册;关闭后仅管理员添加用户 |
|
||||
| 管理员账号 | 修改超级管理员的用户名、密码(修改密码需输入当前密码) |
|
||||
| 用户管理 | 添加用户、重置密码、删除普通用户 |
|
||||
|
||||
### 2.3 添加学生
|
||||
|
||||
1. 首页点击 **添加学生**
|
||||
2. 填写:
|
||||
@@ -40,7 +51,7 @@
|
||||
- **班级**:如「3班」(可选)
|
||||
3. 保存后在卡片上可看到学段标签
|
||||
|
||||
### 2.3 录入成绩
|
||||
### 2.4 录入成绩
|
||||
|
||||
进入学生详情 → **成绩录入** 标签:
|
||||
|
||||
@@ -88,18 +99,33 @@
|
||||
|
||||
## 4. 错题库
|
||||
|
||||
### 4.1 上传错题
|
||||
### 4.1 上传错题(手机 / 平板)
|
||||
|
||||
进入 **错题库** 标签:
|
||||
进入 **错题库** 或 **奥数区** 标签:
|
||||
|
||||
1. 选择 **科目**
|
||||
2. 点击 **上传错题图片**(支持 jpg/png/webp,最大 10MB)
|
||||
3. 上传后后台自动:**OCR 识别 → AI 整理题目 → 生成解法**
|
||||
4. 处理状态:处理中 → 已识别 → 已生成解法
|
||||
2. 点击 **拍照上传** 或 **相册选图**
|
||||
3. 上传后自动:**OCR 识别 → 照片上红框标注错误位置 → 整理题目 → 生成「解题思路」与详细解答**
|
||||
|
||||
> AI 解法依赖服务器上的 **Ollama**。未配置时仍可 OCR,解法需手动填写。
|
||||
> 标注效果类似作业帮:错误作答处显示红色框和 × 标记。详情页可切换「标注图 / 原图」。
|
||||
|
||||
### 4.2 查看与编辑
|
||||
### 4.2 奥数区
|
||||
|
||||
学生详情页 **奥数区** 标签专门存放奥数题:
|
||||
|
||||
- 按学生**学段**(初中 / 高中)生成奥数解题思路
|
||||
- **严禁超纲**:初中不用高中/大学方法,高中不用大学高阶理论
|
||||
- 上传方式与错题库相同,支持拍照
|
||||
|
||||
### 4.3 解题 AI 与学段约束
|
||||
|
||||
- **错题库**:按初中/高中**课内**标准解题,禁止超纲
|
||||
- **奥数区**:按对应学段**奥数培优**范围解题
|
||||
- 超级管理员可在 **系统设置 → AI 模型** 中选择:
|
||||
- **本地 Ollama**(地址 + 模型名)
|
||||
- **OpenAI 兼容 API**(Base URL + 模型 + API Key)
|
||||
|
||||
### 4.4 查看与编辑
|
||||
|
||||
点击错题卡片打开详情:
|
||||
|
||||
@@ -111,7 +137,7 @@
|
||||
|
||||
> 解法标注「AI 生成,请核对」,使用前请人工确认。
|
||||
|
||||
### 4.3 筛选与搜索
|
||||
### 4.5 筛选与搜索
|
||||
|
||||
- 按 **科目** 筛选
|
||||
- **搜索** 题目/解法关键词
|
||||
@@ -130,13 +156,49 @@
|
||||
|
||||
---
|
||||
|
||||
## 7. 常见问题
|
||||
## 7. 维护人员:修改代码后如何更新线上
|
||||
|
||||
**服务器不在本地构建前端。** 维护或二次开发时,请严格按以下流程发布:
|
||||
|
||||
### 只改后端
|
||||
|
||||
```bash
|
||||
git add backend/
|
||||
git commit -m "说明"
|
||||
git push
|
||||
# 服务器
|
||||
bash /opt/secondary-school-grade-archive/deploy/update.sh
|
||||
```
|
||||
|
||||
### 改前端(或前后端都改)
|
||||
|
||||
```powershell
|
||||
# Windows:先构建
|
||||
.\deploy\build-frontend.ps1
|
||||
```
|
||||
|
||||
```bash
|
||||
# Linux / macOS:先构建
|
||||
bash deploy/build-frontend.sh
|
||||
|
||||
git add frontend/ frontend/dist
|
||||
git commit -m "说明"
|
||||
git push
|
||||
# 服务器
|
||||
bash /opt/secondary-school-grade-archive/deploy/update.sh
|
||||
```
|
||||
|
||||
详细说明见 [DEPLOY.md §2](./DEPLOY.md#2-代码修改与发布流程重要) 与 [README](../README.md#修改代码后如何发布必读)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 常见问题
|
||||
|
||||
**Q:忘记密码怎么办?**
|
||||
A:当前版本无找回密码功能,需管理员在数据库中重置或重新注册(生产环境建议后续增加找回流程)。
|
||||
A:普通用户请联系超级管理员,在「系统设置 → 用户管理」中重置密码。超级管理员忘记密码需通过数据库或 `.env` 中的 `ADMIN_DEFAULT_*` 配合运维处理。
|
||||
|
||||
**Q:多人能否共用一台服务器?**
|
||||
A:可以。每人注册独立账号,数据互不可见。
|
||||
A:可以。每人独立账号,数据互不可见。
|
||||
|
||||
**Q:能否同时管理初中和高中孩子?**
|
||||
A:可以。添加学生时分别选择学段即可。
|
||||
@@ -145,11 +207,14 @@ A:可以。添加学生时分别选择学段即可。
|
||||
A:可以。使用同一服务器地址与账号登录即可。
|
||||
|
||||
**Q:HTTPS 和域名怎么配置?**
|
||||
A:本项目不包含反向代理配置,请参考 [DEPLOY.md 第 7 节](./DEPLOY.md#7-反向代理用户自行配置) 自行设置。
|
||||
A:本项目不包含反向代理配置,请参考 [DEPLOY.md §8](./DEPLOY.md#8-反向代理用户自行配置) 自行设置。
|
||||
|
||||
**Q:改了代码但服务器页面没变化?**
|
||||
A:很可能未在开发机执行 `npm run build` 或未将 `frontend/dist` 推送到仓库。见上文第 7 节。
|
||||
|
||||
---
|
||||
|
||||
## 8. 技术支持与版权
|
||||
## 9. 技术支持与版权
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
# dist 由开发机构建后提交,供服务器零 Node 部署使用
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
*{box-sizing:border-box}body{-webkit-font-smoothing:antialiased;-webkit-tap-highlight-color:transparent;background:#f5f5f5;margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif}#root{min-height:100dvh}a{color:inherit}.page-container{max-width:1100px;padding:16px 16px 32px;padding-bottom:max(32px, env(safe-area-inset-bottom));margin:0 auto}.page-header{width:100%;margin-bottom:16px}.student-tabs .ant-tabs-nav{margin-bottom:12px}.wq-grid{grid-template-columns:repeat(auto-fill,minmax(min(100%,260px),1fr));gap:16px;display:grid}.wq-card{background:#fff;border:1px solid #f0f0f0;border-radius:8px;flex-direction:column;transition:box-shadow .2s;display:flex;overflow:hidden}.wq-card-click{cursor:pointer;flex:1}.wq-card-click:active{opacity:.95}.wq-card-actions{text-align:right;border-top:1px solid #f5f5f5;padding:4px 8px 8px}.wq-card-img{object-fit:cover;background:#fafafa;width:100%;height:140px}.wq-card-body{padding:12px}.upload-actions .ant-btn{min-height:44px}.upload-mobile-only{display:none}.upload-desktop-only{display:inline-flex}@media (width<=768px){.page-container{padding:12px 12px 24px}.ant-tabs-tab{font-size:14px;padding:8px 10px!important}.ant-modal{max-width:calc(100vw - 16px)!important;margin:8px auto!important}.ant-table{font-size:12px}.upload-actions{width:100%}.upload-mobile-only{flex-wrap:wrap;gap:8px;width:100%;display:inline-flex}.upload-desktop-only{display:none}.upload-actions .ant-btn{flex:1;min-width:120px}}@media (width<=576px){.wq-card-img{height:120px}}
|
||||
+518
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="author" content="马建军" />
|
||||
<meta name="copyright" content="Copyright (c) 马建军. All rights reserved." />
|
||||
<title>中学成绩档案</title>
|
||||
<script type="module" crossorigin src="/assets/index-C01Hd5WH.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BSTLtBBx.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
+3
-1
@@ -3,7 +3,9 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="author" content="马建军" />
|
||||
<meta name="copyright" content="Copyright (c) 马建军. All rights reserved." />
|
||||
<title>中学成绩档案</title>
|
||||
|
||||
Generated
+20
@@ -16,6 +16,7 @@
|
||||
"echarts-for-react": "^3.0.6",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-easy-crop": "^6.0.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.18.0",
|
||||
"tslib": "^2.8.1"
|
||||
@@ -3308,6 +3309,12 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-wheel": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
|
||||
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/oxlint": {
|
||||
"version": "1.71.0",
|
||||
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.71.0.tgz",
|
||||
@@ -3471,6 +3478,19 @@
|
||||
"react": "^19.2.7"
|
||||
}
|
||||
},
|
||||
"node_modules/react-easy-crop": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-6.0.2.tgz",
|
||||
"integrity": "sha512-nY/YiNEuRjc851+/PsOR6Q7XoshmnXMl+oEOsxp3Ah0PrhECi5388jjRnHwsTFx3W0o2zPwvq85oljzUqZNpEw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"normalize-wheel": "^1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.4.0",
|
||||
"react-dom": ">=16.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"echarts-for-react": "^3.0.6",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-easy-crop": "^6.0.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.18.0",
|
||||
"tslib": "^2.8.1"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { useAuth } from './context/AuthContext'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import StudentDetailPage from './pages/StudentDetailPage'
|
||||
import StudentsPage from './pages/StudentsPage'
|
||||
|
||||
@@ -31,6 +32,14 @@ export default function App() {
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<SettingsPage />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import axios from 'axios'
|
||||
import type {
|
||||
AdminUser,
|
||||
AIProvider,
|
||||
AppFeatures,
|
||||
BackupInfo,
|
||||
Composition,
|
||||
CompositionInputMode,
|
||||
Exam,
|
||||
PublicSettings,
|
||||
ScoreInput,
|
||||
SchoolLevel,
|
||||
Student,
|
||||
Subject,
|
||||
SystemSettings,
|
||||
TokenResponse,
|
||||
TrendResponse,
|
||||
User,
|
||||
WrongQuestion,
|
||||
WrongQuestionCategory,
|
||||
} from '../types'
|
||||
import type { ExamType } from '../types'
|
||||
|
||||
@@ -59,17 +68,73 @@ export const authApi = {
|
||||
me: () => api.get<User>('/auth/me'),
|
||||
}
|
||||
|
||||
export const settingsApi = {
|
||||
public: () => api.get<PublicSettings>('/settings/public'),
|
||||
appFeatures: () => api.get<AppFeatures>('/settings/app-features'),
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
getSettings: () => api.get<SystemSettings>('/admin/settings'),
|
||||
updateSettings: (data: {
|
||||
registration_enabled?: boolean
|
||||
ai_review_enabled?: boolean
|
||||
ai_provider?: AIProvider
|
||||
ollama_base_url?: string | null
|
||||
ollama_model?: string | null
|
||||
openai_base_url?: string | null
|
||||
openai_model?: string | null
|
||||
openai_api_key?: string
|
||||
ocr_service_url?: string | null
|
||||
}) => api.patch<SystemSettings>('/admin/settings', data),
|
||||
updateProfile: (data: {
|
||||
username?: string
|
||||
current_password?: string
|
||||
password?: string
|
||||
}) => api.patch<AdminUser>('/admin/profile', data),
|
||||
listUsers: () => api.get<AdminUser[]>('/admin/users'),
|
||||
createUser: (data: { username: string; password: string }) =>
|
||||
api.post<AdminUser>('/admin/users', data),
|
||||
resetUserPassword: (id: string, password: string) =>
|
||||
api.patch<AdminUser>(`/admin/users/${id}`, { password }),
|
||||
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 = {
|
||||
list: () => api.get<Student[]>('/students'),
|
||||
create: (data: {
|
||||
name: string
|
||||
school_level?: SchoolLevel
|
||||
school_name?: string
|
||||
grade?: string
|
||||
class_name?: string
|
||||
}) => api.post<Student>('/students', data),
|
||||
get: (id: string) => api.get<Student>(`/students/${id}`),
|
||||
update: (id: string, data: Partial<Student>) => api.patch<Student>(`/students/${id}`, data),
|
||||
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}`),
|
||||
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 = {
|
||||
@@ -86,21 +151,49 @@ export const examApi = {
|
||||
examId: string,
|
||||
data: Partial<{ exam_type: ExamType; exam_date: string; title: string; scores: ScoreInput[] }>,
|
||||
) => api.patch<Exam>(`/exams/${examId}`, data),
|
||||
updateReview: (
|
||||
examId: string,
|
||||
reviews: { subject_id: number; review_statuses: ScoreInput['review_statuses'] }[],
|
||||
) => api.patch<Exam>(`/exams/${examId}/review`, { reviews }),
|
||||
remove: (examId: string) => api.delete(`/exams/${examId}`),
|
||||
trend: (studentId: string, subjectId: number) =>
|
||||
api.get<TrendResponse>(`/students/${studentId}/scores/trend`, {
|
||||
params: { subject_id: subjectId },
|
||||
}),
|
||||
exportCsv: (studentId: string) =>
|
||||
api.get(`/students/${studentId}/scores/export`, { responseType: 'blob' }),
|
||||
api.get<Blob>(`/students/${studentId}/scores/export`, {
|
||||
responseType: 'blob',
|
||||
validateStatus: (status) => status >= 200 && status < 300,
|
||||
}),
|
||||
reviewInsight: (studentId: string, subjectName: string) =>
|
||||
api.post<{ insight: string }>(`/students/${studentId}/review-insight`, {
|
||||
subject_name: subjectName,
|
||||
}),
|
||||
}
|
||||
|
||||
export const compositionApi = {
|
||||
list: (studentId: string) => api.get<Composition[]>(`/students/${studentId}/compositions`),
|
||||
create: (studentId: string, data: { topic: string; input_mode?: CompositionInputMode }) =>
|
||||
api.post<Composition>(`/students/${studentId}/compositions`, data),
|
||||
get: (id: string) => api.get<Composition>(`/compositions/${id}`),
|
||||
remove: (id: string) => api.delete(`/compositions/${id}`),
|
||||
regenerate: (id: string) => api.post<Composition>(`/compositions/${id}/regenerate`),
|
||||
ocr: (studentId: string, file: File) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return api.post<{ text: string }>(`/students/${studentId}/compositions/ocr`, form)
|
||||
},
|
||||
download: (id: string) =>
|
||||
api.get(`/compositions/${id}/download`, { responseType: 'blob' }),
|
||||
}
|
||||
|
||||
export const wrongQuestionApi = {
|
||||
list: (studentId: string, params?: { subject_id?: number; q?: string }) =>
|
||||
list: (studentId: string, params?: { subject_id?: number; q?: string; category?: WrongQuestionCategory }) =>
|
||||
api.get<WrongQuestion[]>(`/students/${studentId}/wrong-questions`, { params }),
|
||||
upload: (studentId: string, subjectId: number, file: File) => {
|
||||
upload: (studentId: string, subjectId: number, file: File, category: WrongQuestionCategory = 'regular') => {
|
||||
const form = new FormData()
|
||||
form.append('subject_id', String(subjectId))
|
||||
form.append('category', category)
|
||||
form.append('file', file)
|
||||
return api.post<WrongQuestion>(`/students/${studentId}/wrong-questions`, form)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import api from '../api/client'
|
||||
|
||||
interface Props {
|
||||
questionId: string
|
||||
variant?: 'original' | 'annotated' | 'cropped'
|
||||
className?: string
|
||||
alt?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
export default function AuthenticatedImage({
|
||||
questionId,
|
||||
variant = 'original',
|
||||
className,
|
||||
alt = '题目',
|
||||
style,
|
||||
}: Props) {
|
||||
const [src, setSrc] = useState<string | null>(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let objectUrl: string | null = null
|
||||
let cancelled = false
|
||||
|
||||
const load = async (path: string, fallback?: string) => {
|
||||
try {
|
||||
const res = await api.get(path, { responseType: 'blob' })
|
||||
if (cancelled) return
|
||||
objectUrl = URL.createObjectURL(res.data)
|
||||
setSrc(objectUrl)
|
||||
setFailed(false)
|
||||
} catch {
|
||||
if (fallback && !cancelled) {
|
||||
await load(fallback)
|
||||
} else if (!cancelled) {
|
||||
setFailed(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const annotatedPath = `/wrong-questions/${questionId}/annotated-image`
|
||||
const croppedPath = `/wrong-questions/${questionId}/cropped-image`
|
||||
const originalPath = `/wrong-questions/${questionId}/image`
|
||||
|
||||
if (variant === 'annotated') {
|
||||
load(annotatedPath, originalPath)
|
||||
} else if (variant === 'cropped') {
|
||||
load(croppedPath, annotatedPath)
|
||||
} else {
|
||||
load(originalPath)
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
}, [questionId, variant])
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
...style,
|
||||
background: '#fafafa',
|
||||
color: '#999',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
图片加载失败
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!src) {
|
||||
return <div className={className} style={{ ...style, background: '#fafafa' }} />
|
||||
}
|
||||
|
||||
return <img src={src} alt={alt} className={className} style={style} />
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { DeleteOutlined, DownloadOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { Alert, Button, Modal, Popconfirm, Space, Spin, Tag, Typography } from 'antd'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import type { Composition } from '../types'
|
||||
import { COMPOSITION_STATUS_LABELS } from '../types'
|
||||
|
||||
interface Props {
|
||||
item: Composition | null
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onRegenerate: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
onDownload: (item: Composition) => void
|
||||
regenerating?: boolean
|
||||
}
|
||||
|
||||
export default function CompositionDetailModal({
|
||||
item,
|
||||
open,
|
||||
onClose,
|
||||
onRegenerate,
|
||||
onDelete,
|
||||
onDownload,
|
||||
regenerating,
|
||||
}: Props) {
|
||||
if (!item) return null
|
||||
|
||||
const processing = item.status === 'pending' || item.status === 'generating'
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="作文详情"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
width="92%"
|
||||
style={{ maxWidth: 860 }}
|
||||
footer={
|
||||
<Space wrap>
|
||||
<Popconfirm title="确定删除?" onConfirm={() => onDelete(item.id)}>
|
||||
<Button danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={regenerating}
|
||||
onClick={() => onRegenerate(item.id)}
|
||||
>
|
||||
重新生成
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
disabled={item.status !== 'done'}
|
||||
onClick={() => onDownload(item)}
|
||||
>
|
||||
下载 Markdown
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Tag>{COMPOSITION_STATUS_LABELS[item.status]}</Tag>
|
||||
<Tag>{item.input_mode === 'ocr' ? 'OCR 识别' : '手动输入'}</Tag>
|
||||
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
|
||||
{new Date(item.created_at).toLocaleString()}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text strong>题目</Typography.Text>
|
||||
<pre
|
||||
style={{
|
||||
marginTop: 8,
|
||||
background: '#fafafa',
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{item.topic}
|
||||
</pre>
|
||||
</div>
|
||||
{item.error_message && <Alert type="error" message={item.error_message} showIcon />}
|
||||
{processing && (
|
||||
<div style={{ textAlign: 'center', padding: 24 }}>
|
||||
<Spin tip="正在生成写作方案与范文…" />
|
||||
</div>
|
||||
)}
|
||||
{item.status === 'done' && (
|
||||
<>
|
||||
{item.writing_plan && (
|
||||
<div>
|
||||
<Typography.Title level={5}>写作方案</Typography.Title>
|
||||
<div style={{ background: '#fafafa', padding: 12, borderRadius: 8 }}>
|
||||
<ReactMarkdown>{item.writing_plan}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.sample_essay && (
|
||||
<div>
|
||||
<Typography.Title level={5}>范文</Typography.Title>
|
||||
<div style={{ background: '#fafafa', padding: 12, borderRadius: 8 }}>
|
||||
<ReactMarkdown>{item.sample_essay}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { UploadOutlined } from '@ant-design/icons'
|
||||
import { Button, Input, List, Space, Tag, Typography, Upload, message } from 'antd'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { compositionApi } from '../api/client'
|
||||
import type { Composition, CompositionInputMode, Student } from '../types'
|
||||
import { COMPOSITION_STATUS_LABELS } from '../types'
|
||||
import { SCHOOL_LEVEL_LABELS } from '../constants/school'
|
||||
import CompositionDetailModal from './CompositionDetailModal'
|
||||
|
||||
interface Props {
|
||||
studentId: string
|
||||
student: Student
|
||||
}
|
||||
|
||||
export default function CompositionPanel({ studentId, student }: Props) {
|
||||
const [topic, setTopic] = useState('')
|
||||
const [inputMode, setInputMode] = useState<CompositionInputMode>('manual')
|
||||
const [items, setItems] = useState<Composition[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [ocrLoading, setOcrLoading] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [selected, setSelected] = useState<Composition | null>(null)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [regenerating, setRegenerating] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await compositionApi.list(studentId)
|
||||
setItems(data)
|
||||
setSelected((prev) => {
|
||||
if (!prev) return prev
|
||||
return data.find((item) => item.id === prev.id) ?? prev
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [studentId])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
const hasProcessing = items.some(
|
||||
(item) => item.status === 'pending' || item.status === 'generating',
|
||||
)
|
||||
if (!hasProcessing) return
|
||||
const timer = window.setInterval(load, 4000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [items, load])
|
||||
|
||||
const handleOcr = async (file: File) => {
|
||||
setOcrLoading(true)
|
||||
try {
|
||||
const { data } = await compositionApi.ocr(studentId, file)
|
||||
setTopic(data.text)
|
||||
setInputMode('ocr')
|
||||
message.success('题目识别完成')
|
||||
} catch {
|
||||
message.error('题目识别失败')
|
||||
} finally {
|
||||
setOcrLoading(false)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!topic.trim()) {
|
||||
message.warning('请输入或识别作文题目')
|
||||
return
|
||||
}
|
||||
setCreating(true)
|
||||
try {
|
||||
const { data } = await compositionApi.create(studentId, {
|
||||
topic: topic.trim(),
|
||||
input_mode: inputMode,
|
||||
})
|
||||
message.success('已开始生成,请稍后在历史记录中查看')
|
||||
setTopic('')
|
||||
setInputMode('manual')
|
||||
await load()
|
||||
setSelected(data)
|
||||
setDetailOpen(true)
|
||||
} catch {
|
||||
message.error('创建失败')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openDetail = (item: Composition) => {
|
||||
setSelected(item)
|
||||
setDetailOpen(true)
|
||||
}
|
||||
|
||||
const handleRegenerate = async (id: string) => {
|
||||
setRegenerating(true)
|
||||
try {
|
||||
const { data } = await compositionApi.regenerate(id)
|
||||
message.info('正在重新生成…')
|
||||
setSelected(data)
|
||||
await load()
|
||||
} catch {
|
||||
message.error('重新生成失败')
|
||||
} finally {
|
||||
setRegenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await compositionApi.remove(id)
|
||||
message.success('已删除')
|
||||
setDetailOpen(false)
|
||||
setSelected(null)
|
||||
load()
|
||||
}
|
||||
|
||||
const handleDownload = async (item: Composition) => {
|
||||
try {
|
||||
const { data } = await compositionApi.download(item.id)
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${item.topic.slice(0, 20).replace(/[\\/:*?"<>|]/g, '_') || 'composition'}.md`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
message.error('下载失败')
|
||||
}
|
||||
}
|
||||
|
||||
const stage = SCHOOL_LEVEL_LABELS[student.school_level]
|
||||
const gradeText = [stage, student.grade, student.class_name].filter(Boolean).join(' · ')
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
按 {gradeText || stage} 课内要求生成写作方案与范文,严禁超纲。题目可手动输入或拍照 OCR 识别。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<div>
|
||||
<Typography.Text strong>作文题目</Typography.Text>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
value={topic}
|
||||
onChange={(e) => {
|
||||
setTopic(e.target.value)
|
||||
setInputMode('manual')
|
||||
}}
|
||||
placeholder="输入作文题目,或通过下方上传题目图片 OCR 识别"
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
<Space wrap style={{ marginTop: 12 }}>
|
||||
<Upload beforeUpload={handleOcr} showUploadList={false} accept="image/*">
|
||||
<Button icon={<UploadOutlined />} loading={ocrLoading}>
|
||||
上传题目 OCR
|
||||
</Button>
|
||||
</Upload>
|
||||
<Button type="primary" loading={creating} onClick={handleGenerate}>
|
||||
生成写作方案与范文
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||
历史记录
|
||||
</Typography.Title>
|
||||
<List
|
||||
loading={loading}
|
||||
dataSource={items}
|
||||
locale={{ emptyText: '暂无记录,请在上方输入题目并生成' }}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
actions={[
|
||||
<Button type="link" onClick={() => openDetail(item)}>
|
||||
查看
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<Space wrap>
|
||||
<Typography.Text ellipsis style={{ maxWidth: 420 }}>
|
||||
{item.topic}
|
||||
</Typography.Text>
|
||||
<Tag color={item.status === 'done' ? 'green' : item.status === 'failed' ? 'red' : 'blue'}>
|
||||
{COMPOSITION_STATUS_LABELS[item.status]}
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
description={new Date(item.created_at).toLocaleString()}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CompositionDetailModal
|
||||
item={selected}
|
||||
open={detailOpen}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
onRegenerate={handleRegenerate}
|
||||
onDelete={handleDelete}
|
||||
onDownload={handleDownload}
|
||||
regenerating={regenerating}
|
||||
/>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { Button, Checkbox, Divider, Select, Space, Table, Typography, message } from 'antd'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { examApi } from '../api/client'
|
||||
import type { Exam, ReviewStatus } from '../types'
|
||||
import { EXAM_TYPE_LABELS, REVIEW_STATUS_OPTIONS } from '../types'
|
||||
import ReviewBarChart from './ReviewBarChart'
|
||||
import ReviewAiInsight from './ReviewAiInsight'
|
||||
import ReviewSubjectDetail from './ReviewSubjectDetail'
|
||||
|
||||
function apiErrorMessage(err: unknown, fallback: string): string {
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const detail = (err as { response?: { data?: { detail?: unknown } } }).response?.data?.detail
|
||||
if (typeof detail === 'string') return detail
|
||||
if (Array.isArray(detail)) {
|
||||
return detail.map((item) => item?.msg || String(item)).join(';')
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function firstSubjectWithReview(exams: Exam[]): string | null {
|
||||
const names = new Set<string>()
|
||||
for (const exam of exams) {
|
||||
for (const score of exam.scores) {
|
||||
if (score.review_statuses?.length) {
|
||||
names.add(score.subject_name || `科目${score.subject_id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
const sorted = Array.from(names).sort((a, b) => a.localeCompare(b, 'zh-CN'))
|
||||
return sorted[0] || null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
studentId: string
|
||||
exams: Exam[]
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
export default function ExamReviewPanel({ studentId, exams, onRefresh }: Props) {
|
||||
const [examId, setExamId] = useState<string>()
|
||||
const [statusMap, setStatusMap] = useState<Record<number, ReviewStatus[]>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [selectedSubject, setSelectedSubject] = useState<string | null>(null)
|
||||
|
||||
const examOptions = useMemo(
|
||||
() =>
|
||||
exams.map((exam) => ({
|
||||
value: exam.id,
|
||||
label: `${exam.exam_date} · ${EXAM_TYPE_LABELS[exam.exam_type]}${exam.title ? ` · ${exam.title}` : ''}`,
|
||||
})),
|
||||
[exams],
|
||||
)
|
||||
|
||||
const selectedExam = exams.find((e) => e.id === examId)
|
||||
|
||||
useEffect(() => {
|
||||
if (!examId && exams.length) {
|
||||
setExamId(exams[0].id)
|
||||
}
|
||||
}, [examId, exams])
|
||||
|
||||
useEffect(() => {
|
||||
const defaultSubject = firstSubjectWithReview(exams)
|
||||
if (defaultSubject) {
|
||||
setSelectedSubject((prev) => prev ?? defaultSubject)
|
||||
}
|
||||
}, [exams])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedExam) {
|
||||
setStatusMap({})
|
||||
return
|
||||
}
|
||||
const next: Record<number, ReviewStatus[]> = {}
|
||||
for (const score of selectedExam.scores) {
|
||||
next[score.subject_id] = [...(score.review_statuses || [])]
|
||||
}
|
||||
setStatusMap(next)
|
||||
}, [selectedExam])
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedExam) {
|
||||
message.warning('请选择考试')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
await examApi.update(selectedExam.id, {
|
||||
scores: selectedExam.scores.map((score) => ({
|
||||
subject_id: score.subject_id,
|
||||
total_score: Number(score.total_score),
|
||||
obtained_score: Number(score.obtained_score),
|
||||
review_statuses: statusMap[score.subject_id] || [],
|
||||
})),
|
||||
})
|
||||
message.success('复盘已保存')
|
||||
onRefresh()
|
||||
} catch (err) {
|
||||
message.error(apiErrorMessage(err, '保存失败'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (exams.length === 0) {
|
||||
return (
|
||||
<Typography.Text type="secondary">请先录入至少一次考试成绩,再进行复盘</Typography.Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||
填写复盘
|
||||
</Typography.Title>
|
||||
<Space wrap style={{ width: '100%', marginBottom: 12 }}>
|
||||
<Select
|
||||
style={{ minWidth: 280 }}
|
||||
placeholder="选择考试"
|
||||
value={examId}
|
||||
onChange={setExamId}
|
||||
options={examOptions}
|
||||
/>
|
||||
<Button type="primary" loading={saving} onClick={handleSave}>
|
||||
保存复盘
|
||||
</Button>
|
||||
</Space>
|
||||
{selectedExam && (
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="subject_id"
|
||||
dataSource={selectedExam.scores}
|
||||
scroll={{ x: 480 }}
|
||||
columns={[
|
||||
{
|
||||
title: '科目',
|
||||
dataIndex: 'subject_name',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '得分',
|
||||
width: 100,
|
||||
render: (_, row) => `${row.obtained_score}/${row.total_score}`,
|
||||
},
|
||||
{
|
||||
title: '考试状态(可多选)',
|
||||
render: (_, row) => (
|
||||
<Checkbox.Group
|
||||
options={REVIEW_STATUS_OPTIONS}
|
||||
value={statusMap[row.subject_id] || []}
|
||||
onChange={(values) =>
|
||||
setStatusMap((prev) => ({
|
||||
...prev,
|
||||
[row.subject_id]: values as ReviewStatus[],
|
||||
}))
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
|
||||
<div>
|
||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||
复盘统计
|
||||
</Typography.Title>
|
||||
<ReviewBarChart
|
||||
exams={exams}
|
||||
selectedSubject={selectedSubject}
|
||||
onSubjectSelect={setSelectedSubject}
|
||||
/>
|
||||
<ReviewSubjectDetail exams={exams} subjectName={selectedSubject} />
|
||||
<ReviewAiInsight studentId={studentId} subjectName={selectedSubject} />
|
||||
</div>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Button, Modal, Slider, Space } from 'antd'
|
||||
import { useCallback, useState } from 'react'
|
||||
import Cropper, { type Area } from 'react-easy-crop'
|
||||
import { blobToFile, cropImageToBlob } from '../utils/cropImage'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
imageSrc: string | null
|
||||
filename: string
|
||||
onCancel: () => void
|
||||
onConfirm: (file: File) => void
|
||||
onSkip?: (file: File) => void
|
||||
originalFile?: File | null
|
||||
}
|
||||
|
||||
export default function ImageCropModal({
|
||||
open,
|
||||
imageSrc,
|
||||
filename,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
onSkip,
|
||||
originalFile,
|
||||
}: Props) {
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 })
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [croppedArea, setCroppedArea] = useState<Area | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const onCropComplete = useCallback((_area: Area, pixels: Area) => {
|
||||
setCroppedArea(pixels)
|
||||
}, [])
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!imageSrc || !croppedArea) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const blob = await cropImageToBlob(imageSrc, croppedArea)
|
||||
onConfirm(blobToFile(blob, filename.replace(/\.\w+$/, '') + '.jpg'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSkip = () => {
|
||||
if (originalFile && onSkip) {
|
||||
onSkip(originalFile)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="裁剪题目区域"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
width="92%"
|
||||
style={{ maxWidth: 560 }}
|
||||
destroyOnHidden
|
||||
footer={
|
||||
<Space wrap>
|
||||
<Button onClick={onCancel}>取消</Button>
|
||||
{onSkip && originalFile && (
|
||||
<Button onClick={handleSkip} disabled={submitting}>
|
||||
不裁剪,直接上传
|
||||
</Button>
|
||||
)}
|
||||
<Button type="primary" loading={submitting} onClick={handleConfirm}>
|
||||
确认上传
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<p style={{ margin: '0 0 12px', color: '#666', fontSize: 13 }}>
|
||||
拖动框选仅保留错题区域,识别会更准确
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: 320,
|
||||
background: '#111',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{imageSrc && (
|
||||
<Cropper
|
||||
image={imageSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={undefined}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={onCropComplete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<span style={{ fontSize: 12, color: '#666' }}>缩放</span>
|
||||
<Slider min={1} max={3} step={0.05} value={zoom} onChange={setZoom} />
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Alert, Button, Spin, Typography } from 'antd'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { examApi, settingsApi } from '../api/client'
|
||||
|
||||
interface Props {
|
||||
studentId: string
|
||||
subjectName: string | null
|
||||
}
|
||||
|
||||
function apiErrorMessage(err: unknown, fallback: string): string {
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const detail = (err as { response?: { data?: { detail?: unknown } } }).response?.data?.detail
|
||||
if (typeof detail === 'string') return detail
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
export default function ReviewAiInsight({ studentId, subjectName }: Props) {
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
const [insight, setInsight] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
settingsApi.appFeatures().then(({ data }) => setEnabled(data.ai_review_enabled)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const loadInsight = useCallback(async () => {
|
||||
if (!subjectName || !enabled) {
|
||||
setInsight(null)
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { data } = await examApi.reviewInsight(studentId, subjectName)
|
||||
setInsight(data.insight)
|
||||
} catch (err) {
|
||||
setError(apiErrorMessage(err, 'AI 解读生成失败,请检查模型配置'))
|
||||
setInsight(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [studentId, subjectName, enabled])
|
||||
|
||||
useEffect(() => {
|
||||
loadInsight()
|
||||
}, [loadInsight])
|
||||
|
||||
if (!subjectName) return null
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<Alert
|
||||
style={{ marginTop: 20 }}
|
||||
type="info"
|
||||
showIcon
|
||||
message="AI 复盘解读已在系统设置中关闭"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||||
<Typography.Text strong style={{ fontSize: 15 }}>
|
||||
AI 解读与建议 · {subjectName}
|
||||
</Typography.Text>
|
||||
<Button size="small" loading={loading} onClick={loadInsight}>
|
||||
重新生成
|
||||
</Button>
|
||||
</div>
|
||||
{loading && !insight && (
|
||||
<div style={{ textAlign: 'center', padding: 24 }}>
|
||||
<Spin tip="AI 正在分析复盘数据…" />
|
||||
</div>
|
||||
)}
|
||||
{error && <Alert type="error" message={error} showIcon style={{ marginBottom: 12 }} />}
|
||||
{insight && (
|
||||
<div
|
||||
style={{
|
||||
background: '#f6ffed',
|
||||
border: '1px solid #b7eb8f',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<ReactMarkdown>{insight}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import { useMemo } from 'react'
|
||||
import type { Exam, ReviewStatus } from '../types'
|
||||
import { REVIEW_STATUS_LABELS } from '../types'
|
||||
|
||||
interface Props {
|
||||
exams: Exam[]
|
||||
selectedSubject?: string | null
|
||||
onSubjectSelect?: (subject: string) => void
|
||||
}
|
||||
|
||||
const STATUS_ORDER: ReviewStatus[] = ['careless', 'unknown', 'nervous', 'normal']
|
||||
|
||||
const STATUS_COLORS: Record<ReviewStatus, string> = {
|
||||
careless: '#fa8c16',
|
||||
unknown: '#ff4d4f',
|
||||
nervous: '#722ed1',
|
||||
normal: '#52c41a',
|
||||
}
|
||||
|
||||
function buildChartData(exams: Exam[]) {
|
||||
const subjectSet = new Set<string>()
|
||||
const counts: Record<ReviewStatus, Record<string, number>> = {
|
||||
careless: {},
|
||||
unknown: {},
|
||||
nervous: {},
|
||||
normal: {},
|
||||
}
|
||||
|
||||
for (const exam of exams) {
|
||||
for (const score of exam.scores) {
|
||||
const subjectName = score.subject_name || `科目${score.subject_id}`
|
||||
subjectSet.add(subjectName)
|
||||
for (const status of score.review_statuses || []) {
|
||||
counts[status][subjectName] = (counts[status][subjectName] || 0) + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const subjects = Array.from(subjectSet).sort((a, b) => a.localeCompare(b, 'zh-CN'))
|
||||
return { subjects, counts }
|
||||
}
|
||||
|
||||
export default function ReviewBarChart({ exams, selectedSubject, onSubjectSelect }: Props) {
|
||||
const hasData = STATUS_ORDER.some((status) =>
|
||||
exams.some((exam) =>
|
||||
exam.scores.some((score) => (score.review_statuses || []).includes(status)),
|
||||
),
|
||||
)
|
||||
|
||||
const { subjects, counts } = useMemo(() => buildChartData(exams), [exams])
|
||||
|
||||
if (!hasData) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: 32, color: '#999' }}>
|
||||
暂无复盘数据,请在下方填写各科考试状态
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: '各科考试状态统计',
|
||||
subtext: selectedSubject ? `当前选中:${selectedSubject}` : '点击柱子查看明细',
|
||||
left: 'center',
|
||||
textStyle: { fontSize: 15, fontWeight: 500 },
|
||||
subtextStyle: { fontSize: 12, color: '#888' },
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
formatter: (params: Array<{ seriesName: string; value: number; marker: string; name: string }>) => {
|
||||
const rows = params.filter((p) => p.value > 0)
|
||||
if (!rows.length) return ''
|
||||
const total = rows.reduce((sum, p) => sum + p.value, 0)
|
||||
return (
|
||||
`<strong>${params[0]?.name || ''}</strong><br/>` +
|
||||
rows.map((p) => `${p.marker}${p.seriesName}: ${p.value} 次`).join('<br/>') +
|
||||
`<br/>合计: ${total} 次`
|
||||
)
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
bottom: 0,
|
||||
data: STATUS_ORDER.map((s) => REVIEW_STATUS_LABELS[s]),
|
||||
},
|
||||
grid: { left: 48, right: 24, top: 64, bottom: 56 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: subjects,
|
||||
axisLabel: {
|
||||
interval: 0,
|
||||
rotate: subjects.length > 6 ? 30 : 0,
|
||||
color: (value: string) => (value === selectedSubject ? '#1677ff' : '#666'),
|
||||
fontWeight: (value: string) => (value === selectedSubject ? ('bold' as const) : ('normal' as const)),
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '次数',
|
||||
minInterval: 1,
|
||||
splitLine: { lineStyle: { type: 'dashed' } },
|
||||
},
|
||||
series: STATUS_ORDER.map((status) => ({
|
||||
name: REVIEW_STATUS_LABELS[status],
|
||||
type: 'bar',
|
||||
stack: 'review',
|
||||
barMaxWidth: 48,
|
||||
emphasis: { focus: 'series' },
|
||||
data: subjects.map((subject) => {
|
||||
const value = counts[status][subject] || 0
|
||||
const selected = subject === selectedSubject
|
||||
return {
|
||||
value,
|
||||
itemStyle: {
|
||||
color: STATUS_COLORS[status],
|
||||
borderRadius: status === 'normal' ? [2, 2, 0, 0] : 0,
|
||||
opacity: selectedSubject && !selected ? 0.45 : 1,
|
||||
borderColor: selected ? '#1677ff' : 'transparent',
|
||||
borderWidth: selected ? 2 : 0,
|
||||
},
|
||||
}
|
||||
}),
|
||||
})),
|
||||
}
|
||||
|
||||
const onEvents = {
|
||||
click: (params: { componentType?: string; name?: string }) => {
|
||||
if (params.componentType === 'series' && params.name) {
|
||||
onSubjectSelect?.(params.name)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ReactECharts
|
||||
option={option}
|
||||
style={{ height: 360, width: '100%' }}
|
||||
notMerge
|
||||
onEvents={onEvents}
|
||||
/>
|
||||
<p style={{ color: '#888', fontSize: 12, marginTop: 8 }}>
|
||||
柱状图按科目展示各状态次数(分色堆叠);点击科目查看每次考试详情,存在问题标红
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Tag, Typography } from 'antd'
|
||||
import { useMemo } from 'react'
|
||||
import type { Exam } from '../types'
|
||||
import {
|
||||
EXAM_TYPE_LABELS,
|
||||
REVIEW_STATUS_LABELS,
|
||||
hasReviewProblem,
|
||||
} from '../types'
|
||||
import type { ReviewStatus } from '../types'
|
||||
|
||||
interface Props {
|
||||
exams: Exam[]
|
||||
subjectName: string | null
|
||||
}
|
||||
|
||||
interface DetailRow {
|
||||
key: string
|
||||
examDate: string
|
||||
examType: string
|
||||
title: string | null
|
||||
scoreText: string
|
||||
ratioText: string
|
||||
statuses: ReviewStatus[]
|
||||
isProblem: boolean
|
||||
}
|
||||
|
||||
const STATUS_TAG_COLOR: Record<ReviewStatus, string> = {
|
||||
careless: 'orange',
|
||||
unknown: 'red',
|
||||
nervous: 'purple',
|
||||
normal: 'green',
|
||||
}
|
||||
|
||||
export default function ReviewSubjectDetail({ exams, subjectName }: Props) {
|
||||
const rows = useMemo(() => {
|
||||
if (!subjectName) return []
|
||||
const result: DetailRow[] = []
|
||||
for (const exam of exams) {
|
||||
const score = exam.scores.find(
|
||||
(s) => (s.subject_name || `科目${s.subject_id}`) === subjectName,
|
||||
)
|
||||
if (!score || !score.review_statuses?.length) continue
|
||||
result.push({
|
||||
key: exam.id,
|
||||
examDate: exam.exam_date,
|
||||
examType: EXAM_TYPE_LABELS[exam.exam_type],
|
||||
title: exam.title,
|
||||
scoreText: `${score.obtained_score}/${score.total_score}`,
|
||||
ratioText: `${(score.ratio * 100).toFixed(1)}%`,
|
||||
statuses: score.review_statuses,
|
||||
isProblem: hasReviewProblem(score.review_statuses),
|
||||
})
|
||||
}
|
||||
return result.sort((a, b) => b.examDate.localeCompare(a.examDate))
|
||||
}, [exams, subjectName])
|
||||
|
||||
if (!subjectName) {
|
||||
return (
|
||||
<Typography.Text type="secondary" style={{ display: 'block', padding: '12px 0' }}>
|
||||
点击上方柱状图中的科目,查看各次考试详情
|
||||
</Typography.Text>
|
||||
)
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Typography.Text type="secondary" style={{ display: 'block', padding: '12px 0' }}>
|
||||
{subjectName} 暂无复盘记录
|
||||
</Typography.Text>
|
||||
)
|
||||
}
|
||||
|
||||
const problemCount = rows.filter((r) => r.isProblem).length
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Typography.Text strong style={{ fontSize: 15 }}>
|
||||
{subjectName} · 考试明细
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ marginLeft: 12, fontSize: 12 }}>
|
||||
共 {rows.length} 次,其中 {problemCount} 次存在问题
|
||||
</Typography.Text>
|
||||
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.key}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
flexWrap: 'nowrap',
|
||||
overflowX: 'auto',
|
||||
whiteSpace: 'nowrap',
|
||||
padding: '10px 14px',
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${row.isProblem ? '#ffccc7' : '#f0f0f0'}`,
|
||||
background: row.isProblem ? '#fff2f0' : '#fafafa',
|
||||
}}
|
||||
>
|
||||
<Typography.Text strong style={{ color: row.isProblem ? '#cf1322' : undefined }}>
|
||||
{row.examDate}
|
||||
</Typography.Text>
|
||||
<Typography.Text style={{ color: row.isProblem ? '#cf1322' : '#666' }}>
|
||||
{row.examType}
|
||||
</Typography.Text>
|
||||
{row.title && (
|
||||
<Typography.Text style={{ color: row.isProblem ? '#cf1322' : '#888' }}>
|
||||
{row.title}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<Typography.Text style={{ color: row.isProblem ? '#cf1322' : undefined }}>
|
||||
得分 {row.scoreText}({row.ratioText})
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
type={row.isProblem ? 'danger' : 'secondary'}
|
||||
style={{ fontSize: 13 }}
|
||||
>
|
||||
状态
|
||||
</Typography.Text>
|
||||
{row.statuses.map((status) => (
|
||||
<Tag
|
||||
key={status}
|
||||
color={STATUS_TAG_COLOR[status]}
|
||||
style={{
|
||||
marginInlineEnd: 0,
|
||||
...(row.isProblem && status !== 'normal' ? { fontWeight: 600 } : {}),
|
||||
}}
|
||||
>
|
||||
{REVIEW_STATUS_LABELS[status]}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { DeleteOutlined, EditOutlined } from '@ant-design/icons'
|
||||
import { Button, DatePicker, Form, Input, InputNumber, Modal, Select, Space, Table, message } from 'antd'
|
||||
import { Button, Checkbox, DatePicker, Form, Input, InputNumber, Modal, Select, Space, Table, message } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { examApi } from '../api/client'
|
||||
import type { Exam, ExamType, ScoreInput, Subject } from '../types'
|
||||
import { EXAM_TYPE_LABELS } from '../types'
|
||||
import type { Exam, ExamType, ReviewStatus, ScoreInput, Subject } from '../types'
|
||||
import { EXAM_TYPE_LABELS, REVIEW_STATUS_OPTIONS } from '../types'
|
||||
|
||||
interface Props {
|
||||
studentId: string
|
||||
@@ -28,15 +28,20 @@ export default function ScoreForm({ studentId, subjects, exams, onRefresh }: Pro
|
||||
scores: subjects.map((s) => {
|
||||
const found = editing.scores.find((sc) => sc.subject_id === s.id)
|
||||
return found
|
||||
? { subject_id: s.id, total_score: found.total_score, obtained_score: found.obtained_score }
|
||||
: { subject_id: s.id, total_score: undefined, obtained_score: undefined }
|
||||
? {
|
||||
subject_id: s.id,
|
||||
total_score: found.total_score,
|
||||
obtained_score: found.obtained_score,
|
||||
review_statuses: found.review_statuses || [],
|
||||
}
|
||||
: { subject_id: s.id, total_score: undefined, obtained_score: undefined, review_statuses: [] }
|
||||
}),
|
||||
})
|
||||
} else if (modalOpen) {
|
||||
form.setFieldsValue({
|
||||
exam_type: 'weekly',
|
||||
exam_date: dayjs(),
|
||||
scores: subjects.map((s) => ({ subject_id: s.id })),
|
||||
scores: subjects.map((s) => ({ subject_id: s.id, review_statuses: [] })),
|
||||
})
|
||||
}
|
||||
}, [modalOpen, editing, subjects, form])
|
||||
@@ -55,10 +60,11 @@ export default function ScoreForm({ studentId, subjects, exams, onRefresh }: Pro
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
const scores: ScoreInput[] = (values.scores || [])
|
||||
.map((s: ScoreInput, idx: number) => ({
|
||||
.map((s: ScoreInput & { review_statuses?: ReviewStatus[] }, idx: number) => ({
|
||||
subject_id: subjects[idx]?.id ?? s.subject_id,
|
||||
total_score: s.total_score,
|
||||
obtained_score: s.obtained_score,
|
||||
review_statuses: s.review_statuses || [],
|
||||
}))
|
||||
.filter(
|
||||
(s: ScoreInput) =>
|
||||
@@ -71,6 +77,7 @@ export default function ScoreForm({ studentId, subjects, exams, onRefresh }: Pro
|
||||
subject_id: s.subject_id,
|
||||
total_score: Number(s.total_score),
|
||||
obtained_score: Number(s.obtained_score),
|
||||
review_statuses: s.review_statuses || [],
|
||||
}))
|
||||
|
||||
if (scores.length === 0) {
|
||||
@@ -165,11 +172,11 @@ export default function ScoreForm({ studentId, subjects, exams, onRefresh }: Pro
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={handleSubmit}
|
||||
confirmLoading={loading}
|
||||
width={720}
|
||||
width={900}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
<Space style={{ width: '100%' }} size="large" wrap>
|
||||
<Form.Item name="exam_type" label="考试类型" rules={[{ required: true }]}>
|
||||
<Select
|
||||
style={{ width: 120 }}
|
||||
@@ -191,9 +198,11 @@ export default function ScoreForm({ studentId, subjects, exams, onRefresh }: Pro
|
||||
pagination={false}
|
||||
dataSource={fields.map((f, i) => ({ ...f, subject: subjects[i] }))}
|
||||
rowKey="key"
|
||||
scroll={{ x: 720 }}
|
||||
columns={[
|
||||
{
|
||||
title: '科目',
|
||||
width: 70,
|
||||
render: (_, row) => (
|
||||
<>
|
||||
<Form.Item name={[row.name, 'subject_id']} hidden initialValue={row.subject?.id}>
|
||||
@@ -205,22 +214,25 @@ export default function ScoreForm({ studentId, subjects, exams, onRefresh }: Pro
|
||||
},
|
||||
{
|
||||
title: '总分',
|
||||
width: 100,
|
||||
render: (_, row) => (
|
||||
<Form.Item name={[row.name, 'total_score']} noStyle>
|
||||
<InputNumber min={0} placeholder="总分" style={{ width: 100 }} />
|
||||
<InputNumber min={0} placeholder="总分" style={{ width: 90 }} />
|
||||
</Form.Item>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '得分',
|
||||
width: 100,
|
||||
render: (_, row) => (
|
||||
<Form.Item name={[row.name, 'obtained_score']} noStyle>
|
||||
<InputNumber min={0} placeholder="得分" style={{ width: 100 }} />
|
||||
<InputNumber min={0} placeholder="得分" style={{ width: 90 }} />
|
||||
</Form.Item>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '占比',
|
||||
width: 70,
|
||||
render: (_, row) => {
|
||||
const total = form.getFieldValue(['scores', row.name, 'total_score'])
|
||||
const obtained = form.getFieldValue(['scores', row.name, 'obtained_score'])
|
||||
@@ -230,6 +242,14 @@ export default function ScoreForm({ studentId, subjects, exams, onRefresh }: Pro
|
||||
return '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '考试状态',
|
||||
render: (_, row) => (
|
||||
<Form.Item name={[row.name, 'review_statuses']} noStyle initialValue={[]}>
|
||||
<Checkbox.Group options={REVIEW_STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { DeleteOutlined } from '@ant-design/icons'
|
||||
import { Button, Popconfirm, Space, Tag, Typography, message } from 'antd'
|
||||
import { useEffect } from 'react'
|
||||
import { wrongQuestionApi } from '../api/client'
|
||||
import type { WrongQuestion } from '../types'
|
||||
import { isWrongQuestionProcessing, processingHint } from '../utils/wqProcessing'
|
||||
import { STATUS_LABELS } from '../types'
|
||||
import AuthenticatedImage from './AuthenticatedImage'
|
||||
import WrongQuestionDetail from '../pages/WrongQuestionDetail'
|
||||
|
||||
interface Props {
|
||||
items: WrongQuestion[]
|
||||
selectedId: string | null
|
||||
onSelect: (id: string | null) => void
|
||||
onRefresh: () => void
|
||||
emptyText?: string
|
||||
pollWhenProcessing?: boolean
|
||||
}
|
||||
|
||||
function cardSummary(wq: WrongQuestion): { tone: 'error' | 'pending' | 'normal'; text: string } {
|
||||
if (wq.error_message) {
|
||||
return { tone: 'error', text: wq.error_message }
|
||||
}
|
||||
if (isWrongQuestionProcessing(wq)) {
|
||||
return { tone: 'pending', text: processingHint(wq) }
|
||||
}
|
||||
return {
|
||||
tone: 'normal',
|
||||
text: wq.question_text || wq.ocr_raw_text || STATUS_LABELS[wq.status],
|
||||
}
|
||||
}
|
||||
|
||||
export default function WrongQuestionList({
|
||||
items,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onRefresh,
|
||||
emptyText = '暂无记录',
|
||||
pollWhenProcessing = true,
|
||||
}: Props) {
|
||||
const hasProcessing = pollWhenProcessing && items.some(isWrongQuestionProcessing)
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasProcessing) return
|
||||
const timer = window.setInterval(() => onRefresh(), 4000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [hasProcessing, onRefresh])
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await wrongQuestionApi.remove(id)
|
||||
message.success('已删除')
|
||||
if (selectedId === id) onSelect(null)
|
||||
onRefresh()
|
||||
} catch {
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="wq-grid">
|
||||
{items.map((wq) => {
|
||||
const summary = cardSummary(wq)
|
||||
return (
|
||||
<div key={wq.id} className="wq-card">
|
||||
<div className="wq-card-click" onClick={() => onSelect(wq.id)}>
|
||||
<AuthenticatedImage questionId={wq.id} variant="annotated" alt="题目" className="wq-card-img" />
|
||||
<div className="wq-card-body">
|
||||
<Space size={4} wrap>
|
||||
<Typography.Text strong>{wq.subject_name}</Typography.Text>
|
||||
{wq.category === 'olympiad' && <Tag color="gold">奥数</Tag>}
|
||||
<Tag color={wq.error_message || wq.status === 'failed' ? 'error' : isWrongQuestionProcessing(wq) ? 'processing' : 'default'}>
|
||||
{wq.error_message ? '失败' : STATUS_LABELS[wq.status]}
|
||||
</Tag>
|
||||
</Space>
|
||||
<Typography.Paragraph
|
||||
ellipsis={{ rows: 3 }}
|
||||
style={{
|
||||
margin: '8px 0 0',
|
||||
fontSize: 13,
|
||||
color: summary.tone === 'error' ? '#ff4d4f' : summary.tone === 'pending' ? '#1677ff' : undefined,
|
||||
}}
|
||||
>
|
||||
{summary.text}
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
<div className="wq-card-actions">
|
||||
<Popconfirm title="确定删除该题?" onConfirm={() => handleDelete(wq.id)}>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{items.length === 0 && <Typography.Text type="secondary">{emptyText}</Typography.Text>}
|
||||
{selectedId && (
|
||||
<WrongQuestionDetail
|
||||
questionId={selectedId}
|
||||
open={!!selectedId}
|
||||
onClose={() => onSelect(null)}
|
||||
onUpdated={onRefresh}
|
||||
onDeleted={() => {
|
||||
onSelect(null)
|
||||
onRefresh()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,62 +1,171 @@
|
||||
import { ReloadOutlined, UploadOutlined } from '@ant-design/icons'
|
||||
import { Button, Input, Select, Space, Upload, message } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import { CameraOutlined, PictureOutlined, ReloadOutlined, UploadOutlined } from '@ant-design/icons'
|
||||
import { Button, Input, Select, Space, Typography, Upload, message } from 'antd'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { wrongQuestionApi } from '../api/client'
|
||||
import type { Subject } from '../types'
|
||||
import type { Subject, WrongQuestionCategory } from '../types'
|
||||
import ImageCropModal from './ImageCropModal'
|
||||
|
||||
interface Props {
|
||||
studentId: string
|
||||
subjects: Subject[]
|
||||
category: WrongQuestionCategory
|
||||
onUploaded: () => void
|
||||
}
|
||||
|
||||
export default function WrongQuestionUpload({ studentId, subjects, onUploaded }: Props) {
|
||||
const [subjectId, setSubjectId] = useState<number | undefined>(subjects[0]?.id)
|
||||
export default function WrongQuestionUpload({ studentId, subjects, category, onUploaded }: Props) {
|
||||
const isOlympiad = category === 'olympiad'
|
||||
const availableSubjects = useMemo(
|
||||
() => (isOlympiad ? subjects.filter((s) => s.name === '数学') : subjects),
|
||||
[subjects, isOlympiad],
|
||||
)
|
||||
const [subjectId, setSubjectId] = useState<number | undefined>()
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [cropOpen, setCropOpen] = useState(false)
|
||||
const [cropSrc, setCropSrc] = useState<string | null>(null)
|
||||
const [pendingFile, setPendingFile] = useState<File | null>(null)
|
||||
const cameraRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
useEffect(() => {
|
||||
if (availableSubjects.length) {
|
||||
setSubjectId(availableSubjects[0].id)
|
||||
}
|
||||
}, [availableSubjects])
|
||||
|
||||
const closeCrop = () => {
|
||||
if (cropSrc) URL.revokeObjectURL(cropSrc)
|
||||
setCropOpen(false)
|
||||
setCropSrc(null)
|
||||
setPendingFile(null)
|
||||
}
|
||||
|
||||
const openCrop = (file: File) => {
|
||||
if (!subjectId) {
|
||||
message.warning('请选择科目')
|
||||
return false
|
||||
message.warning(isOlympiad ? '未找到数学科目' : '请选择科目')
|
||||
return
|
||||
}
|
||||
setCropSrc(URL.createObjectURL(file))
|
||||
setPendingFile(file)
|
||||
setCropOpen(true)
|
||||
}
|
||||
|
||||
const doUpload = async (file: File) => {
|
||||
if (!subjectId) {
|
||||
message.warning(isOlympiad ? '未找到数学科目' : '请选择科目')
|
||||
return
|
||||
}
|
||||
setUploading(true)
|
||||
try {
|
||||
await wrongQuestionApi.upload(studentId, subjectId, file)
|
||||
message.success('上传成功,正在 OCR 识别并生成解法…')
|
||||
await wrongQuestionApi.upload(studentId, subjectId, file, category)
|
||||
message.success('上传成功,正在识别并生成解法…')
|
||||
onUploaded()
|
||||
} catch {
|
||||
message.error('上传失败')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePickFile = (file: File) => {
|
||||
openCrop(file)
|
||||
return false
|
||||
}
|
||||
|
||||
const handleCamera = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (file) openCrop(file)
|
||||
}
|
||||
|
||||
const handleCropConfirm = async (file: File) => {
|
||||
closeCrop()
|
||||
await doUpload(file)
|
||||
}
|
||||
|
||||
const handleSkipCrop = async (file: File) => {
|
||||
closeCrop()
|
||||
await doUpload(file)
|
||||
}
|
||||
|
||||
return (
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
style={{ width: 120 }}
|
||||
placeholder="选择科目"
|
||||
value={subjectId}
|
||||
onChange={setSubjectId}
|
||||
options={subjects.map((s) => ({ value: s.id, label: s.name }))}
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%', marginBottom: 16 }}>
|
||||
{isOlympiad ? (
|
||||
<Typography.Text>科目:数学(奥数区仅支持数学)</Typography.Text>
|
||||
) : (
|
||||
<Select
|
||||
style={{ width: '100%', maxWidth: 200 }}
|
||||
placeholder="选择科目"
|
||||
value={subjectId}
|
||||
onChange={setSubjectId}
|
||||
options={availableSubjects.map((s) => ({ value: s.id, label: s.name }))}
|
||||
/>
|
||||
)}
|
||||
<Space wrap className="upload-actions">
|
||||
<span className="upload-mobile-only">
|
||||
<Upload beforeUpload={handlePickFile} showUploadList={false} accept="image/*">
|
||||
<Button icon={<PictureOutlined />} loading={uploading} type="primary" size="large">
|
||||
相册选图
|
||||
</Button>
|
||||
</Upload>
|
||||
<Button
|
||||
icon={<CameraOutlined />}
|
||||
loading={uploading}
|
||||
size="large"
|
||||
onClick={() => cameraRef.current?.click()}
|
||||
>
|
||||
拍照上传
|
||||
</Button>
|
||||
<input
|
||||
ref={cameraRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleCamera}
|
||||
/>
|
||||
</span>
|
||||
<span className="upload-desktop-only">
|
||||
<Upload beforeUpload={handlePickFile} showUploadList={false} accept="image/*">
|
||||
<Button icon={<UploadOutlined />} loading={uploading} type="primary" size="large">
|
||||
上传图片
|
||||
</Button>
|
||||
</Upload>
|
||||
</span>
|
||||
{!isOlympiad && (
|
||||
<span className="upload-mobile-only">
|
||||
<Upload beforeUpload={handlePickFile} showUploadList={false} accept="image/*">
|
||||
<Button icon={<UploadOutlined />} loading={uploading} size="large">
|
||||
上传图片
|
||||
</Button>
|
||||
</Upload>
|
||||
</span>
|
||||
)}
|
||||
</Space>
|
||||
{isOlympiad && (
|
||||
<span style={{ color: '#666', fontSize: 13 }}>
|
||||
奥数区仅数学,按学生学段(初中/高中)生成解题思路,严禁超纲
|
||||
</span>
|
||||
)}
|
||||
<ImageCropModal
|
||||
open={cropOpen}
|
||||
imageSrc={cropSrc}
|
||||
filename={pendingFile?.name || 'question.jpg'}
|
||||
originalFile={pendingFile}
|
||||
onCancel={closeCrop}
|
||||
onConfirm={handleCropConfirm}
|
||||
onSkip={handleSkipCrop}
|
||||
/>
|
||||
<Upload beforeUpload={handleUpload} showUploadList={false} accept="image/*">
|
||||
<Button icon={<UploadOutlined />} loading={uploading} type="primary">
|
||||
上传错题图片
|
||||
</Button>
|
||||
</Upload>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
interface SearchProps {
|
||||
subjectId?: number
|
||||
onSubjectChange: (id?: number) => void
|
||||
onSubjectChange?: (id?: number) => void
|
||||
search: string
|
||||
onSearchChange: (q: string) => void
|
||||
onRefresh: () => void
|
||||
subjects: Subject[]
|
||||
hideSubjectFilter?: boolean
|
||||
}
|
||||
|
||||
export function WrongQuestionFilters({
|
||||
@@ -66,23 +175,26 @@ export function WrongQuestionFilters({
|
||||
onSearchChange,
|
||||
onRefresh,
|
||||
subjects,
|
||||
hideSubjectFilter,
|
||||
}: SearchProps) {
|
||||
return (
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
placeholder="全部科目"
|
||||
value={subjectId}
|
||||
onChange={onSubjectChange}
|
||||
options={subjects.map((s) => ({ value: s.id, label: s.name }))}
|
||||
/>
|
||||
<Space wrap style={{ marginBottom: 16, width: '100%' }}>
|
||||
{!hideSubjectFilter && (
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: '100%', maxWidth: 140 }}
|
||||
placeholder="全部科目"
|
||||
value={subjectId}
|
||||
onChange={onSubjectChange}
|
||||
options={subjects.map((s) => ({ value: s.id, label: s.name }))}
|
||||
/>
|
||||
)}
|
||||
<Input.Search
|
||||
placeholder="搜索题目/解法"
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
onSearch={onRefresh}
|
||||
style={{ width: 220 }}
|
||||
onSearch={() => onRefresh()}
|
||||
style={{ width: '100%', maxWidth: 260 }}
|
||||
allowClear
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={onRefresh}>
|
||||
|
||||
@@ -7,20 +7,44 @@ export const SCHOOL_LEVEL_LABELS: Record<SchoolLevel, string> = {
|
||||
senior_high: '高中',
|
||||
}
|
||||
|
||||
export const GRADE_OPTIONS: Record<SchoolLevel, string[]> = {
|
||||
junior_high: ['初一', '初二', '初三'],
|
||||
senior_high: ['高一', '高二', '高三'],
|
||||
export const GRADE_OPTIONS: Record<SchoolLevel, { value: string; label: string }[]> = {
|
||||
junior_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_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
|
||||
class_name?: string | null
|
||||
}): string {
|
||||
const parts = [
|
||||
SCHOOL_LEVEL_LABELS[student.school_level],
|
||||
student.grade,
|
||||
student.class_name,
|
||||
].filter(Boolean)
|
||||
return parts.length ? parts.join(' · ') : '未设置学段年级'
|
||||
return formatStudentMeta(student, { includeLevel: false })
|
||||
}
|
||||
|
||||
+113
-1
@@ -8,18 +8,130 @@ body {
|
||||
sans-serif;
|
||||
background: #f5f5f5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.page-container {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 16px 32px;
|
||||
padding-bottom: max(32px, env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.student-tabs .ant-tabs-nav {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.wq-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.wq-card {
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
transition: box-shadow 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wq-card-click {
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.wq-card-click:active {
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.wq-card-actions {
|
||||
padding: 4px 8px 8px;
|
||||
border-top: 1px solid #f5f5f5;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.wq-card-img {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
object-fit: cover;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.wq-card-body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.upload-actions .ant-btn {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.upload-mobile-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-desktop-only {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-container {
|
||||
padding: 12px 12px 24px;
|
||||
}
|
||||
|
||||
.ant-tabs-tab {
|
||||
padding: 8px 10px !important;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ant-modal {
|
||||
max-width: calc(100vw - 16px) !important;
|
||||
margin: 8px auto !important;
|
||||
}
|
||||
|
||||
.ant-table {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.upload-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upload-mobile-only {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upload-desktop-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-actions .ant-btn {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.wq-card-img {
|
||||
height: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { LockOutlined, UserOutlined } from '@ant-design/icons'
|
||||
import { Button, Card, Form, Input, Tabs, Typography, message } from 'antd'
|
||||
import { Button, Card, Form, Input, Spin, Tabs, Typography, message } from 'antd'
|
||||
import axios from 'axios'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Navigate, useNavigate } from 'react-router-dom'
|
||||
import { settingsApi } from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
function apiErrorMessage(error: unknown, fallback: string) {
|
||||
@@ -18,6 +20,16 @@ export default function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const [loginForm] = Form.useForm()
|
||||
const [registerForm] = Form.useForm()
|
||||
const [registrationEnabled, setRegistrationEnabled] = useState(true)
|
||||
const [settingsLoading, setSettingsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
settingsApi
|
||||
.public()
|
||||
.then((res) => setRegistrationEnabled(res.data.registration_enabled))
|
||||
.catch(() => setRegistrationEnabled(true))
|
||||
.finally(() => setSettingsLoading(false))
|
||||
}, [])
|
||||
|
||||
if (!loading && user) return <Navigate to="/" replace />
|
||||
|
||||
@@ -45,6 +57,61 @@ export default function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'login',
|
||||
label: '登录',
|
||||
children: (
|
||||
<Form form={loginForm} onFinish={onLogin} layout="vertical">
|
||||
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||
<Input prefix={<UserOutlined />} placeholder="用户名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>
|
||||
登录
|
||||
</Button>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
if (registrationEnabled) {
|
||||
tabItems.push({
|
||||
key: 'register',
|
||||
label: '注册',
|
||||
children: (
|
||||
<Form form={registerForm} onFinish={onRegister} layout="vertical">
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[
|
||||
{ required: true, message: '请输入用户名' },
|
||||
{ min: 3, message: '至少3个字符' },
|
||||
]}
|
||||
>
|
||||
<Input prefix={<UserOutlined />} placeholder="用户名" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[
|
||||
{ required: true, message: '请输入密码' },
|
||||
{ min: 6, message: '至少6个字符' },
|
||||
]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
|
||||
</Form.Item>
|
||||
<Form.Item name="confirm" rules={[{ required: true, message: '请确认密码' }]}>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="确认密码" />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>
|
||||
注册
|
||||
</Button>
|
||||
</Form>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -60,62 +127,14 @@ export default function LoginPage() {
|
||||
<Typography.Paragraph type="secondary" style={{ textAlign: 'center' }}>
|
||||
成绩录入 · 趋势分析 · 错题管理
|
||||
</Typography.Paragraph>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'login',
|
||||
label: '登录',
|
||||
children: (
|
||||
<Form form={loginForm} onFinish={onLogin} layout="vertical">
|
||||
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||
<Input prefix={<UserOutlined />} placeholder="用户名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>
|
||||
登录
|
||||
</Button>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'register',
|
||||
label: '注册',
|
||||
children: (
|
||||
<Form form={registerForm} onFinish={onRegister} layout="vertical">
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[
|
||||
{ required: true, message: '请输入用户名' },
|
||||
{ min: 3, message: '至少3个字符' },
|
||||
]}
|
||||
>
|
||||
<Input prefix={<UserOutlined />} placeholder="用户名" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[
|
||||
{ required: true, message: '请输入密码' },
|
||||
{ min: 6, message: '至少6个字符' },
|
||||
]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="confirm"
|
||||
rules={[{ required: true, message: '请确认密码' }]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="确认密码" />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>
|
||||
注册
|
||||
</Button>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Spin spinning={settingsLoading}>
|
||||
{!settingsLoading && !registrationEnabled && (
|
||||
<Typography.Paragraph type="secondary" style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||
注册已关闭,请联系管理员获取账号
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
<Tabs items={tabItems} />
|
||||
</Spin>
|
||||
<Typography.Paragraph
|
||||
type="secondary"
|
||||
style={{ textAlign: 'center', fontSize: 12, marginTop: 16, marginBottom: 0 }}
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
import { DownloadOutlined, LockOutlined, SettingOutlined, UploadOutlined, UserOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Radio,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tabs,
|
||||
Typography,
|
||||
Upload,
|
||||
message,
|
||||
} from 'antd'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, Navigate } from 'react-router-dom'
|
||||
import { adminApi } from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
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() {
|
||||
const { user } = useAuth()
|
||||
const [settings, setSettings] = useState<SystemSettings | null>(null)
|
||||
const [users, setUsers] = useState<AdminUser[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [profileForm] = Form.useForm()
|
||||
const [createForm] = Form.useForm()
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [resetUser, setResetUser] = useState<AdminUser | null>(null)
|
||||
const [resetForm] = 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
|
||||
|
||||
if (!user?.is_superuser) return <Navigate to="/" replace />
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [settingsRes, usersRes, backupsRes] = await Promise.all([
|
||||
adminApi.getSettings(),
|
||||
adminApi.listUsers(),
|
||||
adminApi.listBackups().catch(() => ({ data: [] as BackupInfo[] })),
|
||||
])
|
||||
setSettings(settingsRes.data)
|
||||
setUsers(usersRes.data)
|
||||
setBackups(backupsRes.data)
|
||||
profileForm.setFieldsValue({ username: user.username })
|
||||
aiForm.setFieldsValue({
|
||||
ai_provider: settingsRes.data.ai_provider,
|
||||
ollama_base_url: settingsRes.data.ollama_base_url || '',
|
||||
ollama_model: settingsRes.data.ollama_model || '',
|
||||
openai_base_url: settingsRes.data.openai_base_url || '',
|
||||
openai_model: settingsRes.data.openai_model || '',
|
||||
openai_api_key: '',
|
||||
ocr_service_url: settingsRes.data.ocr_service_url || '',
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const toggleRegistration = async (checked: boolean) => {
|
||||
const { data } = await adminApi.updateSettings({ registration_enabled: checked })
|
||||
setSettings(data)
|
||||
message.success(checked ? '已开放注册' : '已关闭注册')
|
||||
}
|
||||
|
||||
const toggleAiReview = async (checked: boolean) => {
|
||||
const { data } = await adminApi.updateSettings({ ai_review_enabled: checked })
|
||||
setSettings(data)
|
||||
message.success(checked ? '已开启 AI 复盘解读' : '已关闭 AI 复盘解读')
|
||||
}
|
||||
|
||||
const saveProfile = async (values: {
|
||||
username: string
|
||||
current_password?: string
|
||||
password?: string
|
||||
confirm?: string
|
||||
}) => {
|
||||
if (values.password && values.password !== values.confirm) {
|
||||
message.error('两次密码不一致')
|
||||
return
|
||||
}
|
||||
await adminApi.updateProfile({
|
||||
username: values.username !== user?.username ? values.username : undefined,
|
||||
current_password: values.password ? values.current_password : undefined,
|
||||
password: values.password || undefined,
|
||||
})
|
||||
message.success('账号信息已更新,若修改了用户名或密码请重新登录')
|
||||
}
|
||||
|
||||
const createUser = async (values: { username: string; password: string }) => {
|
||||
await adminApi.createUser(values)
|
||||
message.success('用户已创建')
|
||||
setCreateOpen(false)
|
||||
createForm.resetFields()
|
||||
load()
|
||||
}
|
||||
|
||||
const resetPassword = async (values: { password: string }) => {
|
||||
if (!resetUser) return
|
||||
await adminApi.resetUserPassword(resetUser.id, values.password)
|
||||
message.success('密码已重置')
|
||||
setResetUser(null)
|
||||
resetForm.resetFields()
|
||||
}
|
||||
|
||||
const removeUser = async (id: string) => {
|
||||
await adminApi.deleteUser(id)
|
||||
message.success('用户已删除')
|
||||
load()
|
||||
}
|
||||
|
||||
const saveAiSettings = async (values: {
|
||||
ai_provider: AIProvider
|
||||
ollama_base_url?: string
|
||||
ollama_model?: string
|
||||
openai_base_url?: string
|
||||
openai_model?: string
|
||||
openai_api_key?: string
|
||||
ocr_service_url?: string
|
||||
}) => {
|
||||
const payload: Parameters<typeof adminApi.updateSettings>[0] = {
|
||||
ai_provider: values.ai_provider,
|
||||
ollama_base_url: values.ollama_base_url || null,
|
||||
ollama_model: values.ollama_model || null,
|
||||
openai_base_url: values.openai_base_url || null,
|
||||
openai_model: values.openai_model || null,
|
||||
ocr_service_url: values.ocr_service_url?.trim() || null,
|
||||
}
|
||||
if (values.openai_api_key?.trim()) {
|
||||
payload.openai_api_key = values.openai_api_key.trim()
|
||||
}
|
||||
const { data } = await adminApi.updateSettings(payload)
|
||||
setSettings(data)
|
||||
aiForm.setFieldValue('openai_api_key', '')
|
||||
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 (
|
||||
<div className="page-container">
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Typography.Title level={3} style={{ margin: 0 }}>
|
||||
系统设置
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
超级管理员 · <Link to="/">返回首页</Link>
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'general',
|
||||
label: '基本设置',
|
||||
children: (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<Card title="注册开关" loading={loading}>
|
||||
<Space>
|
||||
<Switch
|
||||
checked={settings?.registration_enabled ?? true}
|
||||
onChange={toggleRegistration}
|
||||
/>
|
||||
<Typography.Text>
|
||||
{settings?.registration_enabled
|
||||
? '开放注册:用户可在登录页自行注册'
|
||||
: '关闭注册:仅超级管理员可添加用户'}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card title="管理员账号">
|
||||
<Form form={profileForm} layout="vertical" onFinish={saveProfile}>
|
||||
<Form.Item
|
||||
name="username"
|
||||
label="用户名"
|
||||
rules={[{ required: true, min: 3 }]}
|
||||
>
|
||||
<Input prefix={<UserOutlined />} />
|
||||
</Form.Item>
|
||||
<Form.Item name="current_password" label="当前密码(修改密码时必填)">
|
||||
<Input.Password prefix={<LockOutlined />} />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label="新密码(留空则不修改)">
|
||||
<Input.Password prefix={<LockOutlined />} />
|
||||
</Form.Item>
|
||||
<Form.Item name="confirm" label="确认新密码">
|
||||
<Input.Password prefix={<LockOutlined />} />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SettingOutlined />}>
|
||||
保存
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ai',
|
||||
label: 'AI 模型',
|
||||
children: (
|
||||
<Card title="解题 AI 配置" loading={loading}>
|
||||
<Form form={aiForm} layout="vertical" onFinish={saveAiSettings}>
|
||||
<Form.Item name="ai_provider" label="AI 提供商" rules={[{ required: true }]}>
|
||||
<Radio.Group>
|
||||
<Radio.Button value="ollama">本地 Ollama</Radio.Button>
|
||||
<Radio.Button value="openai">OpenAI 兼容 API</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
{(aiProvider || settings?.ai_provider) === 'ollama' && (
|
||||
<>
|
||||
<Form.Item name="ollama_base_url" label="Ollama 地址">
|
||||
<Input placeholder="http://127.0.0.1:11434" />
|
||||
</Form.Item>
|
||||
<Form.Item name="ollama_model" label="Ollama 模型">
|
||||
<Input placeholder="qwen2.5:7b" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
{(aiProvider || settings?.ai_provider) === 'openai' && (
|
||||
<>
|
||||
<Form.Item name="openai_base_url" label="API Base URL">
|
||||
<Input placeholder="https://api.openai.com/v1" />
|
||||
</Form.Item>
|
||||
<Form.Item name="openai_model" label="模型名称">
|
||||
<Input placeholder="gpt-4o-mini" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="openai_api_key"
|
||||
label="API Key"
|
||||
extra={
|
||||
settings?.openai_api_key_set
|
||||
? '已配置 Key,留空则不修改'
|
||||
: '请输入 API Key'
|
||||
}
|
||||
>
|
||||
<Input.Password placeholder="sk-..." />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
<Form.Item
|
||||
name="ocr_service_url"
|
||||
label="OCR 服务地址(局域网 GPU 机器)"
|
||||
extra="留空则在应用服务器本机 CPU 识别。同机部署时填 http://127.0.0.1:23567(install.sh 已自动配置)"
|
||||
>
|
||||
<Input placeholder="http://192.168.8.100:23567" />
|
||||
</Form.Item>
|
||||
<Typography.Paragraph type="secondary">
|
||||
错题/奥数解法将按学生学段(初中/高中)生成,并严格禁止超纲解题。
|
||||
</Typography.Paragraph>
|
||||
<Form.Item label="AI 复盘解读">
|
||||
<Space>
|
||||
<Switch
|
||||
checked={settings?.ai_review_enabled ?? true}
|
||||
onChange={toggleAiReview}
|
||||
/>
|
||||
<Typography.Text>
|
||||
{settings?.ai_review_enabled !== false
|
||||
? '已开启:成绩复盘页可生成 AI 解读'
|
||||
: '已关闭:成绩复盘页不调用 AI'}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
保存 AI 配置
|
||||
</Button>
|
||||
</Form>
|
||||
</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',
|
||||
label: '用户管理',
|
||||
children: (
|
||||
<Card
|
||||
title="用户列表"
|
||||
extra={
|
||||
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
||||
添加用户
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={users}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: '用户名', dataIndex: 'username' },
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'is_superuser',
|
||||
render: (v: boolean) => (v ? '超级管理员' : '普通用户'),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
render: (v: string) => new Date(v).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
render: (_, record) =>
|
||||
record.is_superuser ? (
|
||||
<Typography.Text type="secondary">—</Typography.Text>
|
||||
) : (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => setResetUser(record)}>
|
||||
重置密码
|
||||
</Button>
|
||||
<Popconfirm title="确定删除该用户?" onConfirm={() => removeUser(record.id)}>
|
||||
<Button size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Modal
|
||||
title="添加用户"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={() => createForm.submit()}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={createForm} layout="vertical" onFinish={createUser}>
|
||||
<Form.Item name="username" label="用户名" rules={[{ required: true, min: 3 }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label="密码" rules={[{ required: true, min: 6 }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`重置密码 — ${resetUser?.username}`}
|
||||
open={!!resetUser}
|
||||
onCancel={() => setResetUser(null)}
|
||||
onOk={() => resetForm.submit()}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={resetForm} layout="vertical" onFinish={resetPassword}>
|
||||
<Form.Item name="password" label="新密码" rules={[{ required: true, min: 6 }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +1,44 @@
|
||||
import { ArrowLeftOutlined, DownloadOutlined } from '@ant-design/icons'
|
||||
import { Button, Select, Space, Spin, Tabs, Tag, Typography, message } from 'antd'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { examApi, studentApi, subjectApi, wrongQuestionApi } from '../api/client'
|
||||
import ScoreForm from '../components/ScoreForm'
|
||||
import ScoreOverview from '../components/ScoreOverview'
|
||||
import TrendChart from '../components/TrendChart'
|
||||
import WrongQuestionList from '../components/WrongQuestionList'
|
||||
import WrongQuestionUpload, { WrongQuestionFilters } from '../components/WrongQuestionUpload'
|
||||
import { formatStudentMeta, SCHOOL_LEVEL_LABELS } from '../constants/school'
|
||||
import ExamReviewPanel from '../components/ExamReviewPanel'
|
||||
import CompositionPanel from '../components/CompositionPanel'
|
||||
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 { STATUS_LABELS } from '../types'
|
||||
import WrongQuestionDetail from './WrongQuestionDetail'
|
||||
|
||||
const TAB_KEYS = ['scores', 'overview', 'trend', 'review', 'composition', 'wrong', 'olympiad', 'settings'] as const
|
||||
type TabKey = (typeof TAB_KEYS)[number]
|
||||
|
||||
export default function StudentDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const tabParam = searchParams.get('tab')
|
||||
const activeTab: TabKey = TAB_KEYS.includes(tabParam as TabKey) ? (tabParam as TabKey) : 'scores'
|
||||
|
||||
const [student, setStudent] = useState<Student | null>(null)
|
||||
const [subjects, setSubjects] = useState<Subject[]>([])
|
||||
const [exams, setExams] = useState<Exam[]>([])
|
||||
const [trend, setTrend] = useState<TrendResponse | null>(null)
|
||||
const [selectedSubject, setSelectedSubject] = useState<number>()
|
||||
const [wrongQuestions, setWrongQuestions] = useState<WrongQuestion[]>([])
|
||||
const [olympiadQuestions, setOlympiadQuestions] = useState<WrongQuestion[]>([])
|
||||
const [wqSubjectFilter, setWqSubjectFilter] = useState<number>()
|
||||
const [wqSearch, setWqSearch] = useState('')
|
||||
const [olympiadSearch, setOlympiadSearch] = useState('')
|
||||
const [selectedWq, setSelectedWq] = useState<string | null>(null)
|
||||
const [selectedOlympiad, setSelectedOlympiad] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const mathSubject = useMemo(() => subjects.find((s) => s.name === '数学'), [subjects])
|
||||
const subjectNames = Object.fromEntries(subjects.map((s) => [s.id, s.name]))
|
||||
|
||||
const loadExams = useCallback(async () => {
|
||||
@@ -44,43 +58,89 @@ export default function StudentDetailPage() {
|
||||
const { data } = await wrongQuestionApi.list(id, {
|
||||
subject_id: wqSubjectFilter,
|
||||
q: wqSearch || undefined,
|
||||
category: 'regular',
|
||||
})
|
||||
setWrongQuestions(data)
|
||||
}, [id, wqSubjectFilter, wqSearch])
|
||||
|
||||
const loadOlympiadQuestions = useCallback(async () => {
|
||||
if (!id) return
|
||||
const { data } = await wrongQuestionApi.list(id, {
|
||||
subject_id: mathSubject?.id,
|
||||
q: olympiadSearch || undefined,
|
||||
category: 'olympiad',
|
||||
})
|
||||
setOlympiadQuestions(data)
|
||||
}, [id, mathSubject?.id, olympiadSearch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
const init = async () => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [studentRes, subjectRes] = await Promise.all([
|
||||
studentApi.get(id),
|
||||
subjectApi.list(),
|
||||
])
|
||||
if (cancelled) return
|
||||
setStudent(studentRes.data)
|
||||
setSubjects(subjectRes.data)
|
||||
if (subjectRes.data.length) setSelectedSubject(subjectRes.data[0].id)
|
||||
await loadExams()
|
||||
await loadWrongQuestions()
|
||||
const examRes = await examApi.list(id)
|
||||
if (!cancelled) setExams(examRes.data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
init()
|
||||
}, [id, loadExams, loadWrongQuestions])
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
loadWrongQuestions()
|
||||
}, [loadWrongQuestions])
|
||||
|
||||
useEffect(() => {
|
||||
loadOlympiadQuestions()
|
||||
}, [loadOlympiadQuestions])
|
||||
|
||||
useEffect(() => {
|
||||
loadTrend()
|
||||
}, [loadTrend])
|
||||
|
||||
const handleTabChange = (key: string) => {
|
||||
setSearchParams({ tab: key }, { replace: true })
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!id) return
|
||||
try {
|
||||
const { data } = await examApi.exportCsv(id)
|
||||
const url = URL.createObjectURL(data)
|
||||
const res = await examApi.exportCsv(id)
|
||||
const blob = res.data
|
||||
if (blob.type?.includes('application/json')) {
|
||||
const text = await blob.text()
|
||||
try {
|
||||
const err = JSON.parse(text) as { detail?: string }
|
||||
message.error(err.detail || '导出失败')
|
||||
} catch {
|
||||
message.error('导出失败')
|
||||
}
|
||||
return
|
||||
}
|
||||
const disposition = res.headers['content-disposition'] as string | undefined
|
||||
let filename = `${student?.name || 'student'}_scores.csv`
|
||||
if (disposition) {
|
||||
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i)
|
||||
const plainMatch = disposition.match(/filename="?([^";]+)"?/i)
|
||||
if (utf8Match?.[1]) filename = decodeURIComponent(utf8Match[1])
|
||||
else if (plainMatch?.[1]) filename = plainMatch[1]
|
||||
}
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${student?.name || 'student'}_scores.csv`
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
@@ -98,36 +158,36 @@ export default function StudentDetailPage() {
|
||||
|
||||
if (!student) return <Typography.Text>学生不存在</Typography.Text>
|
||||
|
||||
const stageLabel = SCHOOL_LEVEL_LABELS[student.school_level]
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 1100, margin: '0 auto', padding: '16px 16px 32px' }}>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<div className="page-container">
|
||||
<Space className="page-header" wrap>
|
||||
<Link to="/">
|
||||
<Button icon={<ArrowLeftOutlined />}>返回</Button>
|
||||
</Link>
|
||||
<StudentAvatar student={student} size={40} />
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
{student.name}
|
||||
</Typography.Title>
|
||||
<Tag color={student.school_level === 'senior_high' ? 'purple' : 'blue'}>
|
||||
{SCHOOL_LEVEL_LABELS[student.school_level]}
|
||||
</Tag>
|
||||
<Typography.Text type="secondary">{formatStudentMeta(student)}</Typography.Text>
|
||||
<Tag color={student.school_level === 'senior_high' ? 'purple' : 'blue'}>{stageLabel}</Tag>
|
||||
<Typography.Text type="secondary">{formatStudentSubtitle(student)}</Typography.Text>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleExport}>
|
||||
导出 CSV
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Tabs
|
||||
className="student-tabs"
|
||||
activeKey={activeTab}
|
||||
onChange={handleTabChange}
|
||||
destroyInactiveTabPane={false}
|
||||
items={[
|
||||
{
|
||||
key: 'scores',
|
||||
label: '成绩录入',
|
||||
children: (
|
||||
<ScoreForm
|
||||
studentId={id!}
|
||||
subjects={subjects}
|
||||
exams={exams}
|
||||
onRefresh={loadExams}
|
||||
/>
|
||||
<ScoreForm studentId={id!} subjects={subjects} exams={exams} onRefresh={loadExams} />
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -141,7 +201,7 @@ export default function StudentDetailPage() {
|
||||
children: (
|
||||
<div>
|
||||
<Select
|
||||
style={{ width: 140, marginBottom: 16 }}
|
||||
style={{ width: '100%', maxWidth: 160, marginBottom: 16 }}
|
||||
value={selectedSubject}
|
||||
onChange={setSelectedSubject}
|
||||
options={subjects.map((s) => ({ value: s.id, label: s.name }))}
|
||||
@@ -156,14 +216,28 @@ export default function StudentDetailPage() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'review',
|
||||
label: '成绩复盘',
|
||||
children: <ExamReviewPanel studentId={id!} exams={exams} onRefresh={loadExams} />,
|
||||
},
|
||||
{
|
||||
key: 'composition',
|
||||
label: '作文区',
|
||||
children: <CompositionPanel studentId={id!} student={student} />,
|
||||
},
|
||||
{
|
||||
key: 'wrong',
|
||||
label: '错题库',
|
||||
children: (
|
||||
<div>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||
上传后自动标注错误位置(红框),并生成解题思路,按{stageLabel}课内标准解题
|
||||
</Typography.Paragraph>
|
||||
<WrongQuestionUpload
|
||||
studentId={id!}
|
||||
subjects={subjects}
|
||||
category="regular"
|
||||
onUploaded={loadWrongQuestions}
|
||||
/>
|
||||
<WrongQuestionFilters
|
||||
@@ -174,54 +248,57 @@ export default function StudentDetailPage() {
|
||||
onRefresh={loadWrongQuestions}
|
||||
subjects={subjects}
|
||||
/>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 16 }}>
|
||||
{wrongQuestions.map((wq) => (
|
||||
<div
|
||||
key={wq.id}
|
||||
onClick={() => setSelectedWq(wq.id)}
|
||||
style={{
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={wrongQuestionApi.imageUrl(wq.id)}
|
||||
alt="错题"
|
||||
style={{ width: '100%', height: 140, objectFit: 'cover', background: '#fafafa' }}
|
||||
/>
|
||||
<div style={{ padding: 12 }}>
|
||||
<Space>
|
||||
<Typography.Text strong>{wq.subject_name}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{STATUS_LABELS[wq.status]}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
<Typography.Paragraph
|
||||
ellipsis={{ rows: 2 }}
|
||||
style={{ margin: '8px 0 0', fontSize: 13 }}
|
||||
>
|
||||
{wq.question_text || wq.ocr_raw_text || '处理中…'}
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{wrongQuestions.length === 0 && (
|
||||
<Typography.Text type="secondary">暂无错题</Typography.Text>
|
||||
)}
|
||||
{selectedWq && (
|
||||
<WrongQuestionDetail
|
||||
questionId={selectedWq}
|
||||
open={!!selectedWq}
|
||||
onClose={() => setSelectedWq(null)}
|
||||
onUpdated={loadWrongQuestions}
|
||||
/>
|
||||
)}
|
||||
<WrongQuestionList
|
||||
items={wrongQuestions}
|
||||
selectedId={selectedWq}
|
||||
onSelect={setSelectedWq}
|
||||
onRefresh={loadWrongQuestions}
|
||||
emptyText="暂无错题"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'olympiad',
|
||||
label: '奥数区',
|
||||
children: (
|
||||
<div>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||
{stageLabel}数学奥数,严格限制在{stageLabel}奥数培优范围内,禁止超纲
|
||||
</Typography.Paragraph>
|
||||
<WrongQuestionUpload
|
||||
studentId={id!}
|
||||
subjects={subjects}
|
||||
category="olympiad"
|
||||
onUploaded={loadOlympiadQuestions}
|
||||
/>
|
||||
<WrongQuestionFilters
|
||||
search={olympiadSearch}
|
||||
onSearchChange={setOlympiadSearch}
|
||||
onRefresh={loadOlympiadQuestions}
|
||||
subjects={subjects}
|
||||
hideSubjectFilter
|
||||
/>
|
||||
<WrongQuestionList
|
||||
items={olympiadQuestions}
|
||||
selectedId={selectedOlympiad}
|
||||
onSelect={setSelectedOlympiad}
|
||||
onRefresh={loadOlympiadQuestions}
|
||||
emptyText="暂无奥数题"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
label: '设置',
|
||||
children: (
|
||||
<StudentSettingsPanel
|
||||
student={student}
|
||||
onUpdated={(updated) => setStudent(updated)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { LogoutOutlined, PlusOutlined, UserOutlined } from '@ant-design/icons'
|
||||
import { Button, Card, Col, Form, Input, Modal, Row, Select, Space, Spin, Tag, Typography, message } from 'antd'
|
||||
import { DeleteOutlined, EditOutlined, LogoutOutlined, PlusOutlined, SettingOutlined } from '@ant-design/icons'
|
||||
import { Button, Card, Col, Form, Modal, Popconfirm, Row, Space, Spin, Tag, Typography, message } from 'antd'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { studentApi } from '../api/client'
|
||||
import { formatStudentMeta, GRADE_OPTIONS, SCHOOL_LEVEL_LABELS } from '../constants/school'
|
||||
import 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 type { SchoolLevel, Student } from '../types'
|
||||
import type { Student } from '../types'
|
||||
|
||||
export default function StudentsPage() {
|
||||
const { user, logout } = useAuth()
|
||||
const [students, setStudents] = useState<Student[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
const schoolLevel = Form.useWatch('school_level', form) as SchoolLevel | undefined
|
||||
const [editing, setEditing] = useState<Student | null>(null)
|
||||
const [form] = Form.useForm<StudentFormValues>()
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
@@ -30,21 +32,45 @@ export default function StudentsPage() {
|
||||
}, [])
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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()
|
||||
await studentApi.create(values)
|
||||
message.success('学生已添加')
|
||||
if (editing) {
|
||||
await studentApi.update(editing.id, values)
|
||||
message.success('学生资料已更新')
|
||||
} else {
|
||||
await studentApi.create(values)
|
||||
message.success('学生已添加')
|
||||
}
|
||||
setModalOpen(false)
|
||||
form.resetFields()
|
||||
load()
|
||||
}
|
||||
|
||||
const handleDelete = async (student: Student) => {
|
||||
await studentApi.remove(student.id)
|
||||
message.success('学生已删除')
|
||||
load()
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 960, margin: '0 auto', padding: '16px 16px 32px' }}>
|
||||
<div className="page-container">
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -62,6 +88,11 @@ export default function StudentsPage() {
|
||||
<Typography.Text type="secondary">欢迎,{user?.username}</Typography.Text>
|
||||
</div>
|
||||
<Space wrap>
|
||||
{user?.is_superuser && (
|
||||
<Link to="/settings">
|
||||
<Button icon={<SettingOutlined />}>系统设置</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
添加学生
|
||||
</Button>
|
||||
@@ -75,12 +106,34 @@ export default function StudentsPage() {
|
||||
<Row gutter={[16, 16]}>
|
||||
{students.map((s) => (
|
||||
<Col xs={24} sm={12} md={8} key={s.id}>
|
||||
<Link to={`/students/${s.id}`} style={{ textDecoration: 'none' }}>
|
||||
<Card hoverable>
|
||||
<Card
|
||||
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">
|
||||
<UserOutlined style={{ fontSize: 24, color: '#1677ff' }} />
|
||||
<StudentAvatar student={s} size={48} />
|
||||
<div>
|
||||
<Space size={4}>
|
||||
<Space size={4} wrap>
|
||||
<Typography.Text strong>{s.name}</Typography.Text>
|
||||
<Tag color={s.school_level === 'senior_high' ? 'purple' : 'blue'}>
|
||||
{SCHOOL_LEVEL_LABELS[s.school_level]}
|
||||
@@ -88,12 +141,12 @@ export default function StudentsPage() {
|
||||
</Space>
|
||||
<br />
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{formatStudentMeta(s)}
|
||||
{formatStudentSubtitle(s)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</Link>
|
||||
</Link>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
{!loading && students.length === 0 && (
|
||||
@@ -107,38 +160,14 @@ export default function StudentsPage() {
|
||||
</Spin>
|
||||
|
||||
<Modal
|
||||
title="添加学生"
|
||||
title={editing ? '修改学生' : '添加学生'}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={handleCreate}
|
||||
onOk={handleSubmit}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical" initialValues={{ school_level: 'junior_high' }}>
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="school_level" label="学段" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={Object.entries(SCHOOL_LEVEL_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))}
|
||||
onChange={() => form.setFieldValue('grade', undefined)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="grade" label="年级">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder={schoolLevel === 'senior_high' ? '如:高一' : '如:初二'}
|
||||
options={(GRADE_OPTIONS[schoolLevel || 'junior_high'] || []).map((g) => ({
|
||||
value: g,
|
||||
label: g,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="class_name" label="班级">
|
||||
<Input placeholder="如:3班" />
|
||||
</Form.Item>
|
||||
<StudentFormFields form={form} />
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Alert, Button, Col, Input, Modal, Row, Space, Spin, Typography, message } from 'antd'
|
||||
import { Alert, Button, Col, Input, Modal, Popconfirm, Row, Segmented, Space, Spin, Typography, message } from 'antd'
|
||||
import { useEffect, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import AuthenticatedImage from '../components/AuthenticatedImage'
|
||||
import { wrongQuestionApi } from '../api/client'
|
||||
import type { WrongQuestion } from '../types'
|
||||
import { isWrongQuestionProcessing, processingHint } from '../utils/wqProcessing'
|
||||
import { STATUS_LABELS } from '../types'
|
||||
|
||||
interface Props {
|
||||
@@ -10,23 +12,35 @@ interface Props {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onUpdated: () => void
|
||||
onDeleted?: () => void
|
||||
}
|
||||
|
||||
export default function WrongQuestionDetail({ questionId, open, onClose, onUpdated }: Props) {
|
||||
export default function WrongQuestionDetail({
|
||||
questionId,
|
||||
open,
|
||||
onClose,
|
||||
onUpdated,
|
||||
onDeleted,
|
||||
}: Props) {
|
||||
const [wq, setWq] = useState<WrongQuestion | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [questionText, setQuestionText] = useState('')
|
||||
const [approachText, setApproachText] = useState('')
|
||||
const [solutionText, setSolutionText] = useState('')
|
||||
const [imageMode, setImageMode] = useState<'annotated' | 'original'>('annotated')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [regenerating, setRegenerating] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await wrongQuestionApi.get(questionId)
|
||||
setWq(data)
|
||||
setQuestionText(data.question_text || '')
|
||||
setQuestionText(data.question_text || data.ocr_raw_text || '')
|
||||
setApproachText(data.solution_approach || '')
|
||||
setSolutionText(data.solution_text || '')
|
||||
setImageMode(data.has_annotated_image ? 'annotated' : 'original')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -36,11 +50,30 @@ export default function WrongQuestionDetail({ questionId, open, onClose, onUpdat
|
||||
if (open && questionId) load()
|
||||
}, [open, questionId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !wq || !isWrongQuestionProcessing(wq)) return
|
||||
const timer = window.setInterval(async () => {
|
||||
try {
|
||||
const { data } = await wrongQuestionApi.get(questionId)
|
||||
setWq(data)
|
||||
setQuestionText(data.question_text || data.ocr_raw_text || '')
|
||||
setApproachText(data.solution_approach || '')
|
||||
setSolutionText(data.solution_text || '')
|
||||
if (data.has_annotated_image) setImageMode('annotated')
|
||||
if (!isWrongQuestionProcessing(data)) onUpdated()
|
||||
} catch {
|
||||
/* ignore poll errors */
|
||||
}
|
||||
}, 4000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [open, questionId, wq?.status, wq?.question_text, wq?.error_message])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await wrongQuestionApi.update(questionId, {
|
||||
question_text: questionText,
|
||||
solution_approach: approachText,
|
||||
solution_text: solutionText,
|
||||
})
|
||||
message.success('已保存')
|
||||
@@ -55,12 +88,13 @@ export default function WrongQuestionDetail({ questionId, open, onClose, onUpdat
|
||||
try {
|
||||
const { data } = await wrongQuestionApi.regenerate(questionId)
|
||||
setWq(data)
|
||||
setQuestionText(data.question_text || '')
|
||||
setQuestionText(data.question_text || data.ocr_raw_text || '')
|
||||
setApproachText(data.solution_approach || '')
|
||||
setSolutionText(data.solution_text || '')
|
||||
message.success('解法已重新生成')
|
||||
message.success('解题思路已重新生成')
|
||||
onUpdated()
|
||||
} catch {
|
||||
message.error('生成失败,请确认 Ollama 已启动')
|
||||
message.error('生成失败,请检查 AI 模型配置')
|
||||
} finally {
|
||||
setRegenerating(false)
|
||||
}
|
||||
@@ -68,23 +102,46 @@ export default function WrongQuestionDetail({ questionId, open, onClose, onUpdat
|
||||
|
||||
const handleRetryOcr = async () => {
|
||||
await wrongQuestionApi.retryOcr(questionId)
|
||||
message.info('已重新识别,请稍后刷新')
|
||||
message.info('已重新识别并标注,请稍后刷新')
|
||||
onUpdated()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
setDeleting(true)
|
||||
try {
|
||||
await wrongQuestionApi.remove(questionId)
|
||||
message.success('已删除')
|
||||
onDeleted?.()
|
||||
onClose()
|
||||
} catch {
|
||||
message.error('删除失败')
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={wq ? `${wq.subject_name} · 错题详情` : '错题详情'}
|
||||
title={
|
||||
wq
|
||||
? `${wq.subject_name}${wq.category === 'olympiad' ? ' · 奥数' : ''} · 详情`
|
||||
: '详情'
|
||||
}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
width="90%"
|
||||
style={{ maxWidth: 960 }}
|
||||
footer={
|
||||
<Space wrap>
|
||||
<Button onClick={handleRetryOcr}>重新 OCR</Button>
|
||||
<Popconfirm title="确定删除该题?" onConfirm={handleDelete}>
|
||||
<Button danger loading={deleting}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Button onClick={handleRetryOcr}>重新识别标注</Button>
|
||||
<Button loading={regenerating} onClick={handleRegenerate}>
|
||||
重新生成解法
|
||||
重新生成思路
|
||||
</Button>
|
||||
<Button type="primary" loading={saving} onClick={handleSave}>
|
||||
保存编辑
|
||||
@@ -95,10 +152,30 @@ export default function WrongQuestionDetail({ questionId, open, onClose, onUpdat
|
||||
<Spin spinning={loading}>
|
||||
{wq && (
|
||||
<>
|
||||
<Typography.Text type="secondary">状态:{STATUS_LABELS[wq.status]}</Typography.Text>
|
||||
{wq.solution_text && (
|
||||
<Space wrap style={{ marginBottom: 8 }}>
|
||||
<Typography.Text type="secondary">状态:{STATUS_LABELS[wq.status]}</Typography.Text>
|
||||
{wq.has_annotated_image && !wq.error_message && (
|
||||
<Typography.Text type="danger">红色框为自动标注的错误位置</Typography.Text>
|
||||
)}
|
||||
</Space>
|
||||
{wq.error_message && (
|
||||
<Alert
|
||||
message="AI 生成内容,请核对后再使用"
|
||||
message="处理失败"
|
||||
description={wq.error_message}
|
||||
type="error"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
)}
|
||||
{wq.status === 'pending' && !wq.error_message && (
|
||||
<Alert message={processingHint(wq)} type="info" showIcon style={{ marginBottom: 12 }} />
|
||||
)}
|
||||
{wq.status === 'ocr_done' && !wq.question_text && !wq.error_message && (
|
||||
<Alert message={processingHint(wq)} type="info" showIcon style={{ marginBottom: 12 }} />
|
||||
)}
|
||||
{(wq.solution_approach || wq.solution_text) && (
|
||||
<Alert
|
||||
message="AI 识别与标注,请核对后再使用"
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ margin: '12px 0' }}
|
||||
@@ -106,14 +183,27 @@ export default function WrongQuestionDetail({ questionId, open, onClose, onUpdat
|
||||
)}
|
||||
<Row gutter={16} style={{ marginTop: 12 }}>
|
||||
<Col xs={24} md={10}>
|
||||
<img
|
||||
src={wrongQuestionApi.imageUrl(wq.id)}
|
||||
{wq.has_annotated_image && (
|
||||
<Segmented
|
||||
block
|
||||
style={{ marginBottom: 8 }}
|
||||
value={imageMode}
|
||||
onChange={(v) => setImageMode(v as 'annotated' | 'original')}
|
||||
options={[
|
||||
{ label: '标注图', value: 'annotated' },
|
||||
{ label: '原图', value: 'original' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<AuthenticatedImage
|
||||
questionId={wq.id}
|
||||
variant={imageMode}
|
||||
alt="原题"
|
||||
style={{ width: '100%', borderRadius: 8, border: '1px solid #f0f0f0' }}
|
||||
/>
|
||||
{wq.ocr_raw_text && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Typography.Text strong>OCR 原文</Typography.Text>
|
||||
<Typography.Text strong>OCR 原文(已排除手写作答)</Typography.Text>
|
||||
<pre
|
||||
style={{
|
||||
background: '#fafafa',
|
||||
@@ -132,12 +222,36 @@ export default function WrongQuestionDetail({ questionId, open, onClose, onUpdat
|
||||
<Col xs={24} md={14}>
|
||||
<Typography.Text strong>识别题目(可编辑)</Typography.Text>
|
||||
<Input.TextArea
|
||||
rows={6}
|
||||
rows={5}
|
||||
value={questionText}
|
||||
onChange={(e) => setQuestionText(e.target.value)}
|
||||
style={{ marginTop: 8, marginBottom: 16 }}
|
||||
/>
|
||||
<Typography.Text strong>解法</Typography.Text>
|
||||
<Typography.Text strong>解题思路</Typography.Text>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
value={approachText}
|
||||
onChange={(e) => setApproachText(e.target.value)}
|
||||
placeholder="识别完成后自动生成,类似作业帮「解题思路」"
|
||||
style={{ marginTop: 8, marginBottom: 16 }}
|
||||
/>
|
||||
{approachText && (
|
||||
<div
|
||||
style={{
|
||||
background: '#e6f4ff',
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
border: '1px solid #91caff',
|
||||
}}
|
||||
>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
思路预览
|
||||
</Typography.Text>
|
||||
<ReactMarkdown>{approachText}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
<Typography.Text strong>详细解答</Typography.Text>
|
||||
<Input.TextArea
|
||||
rows={8}
|
||||
value={solutionText}
|
||||
@@ -147,7 +261,7 @@ export default function WrongQuestionDetail({ questionId, open, onClose, onUpdat
|
||||
{solutionText && (
|
||||
<div style={{ background: '#fafafa', padding: 12, borderRadius: 8 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
预览
|
||||
解答预览
|
||||
</Typography.Text>
|
||||
<ReactMarkdown>{solutionText}</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
@@ -7,9 +7,42 @@ export interface TokenResponse {
|
||||
export interface User {
|
||||
id: string
|
||||
username: string
|
||||
is_superuser: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface PublicSettings {
|
||||
registration_enabled: boolean
|
||||
}
|
||||
|
||||
export interface AppFeatures {
|
||||
ai_review_enabled: boolean
|
||||
}
|
||||
|
||||
export interface SystemSettings {
|
||||
registration_enabled: boolean
|
||||
ai_review_enabled: boolean
|
||||
ai_provider: AIProvider
|
||||
ollama_base_url: string | null
|
||||
ollama_model: string | null
|
||||
openai_base_url: string | null
|
||||
openai_model: string | null
|
||||
openai_api_key_set: boolean
|
||||
ocr_service_url: string | null
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
id: string
|
||||
username: string
|
||||
is_superuser: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type WrongQuestionCategory = 'regular' | 'olympiad'
|
||||
|
||||
export type AIProvider = 'ollama' | 'openai'
|
||||
|
||||
export type SchoolLevel = 'junior_high' | 'senior_high'
|
||||
|
||||
export interface Student {
|
||||
@@ -18,6 +51,14 @@ export interface Student {
|
||||
school_level: SchoolLevel
|
||||
grade: 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
|
||||
}
|
||||
|
||||
@@ -33,6 +74,31 @@ export interface Score {
|
||||
total_score: number
|
||||
obtained_score: number
|
||||
ratio: number
|
||||
review_statuses?: ReviewStatus[]
|
||||
}
|
||||
|
||||
export type ReviewStatus = 'careless' | 'unknown' | 'nervous' | 'normal'
|
||||
|
||||
export const REVIEW_STATUS_LABELS: Record<ReviewStatus, string> = {
|
||||
careless: '粗心',
|
||||
unknown: '不会',
|
||||
nervous: '紧张',
|
||||
normal: '正常发挥',
|
||||
}
|
||||
|
||||
export const REVIEW_STATUS_OPTIONS = (
|
||||
Object.entries(REVIEW_STATUS_LABELS) as [ReviewStatus, string][]
|
||||
).map(([value, label]) => ({ value, label }))
|
||||
|
||||
export const PROBLEM_REVIEW_STATUSES: ReviewStatus[] = ['careless', 'unknown', 'nervous']
|
||||
|
||||
export function hasReviewProblem(statuses: ReviewStatus[] | undefined): boolean {
|
||||
return (statuses || []).some((s) => PROBLEM_REVIEW_STATUSES.includes(s))
|
||||
}
|
||||
|
||||
export function formatReviewStatuses(statuses: ReviewStatus[] | undefined): string {
|
||||
if (!statuses?.length) return '-'
|
||||
return statuses.map((s) => REVIEW_STATUS_LABELS[s]).join('、')
|
||||
}
|
||||
|
||||
export type ExamType = 'weekly' | 'monthly' | 'final'
|
||||
@@ -50,6 +116,7 @@ export interface ScoreInput {
|
||||
subject_id: number
|
||||
total_score: number
|
||||
obtained_score: number
|
||||
review_statuses?: ReviewStatus[]
|
||||
}
|
||||
|
||||
export interface TrendPoint {
|
||||
@@ -79,14 +146,28 @@ export interface WrongQuestion {
|
||||
student_id: string
|
||||
subject_id: number
|
||||
subject_name?: string
|
||||
category: WrongQuestionCategory
|
||||
image_path: string
|
||||
ocr_raw_text: string | null
|
||||
question_text: string | null
|
||||
solution_approach: string | null
|
||||
solution_text: string | null
|
||||
mark_regions: MarkRegion[] | null
|
||||
has_annotated_image: boolean
|
||||
has_cropped_image: boolean
|
||||
error_message: string | null
|
||||
status: WrongQuestionStatus
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface MarkRegion {
|
||||
line_id: number
|
||||
text: string
|
||||
bbox: number[]
|
||||
type: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export const EXAM_TYPE_LABELS: Record<ExamType, string> = {
|
||||
weekly: '周考',
|
||||
monthly: '月考',
|
||||
@@ -99,3 +180,26 @@ export const STATUS_LABELS: Record<WrongQuestionStatus, string> = {
|
||||
solved: '已生成解法',
|
||||
failed: '失败',
|
||||
}
|
||||
|
||||
export type CompositionStatus = 'pending' | 'generating' | 'done' | 'failed'
|
||||
export type CompositionInputMode = 'manual' | 'ocr'
|
||||
|
||||
export interface Composition {
|
||||
id: string
|
||||
student_id: string
|
||||
topic: string
|
||||
input_mode: CompositionInputMode
|
||||
writing_plan: string | null
|
||||
sample_essay: string | null
|
||||
error_message: string | null
|
||||
status: CompositionStatus
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export const COMPOSITION_STATUS_LABELS: Record<CompositionStatus, string> = {
|
||||
pending: '等待生成',
|
||||
generating: '生成中',
|
||||
done: '已完成',
|
||||
failed: '失败',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
export interface CropArea {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
img.onload = () => resolve(img)
|
||||
img.onerror = reject
|
||||
img.src = src
|
||||
})
|
||||
}
|
||||
|
||||
export async function cropImageToBlob(
|
||||
imageSrc: string,
|
||||
area: CropArea,
|
||||
mimeType = 'image/jpeg',
|
||||
quality = 0.92,
|
||||
): Promise<Blob> {
|
||||
const image = await loadImage(imageSrc)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = Math.max(1, Math.round(area.width))
|
||||
canvas.height = Math.max(1, Math.round(area.height))
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('无法创建画布')
|
||||
|
||||
ctx.drawImage(
|
||||
image,
|
||||
area.x,
|
||||
area.y,
|
||||
area.width,
|
||||
area.height,
|
||||
0,
|
||||
0,
|
||||
area.width,
|
||||
area.height,
|
||||
)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) => (blob ? resolve(blob) : reject(new Error('裁剪失败'))),
|
||||
mimeType,
|
||||
quality,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function blobToFile(blob: Blob, filename: string): File {
|
||||
return new File([blob], filename, { type: blob.type || 'image/jpeg' })
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { WrongQuestion } from '../types'
|
||||
|
||||
export function isWrongQuestionProcessing(wq: WrongQuestion): boolean {
|
||||
if (wq.error_message) return false
|
||||
if (wq.status === 'pending') return true
|
||||
if (wq.status === 'ocr_done' && !wq.question_text) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function processingHint(wq: WrongQuestion): string {
|
||||
if (wq.status === 'pending') {
|
||||
return '正在识别文字(约 10–30 秒)…'
|
||||
}
|
||||
if (wq.status === 'ocr_done') {
|
||||
return '正在标注错题并生成解题思路(约 30–90 秒)…'
|
||||
}
|
||||
return '正在识别、标注并生成解题思路…'
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export default defineConfig({
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
target: 'http://localhost:23566',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user