Compare commits

...

31 Commits

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 17:56:09 +08:00
dekun 1cb3c7fad5 新增作文区与 AI 解读开关,修复 CSV 导出。
系统设置可关闭成绩复盘 AI;学生详情增加作文区(OCR/手动题目、方案与范文、历史与 MD 下载);导出改用 UTF-8 文件名响应。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 17:42:17 +08:00
dekun aaa08cdf38 修正 AI 复盘解读:按日期顺序、得分率与科目专属建议。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 17:36:08 +08:00
dekun 02e7ba055a 考试明细单行展示,选中科目后增加 AI 解读与建议。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 17:32:28 +08:00
dekun 5f00f07dbe 成绩复盘独立导航页,柱状图点击展示各科考试明细。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 17:26:53 +08:00
dekun b4df6e5e18 移除已废弃的 ReviewTreeChart 组件。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 17:17:15 +08:00
dekun 4b55eb54b0 复盘统计改为分色堆叠柱状图,替代节点树图。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 17:16:44 +08:00
dekun f7a761da33 修复复盘保存 405:复盘改走成绩更新接口,并修正 API 404 回退。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 16:48:20 +08:00
dekun bec9df5d6f 修复复盘保存失败:原地更新成绩并新增 review 专用接口。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 16:38:20 +08:00
dekun ff4e0b1d37 成绩复盘与 PC 端上传优化:各科考试状态多选及树状统计。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 16:33:21 +08:00
dekun acfe002fbf 上传前人工裁剪错题区域,OCR 原文排除手写作答。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 16:01:46 +08:00
dekun 23be608521 清理 Ollama/API URL 中的终端控制字符,修复 AI 调用失败。 2026-06-28 15:42:28 +08:00
dekun e5ff76c20b 修复 update 后 start.sh 无执行权限导致服务无法启动。 2026-06-28 15:39:01 +08:00
dekun 4de460c235 OCR Worker 改用 RapidOCR/ONNX,修复 Paddle SIGILL。 2026-06-28 15:27:59 +08:00
dekun 035b65dcc8 OCR 修复:无 cuDNN 用 CPU、禁用 ir_optim 避免 SIGILL。 2026-06-28 15:18:42 +08:00
dekun 0d4861fa62 OCR 500: GPU 回退 CPU、返回详细错误、增加 test-ocr 诊断。 2026-06-28 15:04:33 +08:00
dekun edd3e80ef1 install: show wait progress, shorten OCR health wait, log to file. 2026-06-28 14:58:06 +08:00
dekun 04f1381a2d 完善 uninstall.sh:停止服务并可选清除目录与数据库。 2026-06-28 14:46:34 +08:00
dekun 357f61c57c 一键部署:主程序+OCR同机(screen/GPU),Ollama外置局域网。 2026-06-28 14:44:51 +08:00
dekun 9713c640b4 OCR Worker: add check and systemd install scripts. 2026-06-28 14:39:38 +08:00
dekun ff0c103dc5 支持局域网 GPU OCR 服务,配置方式类似 Ollama。 2026-06-28 14:16:06 +08:00
dekun 14bf314544 加速错题 OCR:上传压缩、识别缩放、启动预热与 MKL-DNN。 2026-06-28 14:12:01 +08:00
dekun 6200dbb596 修复错题一直显示处理中:超时、自动刷新与状态更新。 2026-06-28 14:09:10 +08:00
dekun c42cd0b46d 修复更新后服务无法访问:强制 systemd 配置与健康检查。
- update.sh 必须 root 运行,自动注册并重启 grade-archive

- 新增 deploy/repair.sh 一键修复连接被拒绝
2026-06-28 14:03:36 +08:00
dekun 1c50ebc0ec 修复 OCR libGL.so.1 缺失:安装 libgl1 等系统依赖。
- 新增 deploy/install-ocr-deps.sh,update.sh 自动检查

- install.sh 预装 OpenGL/Mesa 库
2026-06-28 13:57:49 +08:00
dekun a145f38606 错题处理失败时直接显示具体错误信息。 2026-06-28 13:54:43 +08:00
dekun a2a6d59f7c 作业帮式错题标注:OCR 定位错误红框 + 解题思路。
- PaddleOCR 行级坐标 + AI 识别错答区域,生成标注图

- 解法拆分为「解题思路」与「详细解答」

- 详情页标注图/原图切换,列表显示标注缩略图
2026-06-28 13:50:20 +08:00
dekun c30e21b51e 修复错题图片显示、Tab 刷新跳转,奥数仅数学并支持删除。
- 图片通过带 Token 的 blob 请求加载,修复不显示

- URL ?tab= 保持当前标签,刷新列表不再重置到成绩录入

- 奥数区固定数学科目;错题卡片与详情增加删除
2026-06-28 13:47:53 +08:00
dekun 43483bf56f 移动端拍照上传、奥数区、学段约束解题与 AI 模型配置。
- 手机/平板响应式布局,支持拍照与相册上传

- 学生详情新增奥数区,按初/高中学段生成解法并禁止超纲

- 系统设置可配置 Ollama 或 OpenAI 兼容 API

- 更新 frontend/dist 与使用说明
2026-06-28 13:39:54 +08:00
dekun 4375ea491e 部署文档与脚本增加 HTTP 代理配置(192.168.8.246:10810)。 2026-06-28 13:24:19 +08:00
dekun f1ad4273f4 零 Node 部署、超级管理员,并完善本地构建发布文档。
- FastAPI 单进程托管 frontend/dist,systemd 替代 PM2

- 超级管理员 admin、注册开关与用户管理

- README/DEPLOY/USAGE 说明:改代码须本地构建 dist 后 push,服务器 update.sh

- 提交 frontend/dist 与 build-frontend 脚本
2026-06-28 13:19:41 +08:00
91 changed files with 7935 additions and 622 deletions
+10 -2
View File
@@ -2,8 +2,7 @@
# 部署目录默认:/opt/secondary-school-grade-archive # 部署目录默认:/opt/secondary-school-grade-archive
WEB_PORT=23566 WEB_PORT=23566
API_PORT=23568 FRONTEND_DIST=/opt/secondary-school-grade-archive/frontend/dist
API_TARGET=http://127.0.0.1:23568
SECRET_KEY=请替换为随机字符串 SECRET_KEY=请替换为随机字符串
POSTGRES_USER=gradeapp 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_BASE_URL=http://127.0.0.1:11434
OLLAMA_MODEL=qwen2.5:7b 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 FLUCTUATION_THRESHOLD=0.08
ADMIN_DEFAULT_USERNAME=admin
ADMIN_DEFAULT_PASSWORD=admin123
+2 -1
View File
@@ -5,4 +5,5 @@ __pycache__/
.env .env
!.env.example !.env.example
node_modules/ node_modules/
dist/ frontend/node_modules/
# frontend/dist 随仓库发布,服务器无需 npm 构建
+84 -14
View File
@@ -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) | 用户使用说明 | | [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 ```bash
git clone https://git.bz121.com/dekun/secondary-school-grade-archive.git /opt/secondary-school-grade-archive 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 bash deploy/install.sh
``` ```
> `install.sh` 会自动将代理用于 `apt`、`git`、`pip`、`curl`。无需代理时可省略 `export` 步骤。
- 安装目录:`/opt/secondary-school-grade-archive` - 安装目录:`/opt/secondary-school-grade-archive`
- 访问地址:`http://<服务器IP>:23566` - 访问地址:`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 ```bash
# PostgreSQL 本地安装后 # PostgreSQL 本地安装后
cp backend/.env.example backend/.env cp .env.example .env
cd backend cd backend
python3 -m venv venv python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt pip install -r requirements.txt
uvicorn app.main:app --reload --port 23568 uvicorn app.main:app --reload --host 0.0.0.0 --port 23566
cd frontend cd frontend
npm install && npm run dev npm install && npm run dev
``` ```
开发前端http://localhost:5173(代理 `/api` 23568 - 前端开发:http://localhost:5173Vite 代理 `/api` 23566
- 后端 APIhttp://localhost:23566/api/health
生产网关本地模拟: ### 方式二:模拟生产(单进程 + 静态 dist)
```bash ```bash
cd deploy/pm2 && npm install cd frontend && npm run build
# 先 build 前端 cd ../backend
cd ../../frontend && npm run build source venv/bin/activate
WEB_PORT=23566 pm2 start ../deploy/pm2/ecosystem.config.cjs uvicorn app.main:app --reload --host 0.0.0.0 --port 23566
``` ```
访问 http://localhost:23566
--- ---
## 运维 ## 运维
```bash ```bash
bash deploy/update.sh # 更新 bash deploy/update.sh # 拉代码 + 更新依赖 + 重启服务
bash deploy/backup.sh # 备份 bash deploy/backup.sh # 备份
bash deploy/uninstall.sh # 停止 bash deploy/uninstall.sh # 停止并卸载
``` ```
--- ---
+5
View File
@@ -2,7 +2,12 @@ DATABASE_URL=postgresql://gradeapp:postgres@127.0.0.1:5432/student_archive
SECRET_KEY=dev-secret-key-change-in-production SECRET_KEY=dev-secret-key-change-in-production
CORS_ORIGINS=http://localhost:5173,http://localhost:23566 CORS_ORIGINS=http://localhost:5173,http://localhost:23566
UPLOAD_DIR=uploads UPLOAD_DIR=uploads
BACKUP_DIR=/root/grade-archive-backups
BACKUP_RETENTION_DAYS=30
AUTO_BACKUP_INTERVAL_HOURS=24
API_PORT=23568 API_PORT=23568
OLLAMA_BASE_URL=http://127.0.0.1:11434 OLLAMA_BASE_URL=http://127.0.0.1:11434
OLLAMA_MODEL=qwen2.5:7b OLLAMA_MODEL=qwen2.5:7b
OCR_SERVICE_URL=
OCR_API_KEY=
FLUCTUATION_THRESHOLD=0.08 FLUCTUATION_THRESHOLD=0.08
+21 -1
View File
@@ -8,11 +8,31 @@ class Settings(BaseSettings):
REFRESH_TOKEN_EXPIRE_DAYS: int = 7 REFRESH_TOKEN_EXPIRE_DAYS: int = 7
ALGORITHM: str = "HS256" ALGORITHM: str = "HS256"
UPLOAD_DIR: str = "uploads" UPLOAD_DIR: str = "uploads"
BACKUP_DIR: str = "/root/grade-archive-backups"
BACKUP_RETENTION_DAYS: int = 30
AUTO_BACKUP_INTERVAL_HOURS: int = 24
MAX_UPLOAD_SIZE: int = 10 * 1024 * 1024 MAX_UPLOAD_SIZE: int = 10 * 1024 * 1024
OLLAMA_BASE_URL: str = "http://127.0.0.1:11434" OLLAMA_BASE_URL: str = "http://127.0.0.1:11434"
OLLAMA_MODEL: str = "qwen2.5:7b" OLLAMA_MODEL: str = "qwen2.5:7b"
OPENAI_BASE_URL: str = "https://api.openai.com/v1"
OPENAI_MODEL: str = "gpt-4o-mini"
FLUCTUATION_THRESHOLD: float = 0.08 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: class Config:
env_file = ".env" env_file = ".env"
+6
View File
@@ -32,3 +32,9 @@ def get_current_user(
if user is None: if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
return user 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
View File
@@ -1,26 +1,59 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
import logging
import threading
import time
from fastapi import FastAPI from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware 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.config import settings
from app.core.database import Base, SessionLocal, engine 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.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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
Path(settings.UPLOAD_DIR).mkdir(parents=True, exist_ok=True) Path(settings.UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
Path(settings.BACKUP_DIR).mkdir(parents=True, exist_ok=True)
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
run_migrations() run_migrations()
db = SessionLocal() db = SessionLocal()
try: try:
seed_subjects(db) seed_subjects(db)
seed_admin_and_settings(db)
finally: finally:
db.close() db.close()
ocr_service.warmup_ocr_engine()
if settings.AUTO_BACKUP_INTERVAL_HOURS > 0:
threading.Thread(target=_auto_backup_loop, daemon=True).start()
yield yield
@@ -36,13 +69,43 @@ app.add_middleware(
) )
app.include_router(auth.router, prefix="/api") app.include_router(auth.router, prefix="/api")
app.include_router(settings_router.router, prefix="/api")
app.include_router(admin.router, prefix="/api")
app.include_router(backups.router, prefix="/api")
app.include_router(students.router, prefix="/api") app.include_router(students.router, prefix="/api")
app.include_router(subjects.router, prefix="/api") app.include_router(subjects.router, prefix="/api")
app.include_router(exams.router, prefix="/api") app.include_router(exams.router, prefix="/api")
app.include_router(wrong_questions.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.include_router(export.router, prefix="/api")
@app.get("/api/health") @app.get("/api/health")
def health(): def health():
return {"status": "ok"} 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")
+80
View File
@@ -22,6 +22,28 @@ class WrongQuestionStatus(str, enum.Enum):
failed = "failed" 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): class SchoolLevel(str, enum.Enum):
junior_high = "junior_high" junior_high = "junior_high"
senior_high = "senior_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) 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) username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255)) password_hash: Mapped[str] = mapped_column(String(255))
is_superuser: Mapped[bool] = mapped_column(default=False)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
) )
@@ -51,6 +74,8 @@ class Student(Base):
) )
grade: Mapped[str | None] = mapped_column(String(32), nullable=True) grade: Mapped[str | None] = mapped_column(String(32), nullable=True)
class_name: Mapped[str | None] = mapped_column(String(32), nullable=True) class_name: Mapped[str | None] = mapped_column(String(32), nullable=True)
school_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
avatar_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
) )
@@ -62,6 +87,9 @@ class Student(Base):
wrong_questions: Mapped[list["WrongQuestion"]] = relationship( wrong_questions: Mapped[list["WrongQuestion"]] = relationship(
back_populates="student", cascade="all, delete-orphan" back_populates="student", cascade="all, delete-orphan"
) )
compositions: Mapped[list["Composition"]] = relationship(
back_populates="student", cascade="all, delete-orphan"
)
class Subject(Base): class Subject(Base):
@@ -104,6 +132,7 @@ class SubjectScore(Base):
total_score: Mapped[float] = mapped_column(Numeric(8, 2)) total_score: Mapped[float] = mapped_column(Numeric(8, 2))
obtained_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)) 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") exam_record: Mapped["ExamRecord"] = relationship(back_populates="scores")
subject: Mapped["Subject"] = 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)) image_path: Mapped[str] = mapped_column(String(512))
ocr_raw_text: Mapped[str | None] = mapped_column(Text, nullable=True) ocr_raw_text: Mapped[str | None] = mapped_column(Text, nullable=True)
question_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) 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( status: Mapped[WrongQuestionStatus] = mapped_column(
Enum(WrongQuestionStatus), default=WrongQuestionStatus.pending Enum(WrongQuestionStatus), default=WrongQuestionStatus.pending
) )
category: Mapped[WrongQuestionCategory] = mapped_column(
Enum(WrongQuestionCategory), default=WrongQuestionCategory.regular
)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
) )
student: Mapped["Student"] = relationship(back_populates="wrong_questions") student: Mapped["Student"] = relationship(back_populates="wrong_questions")
subject: Mapped["Subject"] = 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)
)
+173
View File
@@ -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()
+14 -1
View File
@@ -13,14 +13,27 @@ from app.core.security import (
get_password_hash, get_password_hash,
verify_password, verify_password,
) )
from app.models.user import User from app.models.user import SystemSettings, User
from app.schemas import RefreshRequest, TokenResponse, UserLogin, UserOut, UserRegister from app.schemas import RefreshRequest, TokenResponse, UserLogin, UserOut, UserRegister
router = APIRouter(prefix="/auth", tags=["auth"]) 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) @router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def register(data: UserRegister, db: Session = Depends(get_db)): 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(): if db.query(User).filter(User.username == data.username).first():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名已存在") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名已存在")
user = User(username=data.username, password_hash=get_password_hash(data.password)) user = User(username=data.username, password_hash=get_password_hash(data.password))
+80
View File
@@ -0,0 +1,80 @@
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from app.core.deps import get_superuser
from app.models.user import User
from app.schemas import BackupInfoOut
from app.services import backup as backup_service
router = APIRouter(prefix="/admin/backups", tags=["admin-backups"])
@router.get("", response_model=list[BackupInfoOut])
def list_backups(_: User = Depends(get_superuser)):
return backup_service.list_backups()
@router.post("/run", response_model=BackupInfoOut)
def run_backup(_: User = Depends(get_superuser)):
try:
path = backup_service.create_backup()
except FileNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"备份失败: {exc}",
) from exc
stat = path.stat()
from datetime import datetime, timezone
return BackupInfoOut(
filename=path.name,
size_bytes=stat.st_size,
created_at=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
)
@router.get("/{filename}/download")
def download_backup(filename: str, _: User = Depends(get_superuser)):
try:
path = backup_service.resolve_backup_file(filename)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
except FileNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="备份不存在") from exc
return FileResponse(
path,
media_type="application/gzip",
filename=filename,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/restore")
async def restore_backup(file: UploadFile = File(...), _: User = Depends(get_superuser)):
if not file.filename or not file.filename.endswith(".tar.gz"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请上传 .tar.gz 备份包")
content = await file.read()
if len(content) > 512 * 1024 * 1024:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="备份文件过大(最大 512MB")
import tempfile
from pathlib import Path
tmp = Path(tempfile.mkdtemp(prefix="grade-archive-upload-"))
archive = tmp / "restore.tar.gz"
try:
archive.write_bytes(content)
backup_service.restore_backup(archive)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"恢复失败: {exc}",
) from exc
finally:
import shutil
shutil.rmtree(tmp, ignore_errors=True)
return {"ok": True, "message": "数据已恢复,建议重启服务以确保缓存刷新"}
+261
View File
@@ -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()
+209 -3
View File
@@ -1,3 +1,4 @@
import json
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status 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.database import get_db
from app.core.deps import get_current_user from app.core.deps import get_current_user
from app.models.user import ExamRecord, SubjectScore, User from app.models.user import ExamRecord, SubjectScore, SystemSettings, User
from app.schemas import ExamCreate, ExamOut, ExamUpdate, ScoreOut, TrendResponse 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.score_trend import build_trend
from app.services.student_access import get_student_for_user from app.services.student_access import get_student_for_user
router = APIRouter(tags=["exams"]) 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: def _score_to_out(score: SubjectScore) -> ScoreOut:
return ScoreOut( return ScoreOut(
@@ -21,6 +129,7 @@ def _score_to_out(score: SubjectScore) -> ScoreOut:
total_score=float(score.total_score), total_score=float(score.total_score),
obtained_score=float(score.obtained_score), obtained_score=float(score.obtained_score),
ratio=float(score.ratio), ratio=float(score.ratio),
review_statuses=_parse_review_statuses(score.review_statuses_json),
) )
@@ -36,18 +145,35 @@ def _exam_to_out(exam: ExamRecord) -> ExamOut:
def _apply_scores(db: Session, exam: ExamRecord, scores_data): 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: for item in scores_data:
keep_subject_ids.add(item.subject_id)
ratio = round(item.obtained_score / item.total_score, 4) ratio = round(item.obtained_score / item.total_score, 4)
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( exam.scores.append(
SubjectScore( SubjectScore(
subject_id=item.subject_id, subject_id=item.subject_id,
total_score=item.total_score, total_score=item.total_score,
obtained_score=item.obtained_score, obtained_score=item.obtained_score,
ratio=ratio, 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]) @router.get("/students/{student_id}/exams", response_model=list[ExamOut])
def list_exams( def list_exams(
@@ -150,6 +276,86 @@ def update_exam(
return _exam_to_out(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) @router.delete("/exams/{exam_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_exam( def delete_exam(
exam_id: uuid.UUID, exam_id: uuid.UUID,
+12 -7
View File
@@ -1,9 +1,10 @@
import csv import csv
import io import io
import uuid import uuid
from urllib.parse import quote
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse from fastapi.responses import Response
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
from app.core.database import get_db from app.core.database import get_db
@@ -34,10 +35,11 @@ def export_scores_csv(
writer.writerow(["考试日期", "考试类型", "标题", "科目", "总分", "得分", "占比"]) writer.writerow(["考试日期", "考试类型", "标题", "科目", "总分", "得分", "占比"])
type_map = {"weekly": "周考", "monthly": "月考", "final": "期末"} type_map = {"weekly": "周考", "monthly": "月考", "final": "期末"}
for exam in exams: 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: for score in exam.scores:
writer.writerow([ writer.writerow([
exam.exam_date.isoformat(), 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 "", exam.title or "",
score.subject.name if score.subject else "", score.subject.name if score.subject else "",
float(score.total_score), float(score.total_score),
@@ -45,10 +47,13 @@ def export_scores_csv(
f"{float(score.ratio) * 100:.2f}%", f"{float(score.ratio) * 100:.2f}%",
]) ])
output.seek(0) content = output.getvalue().encode("utf-8-sig")
filename = f"{student.name}_scores.csv" filename = f"{student.name}_scores.csv"
return StreamingResponse( encoded = quote(filename)
iter([output.getvalue().encode("utf-8-sig")]), return Response(
media_type="text/csv", content=content,
headers={"Content-Disposition": f'attachment; filename="{filename}"'}, media_type="text/csv; charset=utf-8",
headers={
"Content-Disposition": f'attachment; filename="scores.csv"; filename*=UTF-8\'\'{encoded}'
},
) )
+34
View File
@@ -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))
+66 -5
View File
@@ -1,28 +1,37 @@
import uuid import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.database import get_db from app.core.database import get_db
from app.core.deps import get_current_user from app.core.deps import get_current_user
from app.models.user import Student, User from app.models.user import Student, User
from app.schemas import StudentCreate, StudentOut, StudentUpdate from app.schemas import StudentCreate, StudentOut, StudentUpdate
from app.services.student_access import get_student_for_user from app.services.student_access import get_student_for_user
from app.services.student_avatar import delete_avatar_file, save_avatar
router = APIRouter(prefix="/students", tags=["students"]) router = APIRouter(prefix="/students", tags=["students"])
def _to_out(student: Student) -> StudentOut:
return StudentOut.from_student(student)
@router.get("", response_model=list[StudentOut]) @router.get("", response_model=list[StudentOut])
def list_students( def list_students(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
return ( rows = (
db.query(Student) db.query(Student)
.filter(Student.user_id == current_user.id) .filter(Student.user_id == current_user.id)
.order_by(Student.created_at.desc()) .order_by(Student.created_at.desc())
.all() .all()
) )
return [_to_out(row) for row in rows]
@router.post("", response_model=StudentOut, status_code=status.HTTP_201_CREATED) @router.post("", response_model=StudentOut, status_code=status.HTTP_201_CREATED)
@@ -35,7 +44,7 @@ def create_student(
db.add(student) db.add(student)
db.commit() db.commit()
db.refresh(student) db.refresh(student)
return student return _to_out(student)
@router.get("/{student_id}", response_model=StudentOut) @router.get("/{student_id}", response_model=StudentOut)
@@ -44,7 +53,8 @@ def get_student(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
return get_student_for_user(db, student_id, current_user.id) student = get_student_for_user(db, student_id, current_user.id)
return _to_out(student)
@router.patch("/{student_id}", response_model=StudentOut) @router.patch("/{student_id}", response_model=StudentOut)
@@ -59,7 +69,7 @@ def update_student(
setattr(student, key, value) setattr(student, key, value)
db.commit() db.commit()
db.refresh(student) db.refresh(student)
return student return _to_out(student)
@router.delete("/{student_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{student_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -69,5 +79,56 @@ def delete_student(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
student = get_student_for_user(db, student_id, current_user.id) student = get_student_for_user(db, student_id, current_user.id)
delete_avatar_file(student.avatar_path)
db.delete(student) db.delete(student)
db.commit() db.commit()
@router.post("/{student_id}/avatar", response_model=StudentOut)
async def upload_avatar(
student_id: uuid.UUID,
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
student = get_student_for_user(db, student_id, current_user.id)
content = await file.read()
if len(content) > settings.MAX_UPLOAD_SIZE:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文件超过10MB限制")
if not (file.content_type or "").startswith("image/"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请上传图片文件")
delete_avatar_file(student.avatar_path)
student.avatar_path = save_avatar(str(current_user.id), str(student.id), content)
db.commit()
db.refresh(student)
return _to_out(student)
@router.delete("/{student_id}/avatar", response_model=StudentOut)
def remove_avatar(
student_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
student = get_student_for_user(db, student_id, current_user.id)
delete_avatar_file(student.avatar_path)
student.avatar_path = None
db.commit()
db.refresh(student)
return _to_out(student)
@router.get("/{student_id}/avatar")
def get_avatar(
student_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
student = get_student_for_user(db, student_id, current_user.id)
if not student.avatar_path:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="未设置头像")
path = Path(settings.UPLOAD_DIR) / student.avatar_path
if not path.is_file():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="头像文件不存在")
return FileResponse(path, media_type="image/jpeg")
+262 -28
View File
@@ -1,4 +1,7 @@
import json
import uuid import uuid
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, Query, UploadFile, status 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.config import settings
from app.core.database import SessionLocal, get_db from app.core.database import SessionLocal, get_db
from app.core.deps import get_current_user from app.core.deps import get_current_user
from app.models.user import Subject, User, WrongQuestion, WrongQuestionStatus from app.models.user import Subject, SystemSettings, User, WrongQuestion, WrongQuestionCategory, WrongQuestionStatus
from app.schemas import WrongQuestionOut, WrongQuestionUpdate 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 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 from app.services.student_access import get_student_for_user
router = APIRouter(tags=["wrong_questions"]) 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: def _wq_to_out(wq: WrongQuestion) -> WrongQuestionOut:
return WrongQuestionOut( return WrongQuestionOut(
id=wq.id, id=wq.id,
student_id=wq.student_id, student_id=wq.student_id,
subject_id=wq.subject_id, subject_id=wq.subject_id,
subject_name=wq.subject.name if wq.subject else None, subject_name=wq.subject.name if wq.subject else None,
category=wq.category,
image_path=wq.image_path, image_path=wq.image_path,
ocr_raw_text=wq.ocr_raw_text, ocr_raw_text=wq.ocr_raw_text,
question_text=wq.question_text, question_text=wq.question_text,
solution_approach=wq.solution_approach,
solution_text=wq.solution_text, 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, status=wq.status,
created_at=wq.created_at, 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): def _process_wrong_question(question_id: uuid.UUID):
db = SessionLocal() db = SessionLocal()
wq = None
try: try:
wq = ( wq = (
db.query(WrongQuestion) db.query(WrongQuestion)
@@ -44,42 +162,83 @@ def _process_wrong_question(question_id: uuid.UUID):
if wq is None: if wq is None:
return return
wq.error_message = None
image_full = Path(settings.UPLOAD_DIR) / wq.image_path image_full = Path(settings.UPLOAD_DIR) / wq.image_path
ocr_url = _ocr_service_url(db)
try: try:
ocr_text = ocr_service.run_ocr(str(image_full)) with ThreadPoolExecutor(max_workers=1) as pool:
wq.ocr_raw_text = ocr_text or None future = pool.submit(
wq.status = WrongQuestionStatus.ocr_done if ocr_text else WrongQuestionStatus.failed ocr_service.run_ocr_with_regions, str(image_full), ocr_url
db.commit() )
except Exception: ocr_result = future.result(timeout=settings.OCR_TIMEOUT_SECONDS)
wq.status = WrongQuestionStatus.failed ocr_text = ocr_result["text"]
db.commit() ocr_lines = ocr_result["lines"]
return 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: if not ocr_text:
wq.status = WrongQuestionStatus.failed
wq.error_message = "OCR 未识别到文字,请拍摄更清晰、光线充足的题目照片"
db.commit()
return
wq.status = WrongQuestionStatus.ocr_done
db.commit()
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 return
subject_name = wq.subject.name if wq.subject else "综合"
school_level = wq.student.school_level if wq.student else None
import asyncio import asyncio
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
try: try:
question_text = loop.run_until_complete( loop.run_until_complete(
ollama_service.format_question(subject_name, ocr_text, school_level) _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() db.commit()
except Exception: except Exception as exc:
wq.status = WrongQuestionStatus.ocr_done 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() db.commit()
finally: finally:
loop.close() loop.close()
except Exception as exc:
if wq is not None:
wq.status = WrongQuestionStatus.failed
wq.error_message = _short_error(exc, "处理失败:")
db.commit()
finally: finally:
db.close() db.close()
@@ -88,6 +247,7 @@ def _process_wrong_question(question_id: uuid.UUID):
def list_wrong_questions( def list_wrong_questions(
student_id: uuid.UUID, student_id: uuid.UUID,
subject_id: int | None = Query(None), subject_id: int | None = Query(None),
category: WrongQuestionCategoryEnum | None = Query(None),
q: str | None = Query(None), q: str | None = Query(None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
@@ -100,6 +260,8 @@ def list_wrong_questions(
) )
if subject_id is not None: if subject_id is not None:
query = query.filter(WrongQuestion.subject_id == subject_id) query = query.filter(WrongQuestion.subject_id == subject_id)
if category is not None:
query = query.filter(WrongQuestion.category == category.value)
if q: if q:
pattern = f"%{q}%" pattern = f"%{q}%"
query = query.filter( query = query.filter(
@@ -108,6 +270,8 @@ def list_wrong_questions(
| (WrongQuestion.ocr_raw_text.ilike(pattern)) | (WrongQuestion.ocr_raw_text.ilike(pattern))
) )
items = query.order_by(WrongQuestion.created_at.desc()).all() 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] return [_wq_to_out(w) for w in items]
@@ -121,6 +285,7 @@ async def upload_wrong_question(
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
subject_id: int = Form(...), subject_id: int = Form(...),
file: UploadFile = File(...), file: UploadFile = File(...),
category: WrongQuestionCategoryEnum = Form(WrongQuestionCategoryEnum.regular),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@@ -128,6 +293,8 @@ async def upload_wrong_question(
subject = db.get(Subject, subject_id) subject = db.get(Subject, subject_id)
if subject is None: if subject is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="科目不存在") 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() content = await file.read()
if len(content) > settings.MAX_UPLOAD_SIZE: if len(content) > settings.MAX_UPLOAD_SIZE:
@@ -137,6 +304,7 @@ async def upload_wrong_question(
student_id=student_id, student_id=student_id,
subject_id=subject_id, subject_id=subject_id,
image_path="", image_path="",
category=WrongQuestionCategory(category.value),
status=WrongQuestionStatus.pending, status=WrongQuestionStatus.pending,
) )
db.add(wq) db.add(wq)
@@ -173,6 +341,7 @@ def get_wrong_question(
) )
if wq is None or wq.student.user_id != current_user.id: if wq is None or wq.student.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在")
_expire_stale_processing(wq, db)
return _wq_to_out(wq) return _wq_to_out(wq)
@@ -200,6 +369,8 @@ def update_wrong_question(
wq.question_text = data.question_text wq.question_text = data.question_text
if data.solution_text is not None: if data.solution_text is not None:
wq.solution_text = data.solution_text wq.solution_text = data.solution_text
if data.solution_approach is not None:
wq.solution_approach = data.solution_approach
db.commit() db.commit()
db.refresh(wq) db.refresh(wq)
@@ -222,10 +393,16 @@ def delete_wrong_question(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在")
image_full = Path(settings.UPLOAD_DIR) / wq.image_path 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.delete(wq)
db.commit() db.commit()
if image_full.exists(): if image_full.exists():
image_full.unlink() 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) @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="错题不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在")
wq.status = WrongQuestionStatus.pending wq.status = WrongQuestionStatus.pending
wq.error_message = None
wq.cropped_image_path = None
db.commit() db.commit()
background_tasks.add_task(_process_wrong_question, wq.id) background_tasks.add_task(_process_wrong_question, wq.id)
return _wq_to_out(wq) return _wq_to_out(wq)
@@ -270,21 +449,30 @@ async def regenerate_solution(
subject_name = wq.subject.name if wq.subject else "综合" subject_name = wq.subject.name if wq.subject else "综合"
school_level = wq.student.school_level if wq.student else None 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 "" question_text = wq.question_text or wq.ocr_raw_text or ""
ai_cfg = llm_service.load_ai_config(db)
try: try:
if not wq.question_text and wq.ocr_raw_text: if not wq.question_text and wq.ocr_raw_text:
wq.question_text = await ollama_service.format_question( wq.question_text = await llm_service.format_question(
subject_name, wq.ocr_raw_text, school_level ai_cfg, subject_name, wq.ocr_raw_text, school_level
) )
question_text = wq.question_text question_text = wq.question_text
wq.solution_text = await ollama_service.generate_solution( solution_full = await llm_service.generate_solution(
subject_name, question_text, school_level 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 wq.status = WrongQuestionStatus.solved
except Exception as exc: except Exception as exc:
raise HTTPException( 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 ) from exc
db.commit() db.commit()
@@ -311,3 +499,49 @@ def get_wrong_question_image(
if not image_full.exists(): if not image_full.exists():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
return FileResponse(image_full) 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)
+177
View File
@@ -11,6 +11,16 @@ class ExamTypeEnum(str, Enum):
final = "final" 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): class WrongQuestionStatusEnum(str, Enum):
pending = "pending" pending = "pending"
ocr_done = "ocr_done" ocr_done = "ocr_done"
@@ -18,6 +28,16 @@ class WrongQuestionStatusEnum(str, Enum):
failed = "failed" failed = "failed"
class WrongQuestionCategoryEnum(str, Enum):
regular = "regular"
olympiad = "olympiad"
class AIProviderEnum(str, Enum):
ollama = "ollama"
openai = "openai"
class SchoolLevelEnum(str, Enum): class SchoolLevelEnum(str, Enum):
junior_high = "junior_high" junior_high = "junior_high"
senior_high = "senior_high" senior_high = "senior_high"
@@ -46,6 +66,66 @@ class RefreshRequest(BaseModel):
class UserOut(BaseModel): class UserOut(BaseModel):
id: UUID id: UUID
username: str 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 created_at: datetime
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@@ -56,6 +136,7 @@ class StudentCreate(BaseModel):
school_level: SchoolLevelEnum = SchoolLevelEnum.junior_high school_level: SchoolLevelEnum = SchoolLevelEnum.junior_high
grade: str | None = None grade: str | None = None
class_name: str | None = None class_name: str | None = None
school_name: str | None = Field(default=None, max_length=128)
class StudentUpdate(BaseModel): class StudentUpdate(BaseModel):
@@ -63,6 +144,7 @@ class StudentUpdate(BaseModel):
school_level: SchoolLevelEnum | None = None school_level: SchoolLevelEnum | None = None
grade: str | None = None grade: str | None = None
class_name: str | None = None class_name: str | None = None
school_name: str | None = Field(default=None, max_length=128)
class StudentOut(BaseModel): class StudentOut(BaseModel):
@@ -71,10 +153,25 @@ class StudentOut(BaseModel):
school_level: SchoolLevelEnum school_level: SchoolLevelEnum
grade: str | None grade: str | None
class_name: str | None class_name: str | None
school_name: str | None
has_avatar: bool = False
created_at: datetime created_at: datetime
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@classmethod
def from_student(cls, student) -> "StudentOut":
return cls(
id=student.id,
name=student.name,
school_level=SchoolLevelEnum(student.school_level.value),
grade=student.grade,
class_name=student.class_name,
school_name=student.school_name,
has_avatar=bool(getattr(student, "avatar_path", None)),
created_at=student.created_at,
)
class SubjectOut(BaseModel): class SubjectOut(BaseModel):
id: int id: int
@@ -87,6 +184,7 @@ class ScoreInput(BaseModel):
subject_id: int subject_id: int
total_score: float total_score: float
obtained_score: float obtained_score: float
review_statuses: list[ReviewStatusEnum] = []
@field_validator("total_score") @field_validator("total_score")
@classmethod @classmethod
@@ -105,6 +203,18 @@ class ScoreInput(BaseModel):
raise ValueError("得分不能为负") raise ValueError("得分不能为负")
return v 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): class ScoreOut(BaseModel):
id: UUID id: UUID
@@ -113,6 +223,7 @@ class ScoreOut(BaseModel):
total_score: float total_score: float
obtained_score: float obtained_score: float
ratio: float ratio: float
review_statuses: list[ReviewStatusEnum] = []
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@@ -131,6 +242,23 @@ class ExamUpdate(BaseModel):
scores: list[ScoreInput] | None = None 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): class ExamOut(BaseModel):
id: UUID id: UUID
exam_type: ExamTypeEnum exam_type: ExamTypeEnum
@@ -167,10 +295,16 @@ class WrongQuestionOut(BaseModel):
student_id: UUID student_id: UUID
subject_id: int subject_id: int
subject_name: str | None = None subject_name: str | None = None
category: WrongQuestionCategoryEnum
image_path: str image_path: str
ocr_raw_text: str | None ocr_raw_text: str | None
question_text: str | None question_text: str | None
solution_approach: str | None = None
solution_text: str | 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 status: WrongQuestionStatusEnum
created_at: datetime created_at: datetime
@@ -179,5 +313,48 @@ class WrongQuestionOut(BaseModel):
class WrongQuestionUpdate(BaseModel): class WrongQuestionUpdate(BaseModel):
question_text: str | None = None question_text: str | None = None
solution_approach: str | None = None
solution_text: str | None = None solution_text: str | None = None
subject_id: int | 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
+157
View File
@@ -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
+166
View File
@@ -0,0 +1,166 @@
import json
import logging
import os
import re
import shutil
import subprocess
import tarfile
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse
from app.core.config import settings
logger = logging.getLogger(__name__)
BACKUP_NAME_PATTERN = re.compile(r"^grade-archive_\d{8}_\d{6}\.tar\.gz$")
APP_VERSION = "1.0.0"
def backup_dir() -> Path:
path = Path(settings.BACKUP_DIR)
path.mkdir(parents=True, exist_ok=True)
return path
def _pg_params() -> dict[str, str]:
parsed = urlparse(settings.DATABASE_URL)
return {
"host": parsed.hostname or "127.0.0.1",
"port": str(parsed.port or 5432),
"user": parsed.username or "postgres",
"password": parsed.password or "",
"dbname": parsed.path.lstrip("/") or "student_archive",
}
def _dump_database(dest: Path) -> None:
pg = _pg_params()
env = {**os.environ, "PGPASSWORD": pg["password"]}
cmd = [
"pg_dump",
"-h",
pg["host"],
"-p",
pg["port"],
"-U",
pg["user"],
"-d",
pg["dbname"],
"--no-owner",
"--no-privileges",
"--clean",
"--if-exists",
]
with dest.open("w", encoding="utf-8") as out:
subprocess.run(cmd, check=True, env=env, stdout=out)
def create_backup() -> Path:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
archive_path = backup_dir() / f"grade-archive_{timestamp}.tar.gz"
work = Path(tempfile.mkdtemp(prefix="grade-archive-backup-"))
try:
db_file = work / "database.sql"
_dump_database(db_file)
manifest = {
"app": "secondary-school-grade-archive",
"version": APP_VERSION,
"created_at": datetime.now(timezone.utc).isoformat(),
"database": pg["dbname"] if (pg := _pg_params()) else "student_archive",
}
manifest_file = work / "manifest.json"
manifest_file.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
uploads_dir = Path(settings.UPLOAD_DIR)
with tarfile.open(archive_path, "w:gz") as tar:
tar.add(db_file, arcname="database.sql")
tar.add(manifest_file, arcname="manifest.json")
if uploads_dir.is_dir():
tar.add(uploads_dir, arcname="uploads")
cleanup_old_backups()
logger.info("Backup created: %s", archive_path)
return archive_path
finally:
shutil.rmtree(work, ignore_errors=True)
def cleanup_old_backups() -> None:
if settings.BACKUP_RETENTION_DAYS <= 0:
return
cutoff = datetime.now().timestamp() - settings.BACKUP_RETENTION_DAYS * 86400
for item in backup_dir().glob("grade-archive_*.tar.gz"):
if item.stat().st_mtime < cutoff:
item.unlink(missing_ok=True)
def list_backups() -> list[dict]:
items: list[dict] = []
for path in sorted(backup_dir().glob("grade-archive_*.tar.gz"), reverse=True):
stat = path.stat()
items.append(
{
"filename": path.name,
"size_bytes": stat.st_size,
"created_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
}
)
return items
def resolve_backup_file(filename: str) -> Path:
if not BACKUP_NAME_PATTERN.match(filename):
raise ValueError("无效的备份文件名")
path = backup_dir() / filename
if not path.is_file():
raise FileNotFoundError(filename)
return path
def restore_backup(archive_path: Path) -> None:
work = Path(tempfile.mkdtemp(prefix="grade-archive-restore-"))
try:
with tarfile.open(archive_path, "r:gz") as tar:
tar.extractall(work)
db_file = work / "database.sql"
if not db_file.is_file():
raise ValueError("备份包缺少 database.sql")
pg = _pg_params()
env = {**os.environ, "PGPASSWORD": pg["password"]}
subprocess.run(
[
"psql",
"-h",
pg["host"],
"-p",
pg["port"],
"-U",
pg["user"],
"-d",
pg["dbname"],
"-v",
"ON_ERROR_STOP=1",
"-f",
str(db_file),
],
check=True,
env=env,
)
uploads_src = work / "uploads"
uploads_dest = Path(settings.UPLOAD_DIR)
if uploads_src.is_dir():
if uploads_dest.exists():
shutil.rmtree(uploads_dest)
shutil.copytree(uploads_src, uploads_dest)
else:
uploads_dest.mkdir(parents=True, exist_ok=True)
logger.info("Backup restored from %s", archive_path)
finally:
shutil.rmtree(work, ignore_errors=True)
+386
View File
@@ -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"
+95 -1
View File
@@ -1,12 +1,14 @@
from sqlalchemy import inspect, text from sqlalchemy import inspect, text
from app.core.config import settings
from app.core.database import engine from app.core.database import engine
def run_migrations() -> None: def run_migrations() -> None:
"""Apply lightweight schema updates for existing databases.""" """Apply lightweight schema updates for existing databases."""
inspector = inspect(engine) inspector = inspect(engine)
if "students" not in inspector.get_table_names(): tables = set(inspector.get_table_names())
if "students" not in tables:
return return
columns = {col["name"] for col in inspector.get_columns("students")} columns = {col["name"] for col in inspector.get_columns("students")}
@@ -18,3 +20,95 @@ def run_migrations() -> None:
"NOT NULL DEFAULT 'junior_high'" "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
View File
@@ -1,8 +1,22 @@
import logging
import os
import tempfile
import threading
from io import BytesIO
from pathlib import Path from pathlib import Path
import httpx
from PIL import Image
from app.core.config import settings 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_engine = None
_ocr_warmup_started = False
def get_ocr_engine(): def get_ocr_engine():
@@ -10,22 +24,175 @@ def get_ocr_engine():
if _ocr_engine is None: if _ocr_engine is None:
from paddleocr import PaddleOCR 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 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() engine = get_ocr_engine()
result = engine.ocr(image_path, cls=True) with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
if not result or not result[0]: Image.new("RGB", (120, 40), color=(255, 255, 255)).save(tmp.name, format="JPEG")
return "" tmp_path = tmp.name
lines = [] try:
for line in result[0]: engine.ocr(tmp_path, cls=False)
if line and len(line) >= 2: logger.info("OCR engine warmed up")
text = line[1][0] finally:
if text: Path(tmp_path).unlink(missing_ok=True)
lines.append(text) except Exception as exc:
return "\n".join(lines) 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()
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: 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) user_dir.mkdir(parents=True, exist_ok=True)
rel_path = f"{user_id}/{question_id}{ext}" rel_path = f"{user_id}/{question_id}{ext}"
full_path = Path(settings.UPLOAD_DIR) / rel_path 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 return rel_path
def annotated_rel_path(original_rel: str) -> str:
p = Path(original_rel)
return str(p.parent / f"{p.stem}_marked.jpg")
+103
View File
@@ -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()
+3 -45
View File
@@ -1,47 +1,5 @@
import httpx """Backward-compatible wrapper; prefer app.services.llm."""
from app.core.config import settings from app.services.llm import format_question, generate_solution, load_ai_config
from app.services.school_level import school_level_label
QUESTION_PROMPT = """你是一位{stage}老师。以下是从试卷 OCR 识别出的文字,可能含有噪声。 __all__ = ["format_question", "generate_solution", "load_ai_config"]
科目:{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)
+47 -1
View File
@@ -1,6 +1,8 @@
from sqlalchemy.orm import Session 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 = [ DEFAULT_SUBJECTS = [
"语文", "语文",
@@ -21,3 +23,47 @@ def seed_subjects(db: Session) -> None:
if name not in existing: if name not in existing:
db.add(Subject(name=name)) db.add(Subject(name=name))
db.commit() 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()
+32
View File
@@ -0,0 +1,32 @@
import uuid
from io import BytesIO
from pathlib import Path
from PIL import Image
from app.core.config import settings
def avatar_rel_path(user_id: str, student_id: str) -> str:
return f"{user_id}/avatars/{student_id}.jpg"
def save_avatar(user_id: str, student_id: str, content: bytes) -> str:
rel = avatar_rel_path(user_id, student_id)
full = Path(settings.UPLOAD_DIR) / rel
full.parent.mkdir(parents=True, exist_ok=True)
image = Image.open(BytesIO(content))
if image.mode not in ("RGB", "L"):
image = image.convert("RGB")
image.thumbnail((256, 256), Image.Resampling.LANCZOS)
image.save(full, format="JPEG", quality=85, optimize=True)
return rel
def delete_avatar_file(avatar_path: str | None) -> None:
if not avatar_path:
return
full = Path(settings.UPLOAD_DIR) / avatar_path
if full.is_file():
full.unlink()
+24
View File
@@ -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
View File
@@ -1,9 +1,17 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# 中学成绩档案 — 数据备份(数据库 + uploads,统一 tar.gz
set -euo pipefail set -euo pipefail
INSTALL_DIR="${INSTALL_DIR:-/opt/secondary-school-grade-archive}" INSTALL_DIR="${INSTALL_DIR:-/opt/secondary-school-grade-archive}"
BACKUP_DIR="${BACKUP_DIR:-${INSTALL_DIR}/backups}" BACKUP_DIR="${BACKUP_DIR:-/root/grade-archive-backups}"
TIMESTAMP=$(date +%Y%m%d_%H%M%S) TIMESTAMP=$(date +%Y%m%d_%H%M%S)
ARCHIVE="${BACKUP_DIR}/grade-archive_${TIMESTAMP}.tar.gz"
WORK=$(mktemp -d)
cleanup() {
rm -rf "${WORK}"
}
trap cleanup EXIT
cd "${INSTALL_DIR}" cd "${INSTALL_DIR}"
# shellcheck disable=SC1090 # shellcheck disable=SC1090
@@ -12,11 +20,21 @@ mkdir -p "${BACKUP_DIR}"
echo "[INFO] 备份数据库…" echo "[INFO] 备份数据库…"
PGPASSWORD="${POSTGRES_PASSWORD}" pg_dump -h 127.0.0.1 -U "${POSTGRES_USER}" "${POSTGRES_DB}" \ PGPASSWORD="${POSTGRES_PASSWORD}" pg_dump -h 127.0.0.1 -U "${POSTGRES_USER}" "${POSTGRES_DB}" \
> "${BACKUP_DIR}/db_${TIMESTAMP}.sql" --no-owner --no-privileges --clean --if-exists \
> "${WORK}/database.sql"
echo "[INFO] 备份 uploads…" cat > "${WORK}/manifest.json" <<EOF
tar -czf "${BACKUP_DIR}/uploads_${TIMESTAMP}.tar.gz" -C "${INSTALL_DIR}" uploads/ {
"app": "secondary-school-grade-archive",
"created_at": "$(date -Iseconds)",
"database": "${POSTGRES_DB}"
}
EOF
echo "[INFO] 完成:" echo "[INFO] 打包 uploads…"
echo " ${BACKUP_DIR}/db_${TIMESTAMP}.sql" tar -czf "${ARCHIVE}" -C "${WORK}" database.sql manifest.json -C "${INSTALL_DIR}" uploads/
echo " ${BACKUP_DIR}/uploads_${TIMESTAMP}.tar.gz"
echo "[INFO] 完成: ${ARCHIVE}"
# 清理 30 天前的备份
find "${BACKUP_DIR}" -name 'grade-archive_*.tar.gz' -mtime +30 -delete 2>/dev/null || true
+21
View File
@@ -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"
+24
View File
@@ -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"
+80
View File
@@ -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} 未监听"
}
+16
View File
@@ -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
+28
View File
@@ -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 系统依赖已就绪"
+161 -80
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# #
# 中学成绩档案系统 — Ubuntu PM2 一键部署 # 中学成绩档案系统 — 一键部署
# 架构:主程序 + OCR(GPU/screen 同机) | Ollama(其他电脑局域网)
# 版权所有 (c) 马建军 微信: dekun03 手机: 18364911125 # 版权所有 (c) 马建军 微信: dekun03 手机: 18364911125
# #
set -euo pipefail set -euo pipefail
@@ -8,11 +9,12 @@ set -euo pipefail
REPO_URL="${REPO_URL:-https://git.bz121.com/dekun/secondary-school-grade-archive.git}" REPO_URL="${REPO_URL:-https://git.bz121.com/dekun/secondary-school-grade-archive.git}"
INSTALL_DIR="${INSTALL_DIR:-/opt/secondary-school-grade-archive}" INSTALL_DIR="${INSTALL_DIR:-/opt/secondary-school-grade-archive}"
WEB_PORT="${WEB_PORT:-23566}" WEB_PORT="${WEB_PORT:-23566}"
API_PORT="${API_PORT:-23568}" OCR_PORT="${OCR_PORT:-23567}"
BRANCH="${BRANCH:-main}" BRANCH="${BRANCH:-main}"
NODE_MAJOR="${NODE_MAJOR:-20}"
PIP_MIRROR="${PIP_MIRROR:-https://pypi.tuna.tsinghua.edu.cn/simple}" 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' RED='\033[0;31m'
GREEN='\033[0;32m' GREEN='\033[0;32m'
@@ -23,6 +25,11 @@ log_info() { echo -e "${GREEN}[INFO]${NC} $*"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } 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() { require_root() {
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
log_error "请使用 root 用户运行: sudo bash deploy/install.sh" log_error "请使用 root 用户运行: sudo bash deploy/install.sh"
@@ -41,37 +48,46 @@ check_os() {
} }
check_port() { check_port() {
if command -v ss &>/dev/null && ss -tln | grep -q ":${WEB_PORT} "; then 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} 已被占用" log_error "端口 ${WEB_PORT} 已被占用"
exit 1 exit 1
fi fi
log_info "端口 ${WEB_PORT} 可用" 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
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() { install_base_packages() {
log_info "安装系统依赖…" log_info "安装系统依赖…"
apt-get update -qq apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -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 \ python3 python3-venv python3-pip python3-dev \
build-essential libpq-dev \ build-essential libpq-dev \
postgresql postgresql-contrib \ postgresql postgresql-contrib \
libgomp1 libglib2.0-0 libsm6 libxrender1 libxext6 libgl1 libglx-mesa0 libgbm1 \
} libgomp1 libglib2.0-0 libsm6 libxrender1 libxext6 libxcb1 libfontconfig1
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)"
} }
clone_or_update_repo() { 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}" git clone --branch "${BRANCH}" "${REPO_URL}" "${INSTALL_DIR}" || git clone "${REPO_URL}" "${INSTALL_DIR}"
fi fi
find "${INSTALL_DIR}" -name "*.sh" -exec sed -i 's/\r$//' {} + 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() { generate_env() {
@@ -94,13 +131,9 @@ generate_env() {
server_ip=$(hostname -I 2>/dev/null | awk '{print $1}') server_ip=$(hostname -I 2>/dev/null | awk '{print $1}')
server_ip="${server_ip:-127.0.0.1}" server_ip="${server_ip:-127.0.0.1}"
if [[ -f "${env_file}" ]]; then prompt_ollama_url
log_info "保留已有 .env"
# shellcheck disable=SC1090
set -a && source "${env_file}" && set +a
return
fi
if [[ ! -f "${env_file}" ]]; then
local secret pg_pass pg_user local secret pg_pass pg_user
secret=$(openssl rand -hex 32) secret=$(openssl rand -hex 32)
pg_pass=$(openssl rand -hex 16) pg_pass=$(openssl rand -hex 16)
@@ -109,21 +142,36 @@ generate_env() {
cat > "${env_file}" <<EOF cat > "${env_file}" <<EOF
# generated by deploy/install.sh — $(date -Iseconds) # generated by deploy/install.sh — $(date -Iseconds)
WEB_PORT=${WEB_PORT} WEB_PORT=${WEB_PORT}
API_PORT=${API_PORT} FRONTEND_DIST=${INSTALL_DIR}/frontend/dist
API_TARGET=http://127.0.0.1:${API_PORT}
SECRET_KEY=${secret} SECRET_KEY=${secret}
POSTGRES_USER=${pg_user} POSTGRES_USER=${pg_user}
POSTGRES_PASSWORD=${pg_pass} POSTGRES_PASSWORD=${pg_pass}
POSTGRES_DB=student_archive POSTGRES_DB=student_archive
DATABASE_URL=postgresql://${pg_user}:${pg_pass}@127.0.0.1:5432/student_archive DATABASE_URL=postgresql://${pg_user}:${pg_pass}@127.0.0.1:5432/student_archive
UPLOAD_DIR=${INSTALL_DIR}/uploads UPLOAD_DIR=${INSTALL_DIR}/uploads
BACKUP_DIR=/root/grade-archive-backups
BACKUP_RETENTION_DAYS=30
AUTO_BACKUP_INTERVAL_HOURS=24
CORS_ORIGINS=http://${server_ip}:${WEB_PORT},http://127.0.0.1:${WEB_PORT},http://localhost:${WEB_PORT} CORS_ORIGINS=http://${server_ip}:${WEB_PORT},http://127.0.0.1:${WEB_PORT},http://localhost:${WEB_PORT}
OLLAMA_BASE_URL=http://127.0.0.1:11434 # OCR 同机 GPU Workerscreen 常驻)
OLLAMA_MODEL=qwen2.5:7b 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 FLUCTUATION_THRESHOLD=0.08
ADMIN_DEFAULT_USERNAME=admin
ADMIN_DEFAULT_PASSWORD=admin123
EOF EOF
chmod 600 "${env_file}" chmod 600 "${env_file}"
log_info ".env 已生成" 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() { setup_postgresql() {
@@ -146,9 +194,11 @@ setup_postgresql() {
} }
setup_backend() { setup_backend() {
log_info "安装 Python 依赖(显示完整进度,Paddle 包较大,约 10–30 分钟)…" log_info "安装主程序 Python 依赖…"
cd "${INSTALL_DIR}/backend" cd "${INSTALL_DIR}/backend"
if [[ ! -d venv ]]; then
python3 -m venv venv python3 -m venv venv
fi
# shellcheck disable=SC1091 # shellcheck disable=SC1091
source venv/bin/activate source venv/bin/activate
pip install --upgrade pip --progress-bar on -i "${PIP_MIRROR}" pip install --upgrade pip --progress-bar on -i "${PIP_MIRROR}"
@@ -156,91 +206,122 @@ setup_backend() {
deactivate deactivate
} }
setup_frontend() { setup_ocr_gpu() {
log_info "构建前端…" if command -v nvidia-smi >/dev/null; then
cd "${INSTALL_DIR}/frontend" log_info "检测到 NVIDIA GPUOCR 将常驻显存 (screen)"
npm config set registry "${NPM_REGISTRY}" nvidia-smi --query-gpu=name,memory.total --format=csv,noheader 2>/dev/null || true
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"
else else
log_warn "未获取 PM2 startup 命令,重启后需手动 pm2 resurrect" log_warn "未检测到 NVIDIA GPUOCR 将使用 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 fi
} }
start_pm2() { setup_systemd() {
log_info "启动 PM2 服务…" 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}" cd "${INSTALL_DIR}"
mkdir -p uploads backups mkdir -p uploads backups /root/grade-archive-backups
pm2 delete grade-api grade-web 2>/dev/null || true chmod +x deploy/backup.sh deploy/restore.sh 2>/dev/null || true
pm2 start deploy/pm2/ecosystem.config.cjs systemctl restart grade-archive
pm2 save }
setup_pm2_startup
setup_backup_cron() {
log_info "配置每日自动备份(/root/grade-archive-backups)…"
cat > /etc/cron.d/grade-archive-backup <<EOF
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
0 3 * * * root INSTALL_DIR=${INSTALL_DIR} BACKUP_DIR=/root/grade-archive-backups bash ${INSTALL_DIR}/deploy/backup.sh >> /var/log/grade-archive-backup.log 2>&1
EOF
chmod 644 /etc/cron.d/grade-archive-backup
} }
wait_healthy() { wait_healthy() {
local i local i
log_info "等待主程序就绪(最多 2 分钟)…"
for i in $(seq 1 40); do for i in $(seq 1 40); do
if curl -sf "http://127.0.0.1:${WEB_PORT}/api/health" >/dev/null; then if curl -sf "http://127.0.0.1:${WEB_PORT}/api/health" >/dev/null; then
log_info "健康检查通过" log_info "主程序健康检查通过"
return 0 return 0
fi fi
if (( i % 5 == 0 )); then
echo -ne "\r${YELLOW}[INFO]${NC} 等待主程序… ${i}/40"
fi
sleep 3 sleep 3
done done
log_warn "健康检查超时,请查看: pm2 logs" echo ""
log_warn "主程序健康检查超时: journalctl -u grade-archive -f"
} }
print_summary() { print_summary() {
# shellcheck disable=SC1090
source "${INSTALL_DIR}/.env"
local ip local ip
ip=$(hostname -I 2>/dev/null | awk '{print $1}') ip=$(hostname -I 2>/dev/null | awk '{print $1}')
ip="${ip:-127.0.0.1}" ip="${ip:-127.0.0.1}"
echo "" echo ""
echo "==========================================" echo "=========================================="
echo " 中学成绩档案系统 PM2 部署完成" echo " 中学成绩档案 — 一键部署完成"
echo " 版权所有 (c) 马建军" echo " 版权所有 (c) 马建军"
echo "==========================================" echo "=========================================="
echo " 访问: http://${ip}:${WEB_PORT}" echo " 访问: http://${ip}:${WEB_PORT}"
echo " 目录: ${INSTALL_DIR}" echo " 管理员: admin / admin123(请立即修改)"
echo "" echo ""
echo " pm2 status" echo " 【同机 OCR — GPU 常驻 screen】"
echo " pm2 logs" echo " OCR 地址: http://127.0.0.1:${OCR_PORT}"
echo " bash ${INSTALL_DIR}/deploy/update.sh" echo " 状态: bash ${INSTALL_DIR}/deploy/ocr-screen.sh status"
echo " bash ${INSTALL_DIR}/deploy/backup.sh" echo " 进入终端: screen -r ocr-worker (Ctrl+A D 退出)"
echo " 重启 OCR: bash ${INSTALL_DIR}/deploy/ocr-screen.sh restart"
echo "" 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 " 微信 dekun03 手机 18364911125"
echo "==========================================" echo "=========================================="
} }
main() { main() {
log_info "PM2 一键部署开始" log_info "一键部署开始(主程序 + OCR/GPU/screen | Ollama 外置)"
require_root require_root
setup_deploy_proxy
check_os check_os
clone_or_update_repo
verify_frontend_dist
check_port check_port
install_base_packages install_base_packages
install_node_pm2
clone_or_update_repo
generate_env generate_env
setup_postgresql setup_postgresql
setup_backend setup_backend
setup_frontend setup_ocr_gpu
setup_gateway stop_legacy_pm2
start_pm2 setup_systemd
setup_backup_cron
start_service
wait_healthy wait_healthy
print_summary print_summary
} }
+122
View File
@@ -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
}
+56
View File
@@ -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 Workerscreen 后台,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
+127
View File
@@ -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
+91
View File
@@ -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 "===================================="
+53
View File
@@ -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 修改)"
+34
View File
@@ -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"
+17
View File
@@ -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
+6
View File
@@ -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
+32
View File
@@ -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
+26
View File
@@ -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
+24
View File
@@ -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}"
+36
View File
@@ -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
}
+41
View File
@@ -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
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# 中学成绩档案 — 从备份包恢复(命令行,适合新服务器迁移)
set -euo pipefail
if [[ $# -lt 1 ]]; then
echo "用法: sudo bash deploy/restore.sh /path/to/grade-archive_YYYYMMDD_HHMMSS.tar.gz"
exit 1
fi
ARCHIVE="$1"
INSTALL_DIR="${INSTALL_DIR:-/opt/secondary-school-grade-archive}"
WORK=$(mktemp -d)
cleanup() {
rm -rf "${WORK}"
}
trap cleanup EXIT
if [[ ! -f "${ARCHIVE}" ]]; then
echo "[ERROR] 备份文件不存在: ${ARCHIVE}"
exit 1
fi
cd "${INSTALL_DIR}"
# shellcheck disable=SC1090
source .env
echo "[WARN] 即将恢复数据库与 uploads,当前数据将被覆盖。"
read -rp "确认继续?[y/N] " confirm
if [[ "${confirm}" != "y" && "${confirm}" != "Y" ]]; then
echo "已取消"
exit 0
fi
echo "[INFO] 解压备份…"
tar -xzf "${ARCHIVE}" -C "${WORK}"
if [[ ! -f "${WORK}/database.sql" ]]; then
echo "[ERROR] 备份包缺少 database.sql"
exit 1
fi
echo "[INFO] 恢复数据库…"
PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" \
-v ON_ERROR_STOP=1 -f "${WORK}/database.sql"
if [[ -d "${WORK}/uploads" ]]; then
echo "[INFO] 恢复 uploads…"
rm -rf "${INSTALL_DIR}/uploads"
cp -a "${WORK}/uploads" "${INSTALL_DIR}/uploads"
fi
echo "[INFO] 重启服务…"
systemctl restart grade-archive 2>/dev/null || true
echo "[INFO] 恢复完成"
+21
View File
@@ -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}"
+117 -4
View File
@@ -1,11 +1,124 @@
#!/usr/bin/env bash #!/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 set -euo pipefail
INSTALL_DIR="${INSTALL_DIR:-/opt/secondary-school-grade-archive}" INSTALL_DIR="${INSTALL_DIR:-/opt/secondary-school-grade-archive}"
OCR_SCREEN_NAME="${OCR_SCREEN_NAME:-ocr-worker}"
cd "${INSTALL_DIR}" || exit 1 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; }
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 delete grade-api grade-web 2>/dev/null || true
pm2 save --force pm2 save --force 2>/dev/null || true
fi
echo "PM2 服务已停止。PostgreSQL 数据与 ${INSTALL_DIR}/uploads 仍保留。" if [[ ${PURGE_DB} -eq 1 ]]; then
echo "如需删除源码: rm -rf ${INSTALL_DIR}" 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
View File
@@ -4,39 +4,55 @@ set -euo pipefail
INSTALL_DIR="${INSTALL_DIR:-/opt/secondary-school-grade-archive}" INSTALL_DIR="${INSTALL_DIR:-/opt/secondary-school-grade-archive}"
BRANCH="${BRANCH:-main}" BRANCH="${BRANCH:-main}"
PIP_MIRROR="${PIP_MIRROR:-https://pypi.tuna.tsinghua.edu.cn/simple}" PIP_MIRROR="${PIP_MIRROR:-https://pypi.tuna.tsinghua.edu.cn/simple}"
NPM_REGISTRY="${NPM_REGISTRY:-https://registry.npmmirror.com}"
GREEN='\033[0;32m' # shellcheck source=common.sh
NC='\033[0m' source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh"
log_info() { echo -e "${GREEN}[INFO]${NC} $*"; } # 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 cd "${INSTALL_DIR}" || exit 1
find "${INSTALL_DIR}" -name "*.sh" -exec sed -i 's/\r$//' {} + 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 "拉取最新代码…" log_info "拉取最新代码…"
git fetch origin git fetch origin
git checkout "${BRANCH}" 2>/dev/null || true git checkout "${BRANCH}" 2>/dev/null || true
git pull origin "${BRANCH}" 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 cd backend
source venv/bin/activate source venv/bin/activate
pip install -r requirements.txt --progress-bar on -i "${PIP_MIRROR}" pip install -r requirements.txt --progress-bar on -i "${PIP_MIRROR}"
deactivate 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}" 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 "更新完成" log_info "更新完成"
show_ocr_status
+109
View File
@@ -0,0 +1,109 @@
# 数据备份与恢复
> **中学成绩档案系统** · 备份目录默认 `/root/grade-archive-backups`
---
## 1. 备份内容
每次备份生成一个压缩包 `grade-archive_YYYYMMDD_HHMMSS.tar.gz`,包含:
| 文件/目录 | 说明 |
|-----------|------|
| `database.sql` | PostgreSQL 全库导出(含 `--clean`,可覆盖恢复) |
| `uploads/` | 错题图片、学生头像等上传文件 |
| `manifest.json` | 备份元信息(时间、库名) |
---
## 2. 自动备份
- **目录**`/root/grade-archive-backups`(可通过 `.env``BACKUP_DIR` 修改)
- **频率**:应用启动后每 **24 小时**自动备份一次(`AUTO_BACKUP_INTERVAL_HOURS=24`
- **保留**:默认保留最近 **30 天**`BACKUP_RETENTION_DAYS=30`
- **系统 cron**(可选,安装脚本会写入):每天凌晨 3:00 执行 `deploy/backup.sh`
### 环境变量(`.env`
```env
BACKUP_DIR=/root/grade-archive-backups
BACKUP_RETENTION_DAYS=30
AUTO_BACKUP_INTERVAL_HOURS=24
```
---
## 3. 手动备份(服务器命令行)
```bash
sudo BACKUP_DIR=/root/grade-archive-backups \
bash /opt/secondary-school-grade-archive/deploy/backup.sh
```
备份文件位于 `/root/grade-archive-backups/`
---
## 4. 系统设置中下载备份
1. 使用超级管理员登录
2. 进入 **系统设置 → 数据备份**
3. 点击 **立即备份** 或等待自动备份
4. 在列表中点击 **下载** 保存 `.tar.gz` 到本地
---
## 5. 更换服务器 — 数据恢复
### 方式 A:Web 界面(推荐)
1. 在新服务器完成 `deploy/install.sh``git pull` 到最新版本
2. 超级管理员登录 → **系统设置 → 数据备份**
3. 在「数据恢复」区域上传旧服务器下载的 `grade-archive_*.tar.gz`
4. 恢复成功后建议执行:`sudo systemctl restart grade-archive`
### 方式 B:命令行
1. 将备份包复制到新服务器,例如 `/root/grade-archive_20260628_030000.tar.gz`
2. 执行:
```bash
sudo bash /opt/secondary-school-grade-archive/deploy/restore.sh \
/root/grade-archive_20260628_030000.tar.gz
```
3. 按提示确认后,脚本会恢复数据库与 `uploads/`,并尝试重启服务
---
## 6. 迁移检查清单
- [ ] 旧服务器下载最新备份包
- [ ] 新服务器安装系统(`install.sh`)并配置 Ollama / OCR 地址
- [ ] 上传备份并恢复
- [ ] 验证学生资料、成绩、错题图片、头像是否正常
- [ ] 确认 `.env``OLLAMA_BASE_URL``OCR_SERVICE_URL` 符合新环境
---
## 7. 注意事项
- **恢复会覆盖**当前数据库与 `uploads` 目录,操作前请先备份当前数据
- 备份与恢复需要服务器已安装 `pg_dump` / `psql`(安装脚本已包含 PostgreSQL
- 备份目录在 `/root` 下,仅 root 可读写;应用以 systemd 运行时需确保 `BACKUP_DIR` 对运行用户可写,或保持默认由 root cron / 管理员 API 触发
- 学生头像、学校、年级等资料保存在数据库 `students` 表中,随数据库一并备份
---
## 8. 学生资料字段说明
| 字段 | 说明 |
|------|------|
| 姓名 | 必填 |
| 学校 | 可选,显示在卡片与详情 |
| 学段 | 初中 / 高中 |
| 年级 | 初一~初三 或 高一~高三(带明确标识) |
| 班级 | 如 `3``3班` |
| 头像 | 保存在 `uploads/{用户ID}/avatars/{学生ID}.jpg` |
在学生详情页 **设置** Tab 或首页卡片 **修改** 中维护;首页卡片支持 **删除**
+186 -94
View File
@@ -1,4 +1,4 @@
# Ubuntu PM2 部署文档 # Ubuntu 零 Node 部署文档
> **中学成绩档案系统** · 版权所有 © 马建军 · 微信 **dekun03** · 手机 **18364911125** > **中学成绩档案系统** · 版权所有 © 马建军 · 微信 **dekun03** · 手机 **18364911125**
> 仓库:[https://git.bz121.com/dekun/secondary-school-grade-archive.git](https://git.bz121.com/dekun/secondary-school-grade-archive.git) > 仓库:[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 | | 系统 | Ubuntu 20.04 / 22.04 / 24.04 |
| 用户 | **root** | | 用户 | **root** |
| 目录 | `/opt/secondary-school-grade-archive` | | 目录 | `/opt/secondary-school-grade-archive` |
| 端口 | **23566** 对外 Web**23568** 内部 API(仅本机 | | 端口 | **23566**API + 前端静态资源同一端口 |
| 反向代理 | **不包含**,请自行配置 |
### 架构 ### 架构
``` ```
浏览器 → :23566 (PM2: grade-web, Express 静态 + /api 反代) 浏览器 → :23566 (systemd: grade-archive, Uvicorn)
──→ 127.0.0.1:23568 (PM2: grade-api, Uvicorn) ── /api/* → FastAPI 接口
└── /* → frontend/dist 静态文件
└──→ PostgreSQL (本机) └──→ PostgreSQL (本机)
└──→ uploads/ └──→ uploads/
└──→ Ollama (本机可选, :11434) └──→ Ollama (本机可选, :11434)
``` ```
PM2 进程: ### 为何不在服务器构建前端?
| 名称 | 说明 | 前端 `npm ci && npm run build` 会占用大量磁盘与内存,且需在服务器安装 Node.js。
|------|------| 因此采用 **开发机构建 → 推送 `frontend/dist` → 服务器只拉取** 的方式,服务器仅需 Python + PostgreSQL。
| `grade-api` | FastAPI / Uvicorn |
| `grade-web` | 前端静态资源 + `/api` 反向代理 |
--- ---
## 2. 环境要求 ## 2. 代码修改与发布流程(重要)
- CPU 2 核+,内存 4 GB+OCR 建议 8 GB 每次修改代码后,按改动范围在**开发机**操作,再推送到远端仓库,最后在服务器执行 `update.sh`
- 磁盘 15 GB+
- 可访问 Git 仓库与 npm / PyPI > **服务器需代理时**:在 `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 ```bash
git clone https://git.bz121.com/dekun/secondary-school-grade-archive.git /opt/secondary-school-grade-archive git clone https://git.bz121.com/dekun/secondary-school-grade-archive.git /opt/secondary-school-grade-archive
@@ -52,122 +125,141 @@ chmod +x deploy/*.sh
bash deploy/install.sh bash deploy/install.sh
``` ```
> 脚本会读取上述环境变量,并自动配置 `apt` 代理。`git pull`、`pip install` 同样生效。内网直连时可不设代理。
脚本自动完成: 脚本自动完成:
1. 检测 root、Ubuntu、端口 23566 1. 安装 PostgreSQL、Python 依赖
2. 安装 PostgreSQL、Python3、Node.js 20、PM2 2. 检查 `frontend/dist/index.html` 是否存在
3. 克隆/更新代码 3. 生成 `.env`、创建数据库
4. 生成 `.env`(随机密钥、数据库密码) 4. 注册并启动 systemd 服务 `grade-archive`
5. 创建 PostgreSQL 用户与数据库
6. Python 虚拟环境 + `pip install` **前提:** 仓库中已包含 `frontend/dist/`(开发机构建后推送)。
7. 前端 `npm ci && npm run build`
8. `pm2 start` 并设置开机自启
部署成功后访问:**`http://<服务器IP>:23566`** 部署成功后访问:**`http://<服务器IP>:23566`**
默认超级管理员:**admin / admin123**(登录后请在「系统设置」中修改)
--- ---
## 4. 环境变量(`.env` ## 4. 环境变量(`.env`
| 变量 | 默认 | 说明 | | 变量 | 默认 | 说明 |
|------|------|------| |------|------|------|
| `WEB_PORT` | 23566 | 对外 Web 端口 | | `WEB_PORT` | 23566 | 对外端口 |
| `API_PORT` | 23568 | 内部 API 端口(仅本机,勿与 8000 等常用端口冲突 | | `FRONTEND_DIST` | `.../frontend/dist` | 前端静态目录(绝对路径 |
| `API_TARGET` | `http://127.0.0.1:23568` | Web 网关转发目标 |
| `DATABASE_URL` | 自动生成 | PostgreSQL 连接 | | `DATABASE_URL` | 自动生成 | PostgreSQL 连接 |
| `SECRET_KEY` | 自动生成 | JWT 密钥 | | `SECRET_KEY` | 自动生成 | JWT 密钥 |
| `UPLOAD_DIR` | `.../uploads` | 错题图片目录 | | `UPLOAD_DIR` | `.../uploads` | 错题图片目录 |
| `OLLAMA_BASE_URL` | `http://127.0.0.1:11434` | 本地 Ollama | | `ADMIN_DEFAULT_USERNAME` | admin | 首次安装默认管理员用户名 |
| `ADMIN_DEFAULT_PASSWORD` | admin123 | 首次安装默认管理员密码 |
修改后重启: 示例见仓库根目录 [.env.example](../.env.example)。
```bash
cd /opt/secondary-school-grade-archive
pm2 reload deploy/pm2/ecosystem.config.cjs --update-env
```
--- ---
## 5. 常用命令 ## 5. 常用命令
```bash ```bash
cd /opt/secondary-school-grade-archive # 服务状态
systemctl status grade-archive
pm2 status # 进程状态 # 实时日志
pm2 logs # 全部日志 journalctl -u grade-archive -f
pm2 logs grade-api # 后端日志
pm2 logs grade-web # 网关日志
bash deploy/update.sh # 拉代码 + 重建 + 重启 # 拉代码并重启(日常更新;需代理时先 export,见 §3)
bash deploy/backup.sh # 备份数据库与 uploads export http_proxy=http://192.168.8.246:10810
bash deploy/uninstall.sh # 停止 PM2 服务 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 若之前使用 `grade-api` + `grade-web`PM2 + Express),执行 `deploy/update.sh` 会:
curl -fsSL https://ollama.com/install.sh | sh
ollama pull qwen2.5:7b - 停止并删除 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` | | 默认账号 | **admin / admin123**(首次安装后请立即修改) |
| 502 / API 错误 | `pm2 logs grade-api` | | 系统设置 | 超级管理员可修改自己的用户名、密码 |
| 数据库连接失败 | `systemctl status postgresql` · 检查 `.env``DATABASE_URL` | | 注册开关 | 可开启/关闭登录页公开注册 |
| 前端空白 | 确认 `frontend/dist` 存在 · `pm2 logs grade-web` | | 用户管理 | 注册关闭时,由管理员添加用户并重置密码 |
| 普通用户 | **不能**自行修改用户名和密码 |
使用说明见 [USAGE.md](./USAGE.md)。
--- ---
## 10. 自定义参数 ## 8. 反向代理(用户自行配置)
```bash 本项目**不包含** Nginx / Caddy 等反向代理配置。若需 HTTPS 或域名访问,请在服务器上自行配置,将流量转发到 `127.0.0.1:23566`
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` 会**显示完整下载/安装进度**,不再静默。
--- ---
## 11. 版权 ## 9. OCR 报错 libGL.so.1
见 [LICENSE](../LICENSE) · [COPYRIGHT.md](../COPYRIGHT.md) 若错题识别失败并提示 `libGL.so.1: cannot open shared object file`,说明服务器缺少 OpenGL 运行库(PaddleOCR/OpenCV 需要)。
技术支持:微信 **dekun03** · 手机 **18364911125**
在服务器执行:
```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
View File
@@ -16,7 +16,7 @@
- 错题库:拍照上传 → OCR 识别 → AI 生成解法(可编辑) - 错题库:拍照上传 → OCR 识别 → AI 生成解法(可编辑)
- 成绩 CSV 导出 - 成绩 CSV 导出
部署方式见 [DEPLOY.md](./DEPLOY.md)PM2,端口 23566)。 部署与代码发布见 [DEPLOY.md](./DEPLOY.md)systemd 零 Node,端口 **23566**)。
--- ---
@@ -25,12 +25,23 @@
### 2.1 登录与注册 ### 2.1 登录与注册
1. 浏览器打开 `http://<服务器IP>:23566` 1. 浏览器打开 `http://<服务器IP>:23566`
2. 首次使用点击 **注册**,设置用户名(≥3 字符)和密码(≥6 字符 2. **首次部署**默认超级管理员:**admin / admin123**(请登录后立即修改
3. 注册成功后自动登录 3. 若管理员已**开放注册**,可在登录页 **注册** 新账号(用户名 ≥3 字符,密码 ≥6 字符)
4. 若管理员已**关闭注册**,登录页不显示注册入口,需联系管理员在「系统设置 → 用户管理」中添加账号
> 系统无默认管理员账号,首个注册用户即为普通用户数据仅本人可见。 > 普通用户数据仅本人可见;普通用户**不能**自行修改用户名或密码
### 2.2 添加学生 ### 2.2 系统设置(超级管理员)
首页右上角 **系统设置**(仅超级管理员可见):
| 功能 | 说明 |
|------|------|
| 注册开关 | 开启后用户可自行注册;关闭后仅管理员添加用户 |
| 管理员账号 | 修改超级管理员的用户名、密码(修改密码需输入当前密码) |
| 用户管理 | 添加用户、重置密码、删除普通用户 |
### 2.3 添加学生
1. 首页点击 **添加学生** 1. 首页点击 **添加学生**
2. 填写: 2. 填写:
@@ -40,7 +51,7 @@
- **班级**:如「3班」(可选) - **班级**:如「3班」(可选)
3. 保存后在卡片上可看到学段标签 3. 保存后在卡片上可看到学段标签
### 2.3 录入成绩 ### 2.4 录入成绩
进入学生详情 → **成绩录入** 标签: 进入学生详情 → **成绩录入** 标签:
@@ -88,18 +99,33 @@
## 4. 错题库 ## 4. 错题库
### 4.1 上传错题 ### 4.1 上传错题(手机 / 平板)
进入 **错题库** 标签: 进入 **错题库****奥数区** 标签:
1. 选择 **科目** 1. 选择 **科目**
2. 点击 **上传错题图片**(支持 jpg/png/webp,最大 10MB 2. 点击 **拍照上传****相册选图**
3. 上传后后台自动:**OCR 识别 → AI 整理题目 → 生成解法** 3. 上传后自动:**OCR 识别 → 照片上红框标注错误位置 → 整理题目 → 生成「解题思路」与详细解答**
4. 处理状态:处理中 → 已识别 → 已生成解法
> 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 生成,请核对」,使用前请人工确认。 > 解法标注「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:忘记密码怎么办?** **Q:忘记密码怎么办?**
A当前版本无找回密码功能,需管理员在数据库中重置或重新注册(生产环境建议后续增加找回流程) A普通用户请联系超级管理员,在「系统设置 → 用户管理」中重置密码。超级管理员忘记密码需通过数据库或 `.env` 中的 `ADMIN_DEFAULT_*` 配合运维处理
**Q:多人能否共用一台服务器?** **Q:多人能否共用一台服务器?**
A:可以。每人注册独立账号,数据互不可见。 A:可以。每人独立账号,数据互不可见。
**Q:能否同时管理初中和高中孩子?** **Q:能否同时管理初中和高中孩子?**
A:可以。添加学生时分别选择学段即可。 A:可以。添加学生时分别选择学段即可。
@@ -145,11 +207,14 @@ A:可以。添加学生时分别选择学段即可。
A:可以。使用同一服务器地址与账号登录即可。 A:可以。使用同一服务器地址与账号登录即可。
**QHTTPS 和域名怎么配置?** **QHTTPS 和域名怎么配置?**
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
View File
@@ -8,7 +8,7 @@ pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
node_modules node_modules
dist # dist 由开发机构建后提交,供服务器零 Node 部署使用
dist-ssr dist-ssr
*.local *.local
+1
View File
@@ -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}}
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

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

After

Width:  |  Height:  |  Size: 4.9 KiB

+18
View File
@@ -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
View File
@@ -3,7 +3,9 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <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="author" content="马建军" />
<meta name="copyright" content="Copyright (c) 马建军. All rights reserved." /> <meta name="copyright" content="Copyright (c) 马建军. All rights reserved." />
<title>中学成绩档案</title> <title>中学成绩档案</title>
+20
View File
@@ -16,6 +16,7 @@
"echarts-for-react": "^3.0.6", "echarts-for-react": "^3.0.6",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-easy-crop": "^6.0.2",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^7.18.0", "react-router-dom": "^7.18.0",
"tslib": "^2.8.1" "tslib": "^2.8.1"
@@ -3308,6 +3309,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "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": { "node_modules/oxlint": {
"version": "1.71.0", "version": "1.71.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.71.0.tgz", "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.71.0.tgz",
@@ -3471,6 +3478,19 @@
"react": "^19.2.7" "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": { "node_modules/react-is": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+1
View File
@@ -18,6 +18,7 @@
"echarts-for-react": "^3.0.6", "echarts-for-react": "^3.0.6",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-easy-crop": "^6.0.2",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^7.18.0", "react-router-dom": "^7.18.0",
"tslib": "^2.8.1" "tslib": "^2.8.1"
+9
View File
@@ -1,6 +1,7 @@
import { Navigate, Route, Routes } from 'react-router-dom' import { Navigate, Route, Routes } from 'react-router-dom'
import { useAuth } from './context/AuthContext' import { useAuth } from './context/AuthContext'
import LoginPage from './pages/LoginPage' import LoginPage from './pages/LoginPage'
import SettingsPage from './pages/SettingsPage'
import StudentDetailPage from './pages/StudentDetailPage' import StudentDetailPage from './pages/StudentDetailPage'
import StudentsPage from './pages/StudentsPage' import StudentsPage from './pages/StudentsPage'
@@ -31,6 +32,14 @@ export default function App() {
</PrivateRoute> </PrivateRoute>
} }
/> />
<Route
path="/settings"
element={
<PrivateRoute>
<SettingsPage />
</PrivateRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
) )
+97 -4
View File
@@ -1,14 +1,23 @@
import axios from 'axios' import axios from 'axios'
import type { import type {
AdminUser,
AIProvider,
AppFeatures,
BackupInfo,
Composition,
CompositionInputMode,
Exam, Exam,
PublicSettings,
ScoreInput, ScoreInput,
SchoolLevel, SchoolLevel,
Student, Student,
Subject, Subject,
SystemSettings,
TokenResponse, TokenResponse,
TrendResponse, TrendResponse,
User, User,
WrongQuestion, WrongQuestion,
WrongQuestionCategory,
} from '../types' } from '../types'
import type { ExamType } from '../types' import type { ExamType } from '../types'
@@ -59,17 +68,73 @@ export const authApi = {
me: () => api.get<User>('/auth/me'), 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 = { export const studentApi = {
list: () => api.get<Student[]>('/students'), list: () => api.get<Student[]>('/students'),
create: (data: { create: (data: {
name: string name: string
school_level?: SchoolLevel school_level?: SchoolLevel
school_name?: string
grade?: string grade?: string
class_name?: string class_name?: string
}) => api.post<Student>('/students', data), }) => api.post<Student>('/students', data),
get: (id: string) => api.get<Student>(`/students/${id}`), get: (id: string) => api.get<Student>(`/students/${id}`),
update: (id: string, data: Partial<Student>) => api.patch<Student>(`/students/${id}`, data), update: (
id: string,
data: Partial<{
name: string
school_level: SchoolLevel
school_name: string
grade: string
class_name: string
}>,
) => api.patch<Student>(`/students/${id}`, data),
remove: (id: string) => api.delete(`/students/${id}`), remove: (id: string) => api.delete(`/students/${id}`),
uploadAvatar: (id: string, file: File) => {
const form = new FormData()
form.append('file', file)
return api.post<Student>(`/students/${id}/avatar`, form)
},
removeAvatar: (id: string) => api.delete<Student>(`/students/${id}/avatar`),
} }
export const subjectApi = { export const subjectApi = {
@@ -86,21 +151,49 @@ export const examApi = {
examId: string, examId: string,
data: Partial<{ exam_type: ExamType; exam_date: string; title: string; scores: ScoreInput[] }>, data: Partial<{ exam_type: ExamType; exam_date: string; title: string; scores: ScoreInput[] }>,
) => api.patch<Exam>(`/exams/${examId}`, data), ) => 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}`), remove: (examId: string) => api.delete(`/exams/${examId}`),
trend: (studentId: string, subjectId: number) => trend: (studentId: string, subjectId: number) =>
api.get<TrendResponse>(`/students/${studentId}/scores/trend`, { api.get<TrendResponse>(`/students/${studentId}/scores/trend`, {
params: { subject_id: subjectId }, params: { subject_id: subjectId },
}), }),
exportCsv: (studentId: string) => 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 = { 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 }), 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() const form = new FormData()
form.append('subject_id', String(subjectId)) form.append('subject_id', String(subjectId))
form.append('category', category)
form.append('file', file) form.append('file', file)
return api.post<WrongQuestion>(`/students/${studentId}/wrong-questions`, form) 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>
)
}
+184
View File
@@ -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>
)
}
+104
View File
@@ -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>
)
}
+148
View File
@@ -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>
)
}
+31 -11
View File
@@ -1,10 +1,10 @@
import { DeleteOutlined, EditOutlined } from '@ant-design/icons' 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 dayjs from 'dayjs'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { examApi } from '../api/client' import { examApi } from '../api/client'
import type { Exam, ExamType, ScoreInput, Subject } from '../types' import type { Exam, ExamType, ReviewStatus, ScoreInput, Subject } from '../types'
import { EXAM_TYPE_LABELS } from '../types' import { EXAM_TYPE_LABELS, REVIEW_STATUS_OPTIONS } from '../types'
interface Props { interface Props {
studentId: string studentId: string
@@ -28,15 +28,20 @@ export default function ScoreForm({ studentId, subjects, exams, onRefresh }: Pro
scores: subjects.map((s) => { scores: subjects.map((s) => {
const found = editing.scores.find((sc) => sc.subject_id === s.id) const found = editing.scores.find((sc) => sc.subject_id === s.id)
return found 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) { } else if (modalOpen) {
form.setFieldsValue({ form.setFieldsValue({
exam_type: 'weekly', exam_type: 'weekly',
exam_date: dayjs(), 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]) }, [modalOpen, editing, subjects, form])
@@ -55,10 +60,11 @@ export default function ScoreForm({ studentId, subjects, exams, onRefresh }: Pro
try { try {
const values = await form.validateFields() const values = await form.validateFields()
const scores: ScoreInput[] = (values.scores || []) 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, subject_id: subjects[idx]?.id ?? s.subject_id,
total_score: s.total_score, total_score: s.total_score,
obtained_score: s.obtained_score, obtained_score: s.obtained_score,
review_statuses: s.review_statuses || [],
})) }))
.filter( .filter(
(s: ScoreInput) => (s: ScoreInput) =>
@@ -71,6 +77,7 @@ export default function ScoreForm({ studentId, subjects, exams, onRefresh }: Pro
subject_id: s.subject_id, subject_id: s.subject_id,
total_score: Number(s.total_score), total_score: Number(s.total_score),
obtained_score: Number(s.obtained_score), obtained_score: Number(s.obtained_score),
review_statuses: s.review_statuses || [],
})) }))
if (scores.length === 0) { if (scores.length === 0) {
@@ -165,11 +172,11 @@ export default function ScoreForm({ studentId, subjects, exams, onRefresh }: Pro
onCancel={() => setModalOpen(false)} onCancel={() => setModalOpen(false)}
onOk={handleSubmit} onOk={handleSubmit}
confirmLoading={loading} confirmLoading={loading}
width={720} width={900}
destroyOnHidden destroyOnHidden
> >
<Form form={form} layout="vertical"> <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 }]}> <Form.Item name="exam_type" label="考试类型" rules={[{ required: true }]}>
<Select <Select
style={{ width: 120 }} style={{ width: 120 }}
@@ -191,9 +198,11 @@ export default function ScoreForm({ studentId, subjects, exams, onRefresh }: Pro
pagination={false} pagination={false}
dataSource={fields.map((f, i) => ({ ...f, subject: subjects[i] }))} dataSource={fields.map((f, i) => ({ ...f, subject: subjects[i] }))}
rowKey="key" rowKey="key"
scroll={{ x: 720 }}
columns={[ columns={[
{ {
title: '科目', title: '科目',
width: 70,
render: (_, row) => ( render: (_, row) => (
<> <>
<Form.Item name={[row.name, 'subject_id']} hidden initialValue={row.subject?.id}> <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: '总分', title: '总分',
width: 100,
render: (_, row) => ( render: (_, row) => (
<Form.Item name={[row.name, 'total_score']} noStyle> <Form.Item name={[row.name, 'total_score']} noStyle>
<InputNumber min={0} placeholder="总分" style={{ width: 100 }} /> <InputNumber min={0} placeholder="总分" style={{ width: 90 }} />
</Form.Item> </Form.Item>
), ),
}, },
{ {
title: '得分', title: '得分',
width: 100,
render: (_, row) => ( render: (_, row) => (
<Form.Item name={[row.name, 'obtained_score']} noStyle> <Form.Item name={[row.name, 'obtained_score']} noStyle>
<InputNumber min={0} placeholder="得分" style={{ width: 100 }} /> <InputNumber min={0} placeholder="得分" style={{ width: 90 }} />
</Form.Item> </Form.Item>
), ),
}, },
{ {
title: '占比', title: '占比',
width: 70,
render: (_, row) => { render: (_, row) => {
const total = form.getFieldValue(['scores', row.name, 'total_score']) const total = form.getFieldValue(['scores', row.name, 'total_score'])
const obtained = form.getFieldValue(['scores', row.name, 'obtained_score']) const obtained = form.getFieldValue(['scores', row.name, 'obtained_score'])
@@ -230,6 +242,14 @@ export default function ScoreForm({ studentId, subjects, exams, onRefresh }: Pro
return '-' return '-'
}, },
}, },
{
title: '考试状态',
render: (_, row) => (
<Form.Item name={[row.name, 'review_statuses']} noStyle initialValue={[]}>
<Checkbox.Group options={REVIEW_STATUS_OPTIONS} />
</Form.Item>
),
},
]} ]}
/> />
)} )}
+48
View File
@@ -0,0 +1,48 @@
import { UserOutlined } from '@ant-design/icons'
import { Avatar } from 'antd'
import { useEffect, useState } from 'react'
import api from '../api/client'
import type { Student } from '../types'
interface Props {
student: Pick<Student, 'id' | 'name' | 'has_avatar'>
size?: number
}
export default function StudentAvatar({ student, size = 40 }: Props) {
const [src, setSrc] = useState<string | null>(null)
useEffect(() => {
if (!student.has_avatar) {
setSrc(null)
return
}
let objectUrl: string | null = null
let cancelled = false
api
.get(`/students/${student.id}/avatar`, { responseType: 'blob' })
.then((res) => {
if (cancelled) return
objectUrl = URL.createObjectURL(res.data)
setSrc(objectUrl)
})
.catch(() => {
if (!cancelled) setSrc(null)
})
return () => {
cancelled = true
if (objectUrl) URL.revokeObjectURL(objectUrl)
}
}, [student.id, student.has_avatar])
return (
<Avatar
size={size}
src={src || undefined}
icon={!src ? <UserOutlined /> : undefined}
style={{ backgroundColor: src ? undefined : '#1677ff', flexShrink: 0 }}
>
{!src ? student.name.slice(0, 1) : null}
</Avatar>
)
}
@@ -0,0 +1,49 @@
import { Form, Input, Select } from 'antd'
import { GRADE_OPTIONS, SCHOOL_LEVEL_LABELS } from '../constants/school'
import type { SchoolLevel } from '../types'
export interface StudentFormValues {
name: string
school_level: SchoolLevel
school_name?: string
grade?: string
class_name?: string
}
interface Props {
form: ReturnType<typeof Form.useForm<StudentFormValues>>[0]
}
export default function StudentFormFields({ form }: Props) {
const schoolLevel = Form.useWatch('school_level', form) as SchoolLevel | undefined
return (
<>
<Form.Item name="name" label="姓名" rules={[{ required: true, message: '请输入姓名' }]}>
<Input placeholder="学生姓名" />
</Form.Item>
<Form.Item name="school_name" label="学校">
<Input placeholder="如:XX中学" />
</Form.Item>
<Form.Item name="school_level" label="学段" rules={[{ required: true }]}>
<Select
options={Object.entries(SCHOOL_LEVEL_LABELS).map(([value, label]) => ({ value, label }))}
onChange={() => form.setFieldValue('grade', undefined)}
/>
</Form.Item>
<Form.Item name="grade" label="年级" rules={[{ required: true, message: '请选择年级' }]}>
<Select
allowClear
placeholder={schoolLevel === 'senior_high' ? '请选择高几' : '请选择初几'}
options={(GRADE_OPTIONS[schoolLevel || 'junior_high'] || []).map((item) => ({
value: item.value,
label: item.label,
}))}
/>
</Form.Item>
<Form.Item name="class_name" label="班级">
<Input placeholder="如:3 或 3班" />
</Form.Item>
</>
)
}
@@ -0,0 +1,115 @@
import { DeleteOutlined, UploadOutlined } from '@ant-design/icons'
import { Button, Form, Popconfirm, Space, Typography, Upload, message } from 'antd'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { studentApi } from '../api/client'
import type { Student } from '../types'
import StudentAvatar from './StudentAvatar'
import StudentFormFields, { type StudentFormValues } from './StudentFormFields'
interface Props {
student: Student
onUpdated: (student: Student) => void
}
export default function StudentSettingsPanel({ student, onUpdated }: Props) {
const navigate = useNavigate()
const [form] = Form.useForm<StudentFormValues>()
const [saving, setSaving] = useState(false)
const [avatarLoading, setAvatarLoading] = useState(false)
const [current, setCurrent] = useState(student)
useEffect(() => {
setCurrent(student)
form.setFieldsValue({
name: student.name,
school_name: student.school_name || undefined,
school_level: student.school_level,
grade: student.grade || undefined,
class_name: student.class_name || undefined,
})
}, [student, form])
const handleSave = async () => {
const values = await form.validateFields()
setSaving(true)
try {
const { data } = await studentApi.update(student.id, values)
setCurrent(data)
onUpdated(data)
message.success('学生资料已保存')
} finally {
setSaving(false)
}
}
const handleAvatar = async (file: File) => {
setAvatarLoading(true)
try {
const { data } = await studentApi.uploadAvatar(student.id, file)
setCurrent(data)
onUpdated(data)
message.success('头像已更新')
} catch {
message.error('头像上传失败')
} finally {
setAvatarLoading(false)
}
return false
}
const handleRemoveAvatar = async () => {
const { data } = await studentApi.removeAvatar(student.id)
setCurrent(data)
onUpdated(data)
message.success('头像已移除')
}
const handleDelete = async () => {
await studentApi.remove(student.id)
message.success('学生已删除')
navigate('/')
}
return (
<Space direction="vertical" size="large" style={{ width: '100%', maxWidth: 520 }}>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
/ AI 使
</Typography.Paragraph>
<Space align="center" size="middle">
<StudentAvatar student={current} size={72} />
<Space direction="vertical" size={8}>
<Upload beforeUpload={handleAvatar} showUploadList={false} accept="image/*">
<Button icon={<UploadOutlined />} loading={avatarLoading}>
</Button>
</Upload>
{current.has_avatar && (
<Button size="small" danger onClick={handleRemoveAvatar}>
</Button>
)}
</Space>
</Space>
<Form form={form} layout="vertical">
<StudentFormFields form={form} />
<Space wrap>
<Button type="primary" loading={saving} onClick={handleSave}>
</Button>
<Popconfirm
title="确定删除该学生?"
description="将同时删除其成绩、错题、作文等全部数据,且不可恢复。"
onConfirm={handleDelete}
>
<Button danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
</Form>
</Space>
)
}
@@ -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()
}}
/>
)}
</>
)
}
+134 -22
View File
@@ -1,62 +1,171 @@
import { ReloadOutlined, UploadOutlined } from '@ant-design/icons' import { CameraOutlined, PictureOutlined, ReloadOutlined, UploadOutlined } from '@ant-design/icons'
import { Button, Input, Select, Space, Upload, message } from 'antd' import { Button, Input, Select, Space, Typography, Upload, message } from 'antd'
import { useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { wrongQuestionApi } from '../api/client' import { wrongQuestionApi } from '../api/client'
import type { Subject } from '../types' import type { Subject, WrongQuestionCategory } from '../types'
import ImageCropModal from './ImageCropModal'
interface Props { interface Props {
studentId: string studentId: string
subjects: Subject[] subjects: Subject[]
category: WrongQuestionCategory
onUploaded: () => void onUploaded: () => void
} }
export default function WrongQuestionUpload({ studentId, subjects, onUploaded }: Props) { export default function WrongQuestionUpload({ studentId, subjects, category, onUploaded }: Props) {
const [subjectId, setSubjectId] = useState<number | undefined>(subjects[0]?.id) 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 [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) { if (!subjectId) {
message.warning('请选择科目') message.warning(isOlympiad ? '未找到数学科目' : '请选择科目')
return false return
}
setCropSrc(URL.createObjectURL(file))
setPendingFile(file)
setCropOpen(true)
}
const doUpload = async (file: File) => {
if (!subjectId) {
message.warning(isOlympiad ? '未找到数学科目' : '请选择科目')
return
} }
setUploading(true) setUploading(true)
try { try {
await wrongQuestionApi.upload(studentId, subjectId, file) await wrongQuestionApi.upload(studentId, subjectId, file, category)
message.success('上传成功,正在 OCR 识别并生成解法…') message.success('上传成功,正在识别并生成解法…')
onUploaded() onUploaded()
} catch { } catch {
message.error('上传失败') message.error('上传失败')
} finally { } finally {
setUploading(false) setUploading(false)
} }
}
const handlePickFile = (file: File) => {
openCrop(file)
return false 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 ( return (
<Space wrap style={{ marginBottom: 16 }}> <Space direction="vertical" size="middle" style={{ width: '100%', marginBottom: 16 }}>
{isOlympiad ? (
<Typography.Text></Typography.Text>
) : (
<Select <Select
style={{ width: 120 }} style={{ width: '100%', maxWidth: 200 }}
placeholder="选择科目" placeholder="选择科目"
value={subjectId} value={subjectId}
onChange={setSubjectId} onChange={setSubjectId}
options={subjects.map((s) => ({ value: s.id, label: s.name }))} options={availableSubjects.map((s) => ({ value: s.id, label: s.name }))}
/> />
<Upload beforeUpload={handleUpload} showUploadList={false} accept="image/*"> )}
<Button icon={<UploadOutlined />} loading={uploading} type="primary"> <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> </Button>
</Upload> </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}
/>
</Space> </Space>
) )
} }
interface SearchProps { interface SearchProps {
subjectId?: number subjectId?: number
onSubjectChange: (id?: number) => void onSubjectChange?: (id?: number) => void
search: string search: string
onSearchChange: (q: string) => void onSearchChange: (q: string) => void
onRefresh: () => void onRefresh: () => void
subjects: Subject[] subjects: Subject[]
hideSubjectFilter?: boolean
} }
export function WrongQuestionFilters({ export function WrongQuestionFilters({
@@ -66,23 +175,26 @@ export function WrongQuestionFilters({
onSearchChange, onSearchChange,
onRefresh, onRefresh,
subjects, subjects,
hideSubjectFilter,
}: SearchProps) { }: SearchProps) {
return ( return (
<Space wrap style={{ marginBottom: 16 }}> <Space wrap style={{ marginBottom: 16, width: '100%' }}>
{!hideSubjectFilter && (
<Select <Select
allowClear allowClear
style={{ width: 140 }} style={{ width: '100%', maxWidth: 140 }}
placeholder="全部科目" placeholder="全部科目"
value={subjectId} value={subjectId}
onChange={onSubjectChange} onChange={onSubjectChange}
options={subjects.map((s) => ({ value: s.id, label: s.name }))} options={subjects.map((s) => ({ value: s.id, label: s.name }))}
/> />
)}
<Input.Search <Input.Search
placeholder="搜索题目/解法" placeholder="搜索题目/解法"
value={search} value={search}
onChange={(e) => onSearchChange(e.target.value)} onChange={(e) => onSearchChange(e.target.value)}
onSearch={onRefresh} onSearch={() => onRefresh()}
style={{ width: 220 }} style={{ width: '100%', maxWidth: 260 }}
allowClear allowClear
/> />
<Button icon={<ReloadOutlined />} onClick={onRefresh}> <Button icon={<ReloadOutlined />} onClick={onRefresh}>
+34 -10
View File
@@ -7,20 +7,44 @@ export const SCHOOL_LEVEL_LABELS: Record<SchoolLevel, string> = {
senior_high: '高中', senior_high: '高中',
} }
export const GRADE_OPTIONS: Record<SchoolLevel, string[]> = { export const GRADE_OPTIONS: Record<SchoolLevel, { value: string; label: string }[]> = {
junior_high: ['初一', '初二', '初三'], junior_high: [
senior_high: ['一', '高二', '高三'], { value: '一', label: '初一(七年级)' },
{ value: '初二', label: '初二(八年级)' },
{ value: '初三', label: '初三(九年级)' },
],
senior_high: [
{ value: '高一', label: '高一(十年级)' },
{ value: '高二', label: '高二(十一年级)' },
{ value: '高三', label: '高三(十二年级)' },
],
} }
export function formatStudentMeta(student: { export function formatStudentMeta(
student: {
school_level: SchoolLevel school_level: SchoolLevel
school_name?: string | null
grade?: string | null
class_name?: string | null
},
options?: { includeLevel?: boolean },
): string {
const parts: string[] = []
if (student.school_name?.trim()) parts.push(student.school_name.trim())
if (options?.includeLevel !== false) parts.push(SCHOOL_LEVEL_LABELS[student.school_level])
if (student.grade) parts.push(student.grade)
if (student.class_name) {
const cls = student.class_name.trim()
parts.push(cls.endsWith('班') ? cls : `${cls}`)
}
return parts.length ? parts.join(' · ') : '未设置年级信息'
}
export function formatStudentSubtitle(student: {
school_level: SchoolLevel
school_name?: string | null
grade?: string | null grade?: string | null
class_name?: string | null class_name?: string | null
}): string { }): string {
const parts = [ return formatStudentMeta(student, { includeLevel: false })
SCHOOL_LEVEL_LABELS[student.school_level],
student.grade,
student.class_name,
].filter(Boolean)
return parts.length ? parts.join(' · ') : '未设置学段年级'
} }
+113 -1
View File
@@ -8,18 +8,130 @@ body {
sans-serif; sans-serif;
background: #f5f5f5; background: #f5f5f5;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent;
} }
#root { #root {
min-height: 100vh; min-height: 100vh;
min-height: 100dvh;
} }
a { a {
color: inherit; 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 { .ant-table {
font-size: 12px; 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;
}
} }
+45 -26
View File
@@ -1,7 +1,9 @@
import { LockOutlined, UserOutlined } from '@ant-design/icons' 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 axios from 'axios'
import { useEffect, useState } from 'react'
import { Navigate, useNavigate } from 'react-router-dom' import { Navigate, useNavigate } from 'react-router-dom'
import { settingsApi } from '../api/client'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
function apiErrorMessage(error: unknown, fallback: string) { function apiErrorMessage(error: unknown, fallback: string) {
@@ -18,6 +20,16 @@ export default function LoginPage() {
const navigate = useNavigate() const navigate = useNavigate()
const [loginForm] = Form.useForm() const [loginForm] = Form.useForm()
const [registerForm] = 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 /> if (!loading && user) return <Navigate to="/" replace />
@@ -45,23 +57,7 @@ export default function LoginPage() {
} }
} }
return ( const tabItems = [
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: 16,
}}
>
<Card style={{ width: '100%', maxWidth: 420 }} title="中学生成绩档案">
<Typography.Paragraph type="secondary" style={{ textAlign: 'center' }}>
· ·
</Typography.Paragraph>
<Tabs
items={[
{ {
key: 'login', key: 'login',
label: '登录', label: '登录',
@@ -79,7 +75,10 @@ export default function LoginPage() {
</Form> </Form>
), ),
}, },
{ ]
if (registrationEnabled) {
tabItems.push({
key: 'register', key: 'register',
label: '注册', label: '注册',
children: ( children: (
@@ -102,10 +101,7 @@ export default function LoginPage() {
> >
<Input.Password prefix={<LockOutlined />} placeholder="密码" /> <Input.Password prefix={<LockOutlined />} placeholder="密码" />
</Form.Item> </Form.Item>
<Form.Item <Form.Item name="confirm" rules={[{ required: true, message: '请确认密码' }]}>
name="confirm"
rules={[{ required: true, message: '请确认密码' }]}
>
<Input.Password prefix={<LockOutlined />} placeholder="确认密码" /> <Input.Password prefix={<LockOutlined />} placeholder="确认密码" />
</Form.Item> </Form.Item>
<Button type="primary" htmlType="submit" block> <Button type="primary" htmlType="submit" block>
@@ -113,9 +109,32 @@ export default function LoginPage() {
</Button> </Button>
</Form> </Form>
), ),
}, })
]} }
/>
return (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: 16,
}}
>
<Card style={{ width: '100%', maxWidth: 420 }} title="中学生成绩档案">
<Typography.Paragraph type="secondary" style={{ textAlign: 'center' }}>
· ·
</Typography.Paragraph>
<Spin spinning={settingsLoading}>
{!settingsLoading && !registrationEnabled && (
<Typography.Paragraph type="secondary" style={{ textAlign: 'center', marginBottom: 16 }}>
</Typography.Paragraph>
)}
<Tabs items={tabItems} />
</Spin>
<Typography.Paragraph <Typography.Paragraph
type="secondary" type="secondary"
style={{ textAlign: 'center', fontSize: 12, marginTop: 16, marginBottom: 0 }} style={{ textAlign: 'center', fontSize: 12, marginTop: 16, marginBottom: 0 }}
+494
View File
@@ -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:23567install.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>
)
}
+146 -69
View File
@@ -1,30 +1,44 @@
import { ArrowLeftOutlined, DownloadOutlined } from '@ant-design/icons' import { ArrowLeftOutlined, DownloadOutlined } from '@ant-design/icons'
import { Button, Select, Space, Spin, Tabs, Tag, Typography, message } from 'antd' import { Button, Select, Space, Spin, Tabs, Tag, Typography, message } from 'antd'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom' import { Link, useParams, useSearchParams } from 'react-router-dom'
import { examApi, studentApi, subjectApi, wrongQuestionApi } from '../api/client' import { examApi, studentApi, subjectApi, wrongQuestionApi } from '../api/client'
import ScoreForm from '../components/ScoreForm' import ScoreForm from '../components/ScoreForm'
import ScoreOverview from '../components/ScoreOverview' import ScoreOverview from '../components/ScoreOverview'
import TrendChart from '../components/TrendChart' import TrendChart from '../components/TrendChart'
import WrongQuestionList from '../components/WrongQuestionList'
import WrongQuestionUpload, { WrongQuestionFilters } from '../components/WrongQuestionUpload' 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 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() { export default function StudentDetailPage() {
const { id } = useParams<{ id: string }>() 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 [student, setStudent] = useState<Student | null>(null)
const [subjects, setSubjects] = useState<Subject[]>([]) const [subjects, setSubjects] = useState<Subject[]>([])
const [exams, setExams] = useState<Exam[]>([]) const [exams, setExams] = useState<Exam[]>([])
const [trend, setTrend] = useState<TrendResponse | null>(null) const [trend, setTrend] = useState<TrendResponse | null>(null)
const [selectedSubject, setSelectedSubject] = useState<number>() const [selectedSubject, setSelectedSubject] = useState<number>()
const [wrongQuestions, setWrongQuestions] = useState<WrongQuestion[]>([]) const [wrongQuestions, setWrongQuestions] = useState<WrongQuestion[]>([])
const [olympiadQuestions, setOlympiadQuestions] = useState<WrongQuestion[]>([])
const [wqSubjectFilter, setWqSubjectFilter] = useState<number>() const [wqSubjectFilter, setWqSubjectFilter] = useState<number>()
const [wqSearch, setWqSearch] = useState('') const [wqSearch, setWqSearch] = useState('')
const [olympiadSearch, setOlympiadSearch] = useState('')
const [selectedWq, setSelectedWq] = useState<string | null>(null) const [selectedWq, setSelectedWq] = useState<string | null>(null)
const [selectedOlympiad, setSelectedOlympiad] = useState<string | null>(null)
const [loading, setLoading] = useState(true) 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 subjectNames = Object.fromEntries(subjects.map((s) => [s.id, s.name]))
const loadExams = useCallback(async () => { const loadExams = useCallback(async () => {
@@ -44,43 +58,89 @@ export default function StudentDetailPage() {
const { data } = await wrongQuestionApi.list(id, { const { data } = await wrongQuestionApi.list(id, {
subject_id: wqSubjectFilter, subject_id: wqSubjectFilter,
q: wqSearch || undefined, q: wqSearch || undefined,
category: 'regular',
}) })
setWrongQuestions(data) setWrongQuestions(data)
}, [id, wqSubjectFilter, wqSearch]) }, [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(() => { useEffect(() => {
if (!id) return if (!id) return
const init = async () => { let cancelled = false
;(async () => {
setLoading(true) setLoading(true)
try { try {
const [studentRes, subjectRes] = await Promise.all([ const [studentRes, subjectRes] = await Promise.all([
studentApi.get(id), studentApi.get(id),
subjectApi.list(), subjectApi.list(),
]) ])
if (cancelled) return
setStudent(studentRes.data) setStudent(studentRes.data)
setSubjects(subjectRes.data) setSubjects(subjectRes.data)
if (subjectRes.data.length) setSelectedSubject(subjectRes.data[0].id) if (subjectRes.data.length) setSelectedSubject(subjectRes.data[0].id)
await loadExams() const examRes = await examApi.list(id)
await loadWrongQuestions() if (!cancelled) setExams(examRes.data)
} finally { } finally {
setLoading(false) if (!cancelled) setLoading(false)
} }
})()
return () => {
cancelled = true
} }
init() }, [id])
}, [id, loadExams, loadWrongQuestions])
useEffect(() => {
loadWrongQuestions()
}, [loadWrongQuestions])
useEffect(() => {
loadOlympiadQuestions()
}, [loadOlympiadQuestions])
useEffect(() => { useEffect(() => {
loadTrend() loadTrend()
}, [loadTrend]) }, [loadTrend])
const handleTabChange = (key: string) => {
setSearchParams({ tab: key }, { replace: true })
}
const handleExport = async () => { const handleExport = async () => {
if (!id) return if (!id) return
try { try {
const { data } = await examApi.exportCsv(id) const res = await examApi.exportCsv(id)
const url = URL.createObjectURL(data) 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') const a = document.createElement('a')
a.href = url a.href = url
a.download = `${student?.name || 'student'}_scores.csv` a.download = filename
a.click() a.click()
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} catch { } catch {
@@ -98,36 +158,36 @@ export default function StudentDetailPage() {
if (!student) return <Typography.Text></Typography.Text> if (!student) return <Typography.Text></Typography.Text>
const stageLabel = SCHOOL_LEVEL_LABELS[student.school_level]
return ( return (
<div style={{ maxWidth: 1100, margin: '0 auto', padding: '16px 16px 32px' }}> <div className="page-container">
<Space style={{ marginBottom: 16 }} wrap> <Space className="page-header" wrap>
<Link to="/"> <Link to="/">
<Button icon={<ArrowLeftOutlined />}></Button> <Button icon={<ArrowLeftOutlined />}></Button>
</Link> </Link>
<StudentAvatar student={student} size={40} />
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}>
{student.name} {student.name}
</Typography.Title> </Typography.Title>
<Tag color={student.school_level === 'senior_high' ? 'purple' : 'blue'}> <Tag color={student.school_level === 'senior_high' ? 'purple' : 'blue'}>{stageLabel}</Tag>
{SCHOOL_LEVEL_LABELS[student.school_level]} <Typography.Text type="secondary">{formatStudentSubtitle(student)}</Typography.Text>
</Tag>
<Typography.Text type="secondary">{formatStudentMeta(student)}</Typography.Text>
<Button icon={<DownloadOutlined />} onClick={handleExport}> <Button icon={<DownloadOutlined />} onClick={handleExport}>
CSV CSV
</Button> </Button>
</Space> </Space>
<Tabs <Tabs
className="student-tabs"
activeKey={activeTab}
onChange={handleTabChange}
destroyInactiveTabPane={false}
items={[ items={[
{ {
key: 'scores', key: 'scores',
label: '成绩录入', label: '成绩录入',
children: ( children: (
<ScoreForm <ScoreForm studentId={id!} subjects={subjects} exams={exams} onRefresh={loadExams} />
studentId={id!}
subjects={subjects}
exams={exams}
onRefresh={loadExams}
/>
), ),
}, },
{ {
@@ -141,7 +201,7 @@ export default function StudentDetailPage() {
children: ( children: (
<div> <div>
<Select <Select
style={{ width: 140, marginBottom: 16 }} style={{ width: '100%', maxWidth: 160, marginBottom: 16 }}
value={selectedSubject} value={selectedSubject}
onChange={setSelectedSubject} onChange={setSelectedSubject}
options={subjects.map((s) => ({ value: s.id, label: s.name }))} options={subjects.map((s) => ({ value: s.id, label: s.name }))}
@@ -156,14 +216,28 @@ export default function StudentDetailPage() {
</div> </div>
), ),
}, },
{
key: 'review',
label: '成绩复盘',
children: <ExamReviewPanel studentId={id!} exams={exams} onRefresh={loadExams} />,
},
{
key: 'composition',
label: '作文区',
children: <CompositionPanel studentId={id!} student={student} />,
},
{ {
key: 'wrong', key: 'wrong',
label: '错题库', label: '错题库',
children: ( children: (
<div> <div>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
{stageLabel}
</Typography.Paragraph>
<WrongQuestionUpload <WrongQuestionUpload
studentId={id!} studentId={id!}
subjects={subjects} subjects={subjects}
category="regular"
onUploaded={loadWrongQuestions} onUploaded={loadWrongQuestions}
/> />
<WrongQuestionFilters <WrongQuestionFilters
@@ -174,54 +248,57 @@ export default function StudentDetailPage() {
onRefresh={loadWrongQuestions} onRefresh={loadWrongQuestions}
subjects={subjects} subjects={subjects}
/> />
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 16 }}> <WrongQuestionList
{wrongQuestions.map((wq) => ( items={wrongQuestions}
<div selectedId={selectedWq}
key={wq.id} onSelect={setSelectedWq}
onClick={() => setSelectedWq(wq.id)} onRefresh={loadWrongQuestions}
style={{ emptyText="暂无错题"
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 }}> </div>
<Space> ),
<Typography.Text strong>{wq.subject_name}</Typography.Text> },
<Typography.Text type="secondary" style={{ fontSize: 12 }}> {
{STATUS_LABELS[wq.status]} key: 'olympiad',
</Typography.Text> label: '奥数区',
</Space> children: (
<Typography.Paragraph <div>
ellipsis={{ rows: 2 }} <Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
style={{ margin: '8px 0 0', fontSize: 13 }} {stageLabel}{stageLabel}
>
{wq.question_text || wq.ocr_raw_text || '处理中…'}
</Typography.Paragraph> </Typography.Paragraph>
</div> <WrongQuestionUpload
</div> studentId={id!}
))} subjects={subjects}
</div> category="olympiad"
{wrongQuestions.length === 0 && ( onUploaded={loadOlympiadQuestions}
<Typography.Text type="secondary"></Typography.Text> />
)} <WrongQuestionFilters
{selectedWq && ( search={olympiadSearch}
<WrongQuestionDetail onSearchChange={setOlympiadSearch}
questionId={selectedWq} onRefresh={loadOlympiadQuestions}
open={!!selectedWq} subjects={subjects}
onClose={() => setSelectedWq(null)} hideSubjectFilter
onUpdated={loadWrongQuestions} />
<WrongQuestionList
items={olympiadQuestions}
selectedId={selectedOlympiad}
onSelect={setSelectedOlympiad}
onRefresh={loadOlympiadQuestions}
emptyText="暂无奥数题"
/> />
)}
</div> </div>
), ),
}, },
{
key: 'settings',
label: '设置',
children: (
<StudentSettingsPanel
student={student}
onUpdated={(updated) => setStudent(updated)}
/>
),
},
]} ]}
/> />
</div> </div>
+71 -42
View File
@@ -1,19 +1,21 @@
import { LogoutOutlined, PlusOutlined, UserOutlined } from '@ant-design/icons' import { DeleteOutlined, EditOutlined, LogoutOutlined, PlusOutlined, SettingOutlined } from '@ant-design/icons'
import { Button, Card, Col, Form, Input, Modal, Row, Select, Space, Spin, Tag, Typography, message } from 'antd' import { Button, Card, Col, Form, Modal, Popconfirm, Row, Space, Spin, Tag, Typography, message } from 'antd'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { studentApi } from '../api/client' import { studentApi } from '../api/client'
import { formatStudentMeta, GRADE_OPTIONS, SCHOOL_LEVEL_LABELS } from '../constants/school' import StudentAvatar from '../components/StudentAvatar'
import StudentFormFields, { type StudentFormValues } from '../components/StudentFormFields'
import { formatStudentSubtitle, SCHOOL_LEVEL_LABELS } from '../constants/school'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import type { SchoolLevel, Student } from '../types' import type { Student } from '../types'
export default function StudentsPage() { export default function StudentsPage() {
const { user, logout } = useAuth() const { user, logout } = useAuth()
const [students, setStudents] = useState<Student[]>([]) const [students, setStudents] = useState<Student[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [modalOpen, setModalOpen] = useState(false) const [modalOpen, setModalOpen] = useState(false)
const [form] = Form.useForm() const [editing, setEditing] = useState<Student | null>(null)
const schoolLevel = Form.useWatch('school_level', form) as SchoolLevel | undefined const [form] = Form.useForm<StudentFormValues>()
const load = async () => { const load = async () => {
setLoading(true) setLoading(true)
@@ -30,21 +32,45 @@ export default function StudentsPage() {
}, []) }, [])
const openCreate = () => { const openCreate = () => {
form.setFieldsValue({ school_level: 'junior_high', grade: undefined }) setEditing(null)
form.setFieldsValue({ school_level: 'junior_high', grade: undefined, school_name: undefined })
setModalOpen(true) setModalOpen(true)
} }
const handleCreate = async () => { const openEdit = (student: Student) => {
setEditing(student)
form.setFieldsValue({
name: student.name,
school_name: student.school_name || undefined,
school_level: student.school_level,
grade: student.grade || undefined,
class_name: student.class_name || undefined,
})
setModalOpen(true)
}
const handleSubmit = async () => {
const values = await form.validateFields() const values = await form.validateFields()
if (editing) {
await studentApi.update(editing.id, values)
message.success('学生资料已更新')
} else {
await studentApi.create(values) await studentApi.create(values)
message.success('学生已添加') message.success('学生已添加')
}
setModalOpen(false) setModalOpen(false)
form.resetFields() form.resetFields()
load() load()
} }
const handleDelete = async (student: Student) => {
await studentApi.remove(student.id)
message.success('学生已删除')
load()
}
return ( return (
<div style={{ maxWidth: 960, margin: '0 auto', padding: '16px 16px 32px' }}> <div className="page-container">
<div <div
style={{ style={{
display: 'flex', display: 'flex',
@@ -62,6 +88,11 @@ export default function StudentsPage() {
<Typography.Text type="secondary">{user?.username}</Typography.Text> <Typography.Text type="secondary">{user?.username}</Typography.Text>
</div> </div>
<Space wrap> <Space wrap>
{user?.is_superuser && (
<Link to="/settings">
<Button icon={<SettingOutlined />}></Button>
</Link>
)}
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}> <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button> </Button>
@@ -75,12 +106,34 @@ export default function StudentsPage() {
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
{students.map((s) => ( {students.map((s) => (
<Col xs={24} sm={12} md={8} key={s.id}> <Col xs={24} sm={12} md={8} key={s.id}>
<Link to={`/students/${s.id}`} style={{ textDecoration: 'none' }}> <Card
<Card hoverable> hoverable
actions={[
<Button
type="link"
icon={<EditOutlined />}
onClick={() => openEdit(s)}
key="edit"
>
</Button>,
<Popconfirm
key="delete"
title="确定删除该学生?"
description="将删除其全部成绩与错题数据"
onConfirm={() => handleDelete(s)}
>
<Button type="link" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>,
]}
>
<Link to={`/students/${s.id}`} style={{ textDecoration: 'none', color: 'inherit' }}>
<Space align="start"> <Space align="start">
<UserOutlined style={{ fontSize: 24, color: '#1677ff' }} /> <StudentAvatar student={s} size={48} />
<div> <div>
<Space size={4}> <Space size={4} wrap>
<Typography.Text strong>{s.name}</Typography.Text> <Typography.Text strong>{s.name}</Typography.Text>
<Tag color={s.school_level === 'senior_high' ? 'purple' : 'blue'}> <Tag color={s.school_level === 'senior_high' ? 'purple' : 'blue'}>
{SCHOOL_LEVEL_LABELS[s.school_level]} {SCHOOL_LEVEL_LABELS[s.school_level]}
@@ -88,12 +141,12 @@ export default function StudentsPage() {
</Space> </Space>
<br /> <br />
<Typography.Text type="secondary" style={{ fontSize: 12 }}> <Typography.Text type="secondary" style={{ fontSize: 12 }}>
{formatStudentMeta(s)} {formatStudentSubtitle(s)}
</Typography.Text> </Typography.Text>
</div> </div>
</Space> </Space>
</Card>
</Link> </Link>
</Card>
</Col> </Col>
))} ))}
{!loading && students.length === 0 && ( {!loading && students.length === 0 && (
@@ -107,38 +160,14 @@ export default function StudentsPage() {
</Spin> </Spin>
<Modal <Modal
title="添加学生" title={editing ? '修改学生' : '添加学生'}
open={modalOpen} open={modalOpen}
onCancel={() => setModalOpen(false)} onCancel={() => setModalOpen(false)}
onOk={handleCreate} onOk={handleSubmit}
destroyOnHidden destroyOnHidden
> >
<Form form={form} layout="vertical" initialValues={{ school_level: 'junior_high' }}> <Form form={form} layout="vertical" initialValues={{ school_level: 'junior_high' }}>
<Form.Item name="name" label="姓名" rules={[{ required: true }]}> <StudentFormFields form={form} />
<Input />
</Form.Item>
<Form.Item name="school_level" label="学段" rules={[{ required: true }]}>
<Select
options={Object.entries(SCHOOL_LEVEL_LABELS).map(([value, label]) => ({
value,
label,
}))}
onChange={() => form.setFieldValue('grade', undefined)}
/>
</Form.Item>
<Form.Item name="grade" label="年级">
<Select
allowClear
placeholder={schoolLevel === 'senior_high' ? '如:高一' : '如:初二'}
options={(GRADE_OPTIONS[schoolLevel || 'junior_high'] || []).map((g) => ({
value: g,
label: g,
}))}
/>
</Form.Item>
<Form.Item name="class_name" label="班级">
<Input placeholder="如:3班" />
</Form.Item>
</Form> </Form>
</Modal> </Modal>
</div> </div>
+132 -18
View File
@@ -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 { useEffect, useState } from 'react'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import AuthenticatedImage from '../components/AuthenticatedImage'
import { wrongQuestionApi } from '../api/client' import { wrongQuestionApi } from '../api/client'
import type { WrongQuestion } from '../types' import type { WrongQuestion } from '../types'
import { isWrongQuestionProcessing, processingHint } from '../utils/wqProcessing'
import { STATUS_LABELS } from '../types' import { STATUS_LABELS } from '../types'
interface Props { interface Props {
@@ -10,23 +12,35 @@ interface Props {
open: boolean open: boolean
onClose: () => void onClose: () => void
onUpdated: () => 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 [wq, setWq] = useState<WrongQuestion | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [questionText, setQuestionText] = useState('') const [questionText, setQuestionText] = useState('')
const [approachText, setApproachText] = useState('')
const [solutionText, setSolutionText] = useState('') const [solutionText, setSolutionText] = useState('')
const [imageMode, setImageMode] = useState<'annotated' | 'original'>('annotated')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [regenerating, setRegenerating] = useState(false) const [regenerating, setRegenerating] = useState(false)
const [deleting, setDeleting] = useState(false)
const load = async () => { const load = async () => {
setLoading(true) setLoading(true)
try { try {
const { data } = await wrongQuestionApi.get(questionId) const { data } = await wrongQuestionApi.get(questionId)
setWq(data) setWq(data)
setQuestionText(data.question_text || '') setQuestionText(data.question_text || data.ocr_raw_text || '')
setApproachText(data.solution_approach || '')
setSolutionText(data.solution_text || '') setSolutionText(data.solution_text || '')
setImageMode(data.has_annotated_image ? 'annotated' : 'original')
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -36,11 +50,30 @@ export default function WrongQuestionDetail({ questionId, open, onClose, onUpdat
if (open && questionId) load() if (open && questionId) load()
}, [open, questionId]) }, [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 () => { const handleSave = async () => {
setSaving(true) setSaving(true)
try { try {
await wrongQuestionApi.update(questionId, { await wrongQuestionApi.update(questionId, {
question_text: questionText, question_text: questionText,
solution_approach: approachText,
solution_text: solutionText, solution_text: solutionText,
}) })
message.success('已保存') message.success('已保存')
@@ -55,12 +88,13 @@ export default function WrongQuestionDetail({ questionId, open, onClose, onUpdat
try { try {
const { data } = await wrongQuestionApi.regenerate(questionId) const { data } = await wrongQuestionApi.regenerate(questionId)
setWq(data) setWq(data)
setQuestionText(data.question_text || '') setQuestionText(data.question_text || data.ocr_raw_text || '')
setApproachText(data.solution_approach || '')
setSolutionText(data.solution_text || '') setSolutionText(data.solution_text || '')
message.success('解已重新生成') message.success('解题思路已重新生成')
onUpdated() onUpdated()
} catch { } catch {
message.error('生成失败,请确认 Ollama 已启动') message.error('生成失败,请检查 AI 模型配置')
} finally { } finally {
setRegenerating(false) setRegenerating(false)
} }
@@ -68,23 +102,46 @@ export default function WrongQuestionDetail({ questionId, open, onClose, onUpdat
const handleRetryOcr = async () => { const handleRetryOcr = async () => {
await wrongQuestionApi.retryOcr(questionId) await wrongQuestionApi.retryOcr(questionId)
message.info('已重新识别,请稍后刷新') message.info('已重新识别并标注,请稍后刷新')
onUpdated() onUpdated()
onClose() onClose()
} }
const handleDelete = async () => {
setDeleting(true)
try {
await wrongQuestionApi.remove(questionId)
message.success('已删除')
onDeleted?.()
onClose()
} catch {
message.error('删除失败')
} finally {
setDeleting(false)
}
}
return ( return (
<Modal <Modal
title={wq ? `${wq.subject_name} · 错题详情` : '错题详情'} title={
wq
? `${wq.subject_name}${wq.category === 'olympiad' ? ' · 奥数' : ''} · 详情`
: '详情'
}
open={open} open={open}
onCancel={onClose} onCancel={onClose}
width="90%" width="90%"
style={{ maxWidth: 960 }} style={{ maxWidth: 960 }}
footer={ footer={
<Space wrap> <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 loading={regenerating} onClick={handleRegenerate}>
</Button> </Button>
<Button type="primary" loading={saving} onClick={handleSave}> <Button type="primary" loading={saving} onClick={handleSave}>
@@ -95,10 +152,30 @@ export default function WrongQuestionDetail({ questionId, open, onClose, onUpdat
<Spin spinning={loading}> <Spin spinning={loading}>
{wq && ( {wq && (
<> <>
<Space wrap style={{ marginBottom: 8 }}>
<Typography.Text type="secondary">{STATUS_LABELS[wq.status]}</Typography.Text> <Typography.Text type="secondary">{STATUS_LABELS[wq.status]}</Typography.Text>
{wq.solution_text && ( {wq.has_annotated_image && !wq.error_message && (
<Typography.Text type="danger"></Typography.Text>
)}
</Space>
{wq.error_message && (
<Alert <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" type="warning"
showIcon showIcon
style={{ margin: '12px 0' }} style={{ margin: '12px 0' }}
@@ -106,14 +183,27 @@ export default function WrongQuestionDetail({ questionId, open, onClose, onUpdat
)} )}
<Row gutter={16} style={{ marginTop: 12 }}> <Row gutter={16} style={{ marginTop: 12 }}>
<Col xs={24} md={10}> <Col xs={24} md={10}>
<img {wq.has_annotated_image && (
src={wrongQuestionApi.imageUrl(wq.id)} <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="原题" alt="原题"
style={{ width: '100%', borderRadius: 8, border: '1px solid #f0f0f0' }} style={{ width: '100%', borderRadius: 8, border: '1px solid #f0f0f0' }}
/> />
{wq.ocr_raw_text && ( {wq.ocr_raw_text && (
<div style={{ marginTop: 12 }}> <div style={{ marginTop: 12 }}>
<Typography.Text strong>OCR </Typography.Text> <Typography.Text strong>OCR </Typography.Text>
<pre <pre
style={{ style={{
background: '#fafafa', background: '#fafafa',
@@ -132,12 +222,36 @@ export default function WrongQuestionDetail({ questionId, open, onClose, onUpdat
<Col xs={24} md={14}> <Col xs={24} md={14}>
<Typography.Text strong></Typography.Text> <Typography.Text strong></Typography.Text>
<Input.TextArea <Input.TextArea
rows={6} rows={5}
value={questionText} value={questionText}
onChange={(e) => setQuestionText(e.target.value)} onChange={(e) => setQuestionText(e.target.value)}
style={{ marginTop: 8, marginBottom: 16 }} 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 <Input.TextArea
rows={8} rows={8}
value={solutionText} value={solutionText}
@@ -147,7 +261,7 @@ export default function WrongQuestionDetail({ questionId, open, onClose, onUpdat
{solutionText && ( {solutionText && (
<div style={{ background: '#fafafa', padding: 12, borderRadius: 8 }}> <div style={{ background: '#fafafa', padding: 12, borderRadius: 8 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}> <Typography.Text type="secondary" style={{ fontSize: 12 }}>
</Typography.Text> </Typography.Text>
<ReactMarkdown>{solutionText}</ReactMarkdown> <ReactMarkdown>{solutionText}</ReactMarkdown>
</div> </div>
+104
View File
@@ -7,9 +7,42 @@ export interface TokenResponse {
export interface User { export interface User {
id: string id: string
username: string username: string
is_superuser: boolean
created_at: string 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 type SchoolLevel = 'junior_high' | 'senior_high'
export interface Student { export interface Student {
@@ -18,6 +51,14 @@ export interface Student {
school_level: SchoolLevel school_level: SchoolLevel
grade: string | null grade: string | null
class_name: string | null class_name: string | null
school_name: string | null
has_avatar: boolean
created_at: string
}
export interface BackupInfo {
filename: string
size_bytes: number
created_at: string created_at: string
} }
@@ -33,6 +74,31 @@ export interface Score {
total_score: number total_score: number
obtained_score: number obtained_score: number
ratio: 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' export type ExamType = 'weekly' | 'monthly' | 'final'
@@ -50,6 +116,7 @@ export interface ScoreInput {
subject_id: number subject_id: number
total_score: number total_score: number
obtained_score: number obtained_score: number
review_statuses?: ReviewStatus[]
} }
export interface TrendPoint { export interface TrendPoint {
@@ -79,14 +146,28 @@ export interface WrongQuestion {
student_id: string student_id: string
subject_id: number subject_id: number
subject_name?: string subject_name?: string
category: WrongQuestionCategory
image_path: string image_path: string
ocr_raw_text: string | null ocr_raw_text: string | null
question_text: string | null question_text: string | null
solution_approach: string | null
solution_text: string | null solution_text: string | null
mark_regions: MarkRegion[] | null
has_annotated_image: boolean
has_cropped_image: boolean
error_message: string | null
status: WrongQuestionStatus status: WrongQuestionStatus
created_at: string 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> = { export const EXAM_TYPE_LABELS: Record<ExamType, string> = {
weekly: '周考', weekly: '周考',
monthly: '月考', monthly: '月考',
@@ -99,3 +180,26 @@ export const STATUS_LABELS: Record<WrongQuestionStatus, string> = {
solved: '已生成解法', solved: '已生成解法',
failed: '失败', 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: '失败',
}
+53
View File
@@ -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' })
}
+18
View File
@@ -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 '正在识别文字(约 1030 秒)…'
}
if (wq.status === 'ocr_done') {
return '正在标注错题并生成解题思路(约 30–90 秒)…'
}
return '正在识别、标注并生成解题思路…'
}
+1 -1
View File
@@ -7,7 +7,7 @@ export default defineConfig({
port: 5173, port: 5173,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:23568', target: 'http://localhost:23566',
changeOrigin: true, changeOrigin: true,
}, },
}, },