新增作文区与 AI 解读开关,修复 CSV 导出。
系统设置可关闭成绩复盘 AI;学生详情增加作文区(OCR/手动题目、方案与范文、历史与 MD 下载);导出改用 UTF-8 文件名响应。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+2
-1
@@ -8,7 +8,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import Base, SessionLocal, engine
|
from app.core.database import Base, SessionLocal, engine
|
||||||
from app.routers import admin, auth, exams, export, settings as settings_router, students, subjects, wrong_questions
|
from app.routers import admin, auth, compositions, exams, export, settings as settings_router, students, subjects, wrong_questions
|
||||||
from app.services import ocr as ocr_service
|
from app.services import ocr as ocr_service
|
||||||
from app.services.migrate import run_migrations
|
from app.services.migrate import run_migrations
|
||||||
from app.services.seed import seed_admin_and_settings, seed_subjects
|
from app.services.seed import seed_admin_and_settings, seed_subjects
|
||||||
@@ -57,6 +57,7 @@ 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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,18 @@ class WrongQuestionCategory(str, enum.Enum):
|
|||||||
olympiad = "olympiad"
|
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):
|
class AIProvider(str, enum.Enum):
|
||||||
ollama = "ollama"
|
ollama = "ollama"
|
||||||
openai = "openai"
|
openai = "openai"
|
||||||
@@ -73,6 +85,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):
|
||||||
@@ -150,11 +165,37 @@ class WrongQuestion(Base):
|
|||||||
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):
|
class SystemSettings(Base):
|
||||||
__tablename__ = "system_settings"
|
__tablename__ = "system_settings"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
||||||
registration_enabled: Mapped[bool] = mapped_column(default=True)
|
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")
|
ai_provider: Mapped[str] = mapped_column(String(16), default="ollama")
|
||||||
ollama_base_url: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
ollama_base_url: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||||
ollama_model: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
ollama_model: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ router = APIRouter(prefix="/admin", tags=["admin"])
|
|||||||
def settings_to_out(row: SystemSettings) -> SystemSettingsOut:
|
def settings_to_out(row: SystemSettings) -> SystemSettingsOut:
|
||||||
return SystemSettingsOut(
|
return SystemSettingsOut(
|
||||||
registration_enabled=row.registration_enabled,
|
registration_enabled=row.registration_enabled,
|
||||||
|
ai_review_enabled=getattr(row, "ai_review_enabled", True),
|
||||||
ai_provider=AIProviderEnum(row.ai_provider or "ollama"),
|
ai_provider=AIProviderEnum(row.ai_provider or "ollama"),
|
||||||
ollama_base_url=row.ollama_base_url,
|
ollama_base_url=row.ollama_base_url,
|
||||||
ollama_model=row.ollama_model,
|
ollama_model=row.ollama_model,
|
||||||
@@ -64,6 +65,8 @@ def update_settings(
|
|||||||
row = get_or_create_settings(db)
|
row = get_or_create_settings(db)
|
||||||
if data.registration_enabled is not None:
|
if data.registration_enabled is not None:
|
||||||
row.registration_enabled = data.registration_enabled
|
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:
|
if data.ai_provider is not None:
|
||||||
row.ai_provider = data.ai_provider.value
|
row.ai_provider = data.ai_provider.value
|
||||||
if data.ollama_base_url is not None:
|
if data.ollama_base_url is not None:
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -6,7 +6,7 @@ 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 (
|
from app.schemas import (
|
||||||
ExamCreate,
|
ExamCreate,
|
||||||
ExamOut,
|
ExamOut,
|
||||||
@@ -322,6 +322,10 @@ async def review_insight(
|
|||||||
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)
|
||||||
|
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 = (
|
exams = (
|
||||||
db.query(ExamRecord)
|
db.query(ExamRecord)
|
||||||
.options(joinedload(ExamRecord.scores).joinedload(SubjectScore.subject))
|
.options(joinedload(ExamRecord.scores).joinedload(SubjectScore.subject))
|
||||||
|
|||||||
@@ -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}'
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ from fastapi import APIRouter, Depends
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.models.user import SystemSettings
|
from app.core.deps import get_current_user
|
||||||
from app.schemas import PublicSettingsOut
|
from app.models.user import SystemSettings, User
|
||||||
|
from app.schemas import AppFeaturesOut, PublicSettingsOut
|
||||||
|
|
||||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||||
|
|
||||||
@@ -22,3 +23,12 @@ def get_or_create_settings(db: Session) -> SystemSettings:
|
|||||||
def public_settings(db: Session = Depends(get_db)):
|
def public_settings(db: Session = Depends(get_db)):
|
||||||
row = get_or_create_settings(db)
|
row = get_or_create_settings(db)
|
||||||
return PublicSettingsOut(registration_enabled=row.registration_enabled)
|
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))
|
||||||
|
|||||||
@@ -76,8 +76,13 @@ class PublicSettingsOut(BaseModel):
|
|||||||
registration_enabled: bool
|
registration_enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
class AppFeaturesOut(BaseModel):
|
||||||
|
ai_review_enabled: bool
|
||||||
|
|
||||||
|
|
||||||
class SystemSettingsOut(BaseModel):
|
class SystemSettingsOut(BaseModel):
|
||||||
registration_enabled: bool
|
registration_enabled: bool
|
||||||
|
ai_review_enabled: bool = True
|
||||||
ai_provider: AIProviderEnum
|
ai_provider: AIProviderEnum
|
||||||
ollama_base_url: str | None = None
|
ollama_base_url: str | None = None
|
||||||
ollama_model: str | None = None
|
ollama_model: str | None = None
|
||||||
@@ -92,6 +97,7 @@ class SystemSettingsOut(BaseModel):
|
|||||||
|
|
||||||
class SystemSettingsUpdate(BaseModel):
|
class SystemSettingsUpdate(BaseModel):
|
||||||
registration_enabled: bool | None = None
|
registration_enabled: bool | None = None
|
||||||
|
ai_review_enabled: bool | None = None
|
||||||
ai_provider: AIProviderEnum | None = None
|
ai_provider: AIProviderEnum | None = None
|
||||||
ollama_base_url: str | None = None
|
ollama_base_url: str | None = None
|
||||||
ollama_model: str | None = None
|
ollama_model: str | None = None
|
||||||
@@ -293,3 +299,39 @@ class WrongQuestionUpdate(BaseModel):
|
|||||||
solution_approach: 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}
|
||||||
|
|||||||
@@ -306,4 +306,81 @@ async def generate_review_insight(
|
|||||||
careless_hint=careless_hint,
|
careless_hint=careless_hint,
|
||||||
subject_hints=subject_hints,
|
subject_hints=subject_hints,
|
||||||
)
|
)
|
||||||
return await generate_text(prompt, cfg, temperature=0.2)
|
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"
|
||||||
@@ -91,3 +91,13 @@ def run_migrations() -> None:
|
|||||||
if "review_statuses_json" not in ss_columns:
|
if "review_statuses_json" not in ss_columns:
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
conn.execute(text("ALTER TABLE subject_scores ADD COLUMN review_statuses_json TEXT"))
|
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"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
+518
File diff suppressed because one or more lines are too long
-518
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
<meta name="author" content="马建军" />
|
<meta name="author" content="马建军" />
|
||||||
<meta name="copyright" content="Copyright (c) 马建军. All rights reserved." />
|
<meta name="copyright" content="Copyright (c) 马建军. All rights reserved." />
|
||||||
<title>中学成绩档案</title>
|
<title>中学成绩档案</title>
|
||||||
<script type="module" crossorigin src="/assets/index-CmdQeYPX.js"></script>
|
<script type="module" crossorigin src="/assets/index-BFUIx7uW.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BSTLtBBx.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BSTLtBBx.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import axios from 'axios'
|
|||||||
import type {
|
import type {
|
||||||
AdminUser,
|
AdminUser,
|
||||||
AIProvider,
|
AIProvider,
|
||||||
|
AppFeatures,
|
||||||
|
Composition,
|
||||||
|
CompositionInputMode,
|
||||||
Exam,
|
Exam,
|
||||||
PublicSettings,
|
PublicSettings,
|
||||||
ScoreInput,
|
ScoreInput,
|
||||||
@@ -66,12 +69,14 @@ export const authApi = {
|
|||||||
|
|
||||||
export const settingsApi = {
|
export const settingsApi = {
|
||||||
public: () => api.get<PublicSettings>('/settings/public'),
|
public: () => api.get<PublicSettings>('/settings/public'),
|
||||||
|
appFeatures: () => api.get<AppFeatures>('/settings/app-features'),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const adminApi = {
|
export const adminApi = {
|
||||||
getSettings: () => api.get<SystemSettings>('/admin/settings'),
|
getSettings: () => api.get<SystemSettings>('/admin/settings'),
|
||||||
updateSettings: (data: {
|
updateSettings: (data: {
|
||||||
registration_enabled?: boolean
|
registration_enabled?: boolean
|
||||||
|
ai_review_enabled?: boolean
|
||||||
ai_provider?: AIProvider
|
ai_provider?: AIProvider
|
||||||
ollama_base_url?: string | null
|
ollama_base_url?: string | null
|
||||||
ollama_model?: string | null
|
ollama_model?: string | null
|
||||||
@@ -130,13 +135,32 @@ export const examApi = {
|
|||||||
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) =>
|
reviewInsight: (studentId: string, subjectName: string) =>
|
||||||
api.post<{ insight: string }>(`/students/${studentId}/review-insight`, {
|
api.post<{ insight: string }>(`/students/${studentId}/review-insight`, {
|
||||||
subject_name: subjectName,
|
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; category?: WrongQuestionCategory }) =>
|
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 }),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,21 +1,33 @@
|
|||||||
import { ReloadOutlined } from '@ant-design/icons'
|
|
||||||
import { Alert, Button, Spin, Typography } from 'antd'
|
import { Alert, Button, Spin, Typography } from 'antd'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import { examApi } from '../api/client'
|
import { examApi, settingsApi } from '../api/client'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
studentId: string
|
studentId: string
|
||||||
subjectName: string | null
|
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) {
|
export default function ReviewAiInsight({ studentId, subjectName }: Props) {
|
||||||
|
const [enabled, setEnabled] = useState(true)
|
||||||
const [insight, setInsight] = useState<string | null>(null)
|
const [insight, setInsight] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
settingsApi.appFeatures().then(({ data }) => setEnabled(data.ai_review_enabled)).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const loadInsight = useCallback(async () => {
|
const loadInsight = useCallback(async () => {
|
||||||
if (!subjectName) {
|
if (!subjectName || !enabled) {
|
||||||
setInsight(null)
|
setInsight(null)
|
||||||
setError(null)
|
setError(null)
|
||||||
return
|
return
|
||||||
@@ -25,17 +37,13 @@ export default function ReviewAiInsight({ studentId, subjectName }: Props) {
|
|||||||
try {
|
try {
|
||||||
const { data } = await examApi.reviewInsight(studentId, subjectName)
|
const { data } = await examApi.reviewInsight(studentId, subjectName)
|
||||||
setInsight(data.insight)
|
setInsight(data.insight)
|
||||||
} catch (err: unknown) {
|
} catch (err) {
|
||||||
const detail =
|
setError(apiErrorMessage(err, 'AI 解读生成失败,请检查模型配置'))
|
||||||
err && typeof err === 'object' && 'response' in err
|
|
||||||
? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
|
||||||
: null
|
|
||||||
setError(typeof detail === 'string' ? detail : 'AI 解读生成失败,请检查模型配置')
|
|
||||||
setInsight(null)
|
setInsight(null)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [studentId, subjectName])
|
}, [studentId, subjectName, enabled])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadInsight()
|
loadInsight()
|
||||||
@@ -43,18 +51,24 @@ export default function ReviewAiInsight({ studentId, subjectName }: Props) {
|
|||||||
|
|
||||||
if (!subjectName) return null
|
if (!subjectName) return null
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
style={{ marginTop: 20 }}
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message="AI 复盘解读已在系统设置中关闭"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginTop: 20 }}>
|
<div style={{ marginTop: 20 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||||||
<Typography.Text strong style={{ fontSize: 15 }}>
|
<Typography.Text strong style={{ fontSize: 15 }}>
|
||||||
AI 解读与建议 · {subjectName}
|
AI 解读与建议 · {subjectName}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<Button
|
<Button size="small" loading={loading} onClick={loadInsight}>
|
||||||
size="small"
|
|
||||||
icon={<ReloadOutlined />}
|
|
||||||
loading={loading}
|
|
||||||
onClick={loadInsight}
|
|
||||||
>
|
|
||||||
重新生成
|
重新生成
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,9 +77,7 @@ export default function ReviewAiInsight({ studentId, subjectName }: Props) {
|
|||||||
<Spin tip="AI 正在分析复盘数据…" />
|
<Spin tip="AI 正在分析复盘数据…" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{error && <Alert type="error" message={error} showIcon style={{ marginBottom: 12 }} />}
|
||||||
<Alert type="error" message={error} showIcon style={{ marginBottom: 12 }} />
|
|
||||||
)}
|
|
||||||
{insight && (
|
{insight && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ export default function SettingsPage() {
|
|||||||
message.success(checked ? '已开放注册' : '已关闭注册')
|
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: {
|
const saveProfile = async (values: {
|
||||||
username: string
|
username: string
|
||||||
current_password?: string
|
current_password?: string
|
||||||
@@ -247,6 +253,19 @@ export default function SettingsPage() {
|
|||||||
<Typography.Paragraph type="secondary">
|
<Typography.Paragraph type="secondary">
|
||||||
错题/奥数解法将按学生学段(初中/高中)生成,并严格禁止超纲解题。
|
错题/奥数解法将按学生学段(初中/高中)生成,并严格禁止超纲解题。
|
||||||
</Typography.Paragraph>
|
</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">
|
<Button type="primary" htmlType="submit">
|
||||||
保存 AI 配置
|
保存 AI 配置
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ import TrendChart from '../components/TrendChart'
|
|||||||
import WrongQuestionList from '../components/WrongQuestionList'
|
import WrongQuestionList from '../components/WrongQuestionList'
|
||||||
import WrongQuestionUpload, { WrongQuestionFilters } from '../components/WrongQuestionUpload'
|
import WrongQuestionUpload, { WrongQuestionFilters } from '../components/WrongQuestionUpload'
|
||||||
import ExamReviewPanel from '../components/ExamReviewPanel'
|
import ExamReviewPanel from '../components/ExamReviewPanel'
|
||||||
|
import CompositionPanel from '../components/CompositionPanel'
|
||||||
import { formatStudentMeta, SCHOOL_LEVEL_LABELS } from '../constants/school'
|
import { formatStudentMeta, SCHOOL_LEVEL_LABELS } from '../constants/school'
|
||||||
import type { Exam, Student, Subject, TrendResponse, WrongQuestion } from '../types'
|
import type { Exam, Student, Subject, TrendResponse, WrongQuestion } from '../types'
|
||||||
|
|
||||||
const TAB_KEYS = ['scores', 'overview', 'trend', 'review', 'wrong', 'olympiad'] as const
|
const TAB_KEYS = ['scores', 'overview', 'trend', 'review', 'composition', 'wrong', 'olympiad'] as const
|
||||||
type TabKey = (typeof TAB_KEYS)[number]
|
type TabKey = (typeof TAB_KEYS)[number]
|
||||||
|
|
||||||
export default function StudentDetailPage() {
|
export default function StudentDetailPage() {
|
||||||
@@ -114,11 +115,30 @@ export default function StudentDetailPage() {
|
|||||||
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 {
|
||||||
@@ -198,6 +218,11 @@ export default function StudentDetailPage() {
|
|||||||
label: '成绩复盘',
|
label: '成绩复盘',
|
||||||
children: <ExamReviewPanel studentId={id!} exams={exams} onRefresh={loadExams} />,
|
children: <ExamReviewPanel studentId={id!} exams={exams} onRefresh={loadExams} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'composition',
|
||||||
|
label: '作文区',
|
||||||
|
children: <CompositionPanel studentId={id!} student={student} />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'wrong',
|
key: 'wrong',
|
||||||
label: '错题库',
|
label: '错题库',
|
||||||
|
|||||||
@@ -15,8 +15,13 @@ export interface PublicSettings {
|
|||||||
registration_enabled: boolean
|
registration_enabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AppFeatures {
|
||||||
|
ai_review_enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface SystemSettings {
|
export interface SystemSettings {
|
||||||
registration_enabled: boolean
|
registration_enabled: boolean
|
||||||
|
ai_review_enabled: boolean
|
||||||
ai_provider: AIProvider
|
ai_provider: AIProvider
|
||||||
ollama_base_url: string | null
|
ollama_base_url: string | null
|
||||||
ollama_model: string | null
|
ollama_model: string | null
|
||||||
@@ -167,3 +172,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: '失败',
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user