新增作文区与 AI 解读开关,修复 CSV 导出。

系统设置可关闭成绩复盘 AI;学生详情增加作文区(OCR/手动题目、方案与范文、历史与 MD 下载);导出改用 UTF-8 文件名响应。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-28 17:42:17 +08:00
parent aaa08cdf38
commit 1cb3c7fad5
20 changed files with 1441 additions and 555 deletions
+2 -1
View File
@@ -8,7 +8,7 @@ from fastapi.staticfiles import StaticFiles
from app.core.config import settings
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.migrate import run_migrations
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(exams.router, prefix="/api")
app.include_router(wrong_questions.router, prefix="/api")
app.include_router(compositions.router, prefix="/api")
app.include_router(export.router, prefix="/api")
+41
View File
@@ -27,6 +27,18 @@ class WrongQuestionCategory(str, enum.Enum):
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"
@@ -73,6 +85,9 @@ class Student(Base):
wrong_questions: Mapped[list["WrongQuestion"]] = relationship(
back_populates="student", cascade="all, delete-orphan"
)
compositions: Mapped[list["Composition"]] = relationship(
back_populates="student", cascade="all, delete-orphan"
)
class Subject(Base):
@@ -150,11 +165,37 @@ class WrongQuestion(Base):
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)
+3
View File
@@ -26,6 +26,7 @@ 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,
@@ -64,6 +65,8 @@ def update_settings(
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:
+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()
+5 -1
View File
@@ -6,7 +6,7 @@ from sqlalchemy.orm import Session, joinedload
from app.core.database import get_db
from app.core.deps import get_current_user
from app.models.user import ExamRecord, SubjectScore, User
from app.models.user import ExamRecord, SubjectScore, SystemSettings, User
from app.schemas import (
ExamCreate,
ExamOut,
@@ -322,6 +322,10 @@ async def review_insight(
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))
+12 -7
View File
@@ -1,9 +1,10 @@
import csv
import io
import uuid
from urllib.parse import quote
from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
from fastapi.responses import Response
from sqlalchemy.orm import Session, joinedload
from app.core.database import get_db
@@ -34,10 +35,11 @@ def export_scores_csv(
writer.writerow(["考试日期", "考试类型", "标题", "科目", "总分", "得分", "占比"])
type_map = {"weekly": "周考", "monthly": "月考", "final": "期末"}
for exam in exams:
exam_type = exam.exam_type.value if hasattr(exam.exam_type, "value") else str(exam.exam_type)
for score in exam.scores:
writer.writerow([
exam.exam_date.isoformat(),
type_map.get(exam.exam_type.value, exam.exam_type.value),
type_map.get(exam_type, exam_type),
exam.title or "",
score.subject.name if score.subject else "",
float(score.total_score),
@@ -45,10 +47,13 @@ def export_scores_csv(
f"{float(score.ratio) * 100:.2f}%",
])
output.seek(0)
content = output.getvalue().encode("utf-8-sig")
filename = f"{student.name}_scores.csv"
return StreamingResponse(
iter([output.getvalue().encode("utf-8-sig")]),
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
encoded = quote(filename)
return Response(
content=content,
media_type="text/csv; charset=utf-8",
headers={
"Content-Disposition": f'attachment; filename="scores.csv"; filename*=UTF-8\'\'{encoded}'
},
)
+12 -2
View File
@@ -2,8 +2,9 @@ from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.models.user import SystemSettings
from app.schemas import PublicSettingsOut
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"])
@@ -22,3 +23,12 @@ def get_or_create_settings(db: Session) -> SystemSettings:
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))
+42
View File
@@ -76,8 +76,13 @@ 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
@@ -92,6 +97,7 @@ class SystemSettingsOut(BaseModel):
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
@@ -293,3 +299,39 @@ class WrongQuestionUpdate(BaseModel):
solution_approach: str | None = None
solution_text: str | None = None
subject_id: int | None = None
class CompositionStatusEnum(str, Enum):
pending = "pending"
generating = "generating"
done = "done"
failed = "failed"
class CompositionInputModeEnum(str, Enum):
manual = "manual"
ocr = "ocr"
class CompositionCreate(BaseModel):
topic: str = Field(..., min_length=1, max_length=4000)
input_mode: CompositionInputModeEnum = CompositionInputModeEnum.manual
class CompositionOcrOut(BaseModel):
text: str
class CompositionOut(BaseModel):
id: UUID
student_id: UUID
topic: str
input_mode: CompositionInputModeEnum
writing_plan: str | None = None
sample_essay: str | None = None
error_message: str | None = None
status: CompositionStatusEnum
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
+78 -1
View File
@@ -306,4 +306,81 @@ async def generate_review_insight(
careless_hint=careless_hint,
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"
+10
View File
@@ -91,3 +91,13 @@ def run_migrations() -> None:
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"
)
)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -9,7 +9,7 @@
<meta name="author" content="马建军" />
<meta name="copyright" content="Copyright (c) 马建军. All rights reserved." />
<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">
</head>
<body>
+25 -1
View File
@@ -2,6 +2,9 @@ import axios from 'axios'
import type {
AdminUser,
AIProvider,
AppFeatures,
Composition,
CompositionInputMode,
Exam,
PublicSettings,
ScoreInput,
@@ -66,12 +69,14 @@ export const authApi = {
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
@@ -130,13 +135,32 @@ export const examApi = {
params: { subject_id: subjectId },
}),
exportCsv: (studentId: string) =>
api.get(`/students/${studentId}/scores/export`, { responseType: 'blob' }),
api.get<Blob>(`/students/${studentId}/scores/export`, {
responseType: 'blob',
validateStatus: (status) => status >= 200 && status < 300,
}),
reviewInsight: (studentId: string, subjectName: string) =>
api.post<{ insight: string }>(`/students/${studentId}/review-insight`, {
subject_name: subjectName,
}),
}
export const compositionApi = {
list: (studentId: string) => api.get<Composition[]>(`/students/${studentId}/compositions`),
create: (studentId: string, data: { topic: string; input_mode?: CompositionInputMode }) =>
api.post<Composition>(`/students/${studentId}/compositions`, data),
get: (id: string) => api.get<Composition>(`/compositions/${id}`),
remove: (id: string) => api.delete(`/compositions/${id}`),
regenerate: (id: string) => api.post<Composition>(`/compositions/${id}/regenerate`),
ocr: (studentId: string, file: File) => {
const form = new FormData()
form.append('file', file)
return api.post<{ text: string }>(`/students/${studentId}/compositions/ocr`, form)
},
download: (id: string) =>
api.get(`/compositions/${id}/download`, { responseType: 'blob' }),
}
export const wrongQuestionApi = {
list: (studentId: string, params?: { subject_id?: number; q?: string; category?: WrongQuestionCategory }) =>
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>
)
}
+31 -19
View File
@@ -1,21 +1,33 @@
import { ReloadOutlined } from '@ant-design/icons'
import { Alert, Button, Spin, Typography } from 'antd'
import { useCallback, useEffect, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import { examApi } from '../api/client'
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) {
if (!subjectName || !enabled) {
setInsight(null)
setError(null)
return
@@ -25,17 +37,13 @@ export default function ReviewAiInsight({ studentId, subjectName }: Props) {
try {
const { data } = await examApi.reviewInsight(studentId, subjectName)
setInsight(data.insight)
} catch (err: unknown) {
const detail =
err && typeof err === 'object' && 'response' in err
? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
: null
setError(typeof detail === 'string' ? detail : 'AI 解读生成失败,请检查模型配置')
} catch (err) {
setError(apiErrorMessage(err, 'AI 解读生成失败,请检查模型配置'))
setInsight(null)
} finally {
setLoading(false)
}
}, [studentId, subjectName])
}, [studentId, subjectName, enabled])
useEffect(() => {
loadInsight()
@@ -43,18 +51,24 @@ export default function ReviewAiInsight({ studentId, subjectName }: Props) {
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"
icon={<ReloadOutlined />}
loading={loading}
onClick={loadInsight}
>
<Button size="small" loading={loading} onClick={loadInsight}>
</Button>
</div>
@@ -63,9 +77,7 @@ export default function ReviewAiInsight({ studentId, subjectName }: Props) {
<Spin tip="AI 正在分析复盘数据…" />
</div>
)}
{error && (
<Alert type="error" message={error} showIcon style={{ marginBottom: 12 }} />
)}
{error && <Alert type="error" message={error} showIcon style={{ marginBottom: 12 }} />}
{insight && (
<div
style={{
+19
View File
@@ -69,6 +69,12 @@ export default function SettingsPage() {
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
@@ -247,6 +253,19 @@ export default function SettingsPage() {
<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>
+29 -4
View File
@@ -9,10 +9,11 @@ import TrendChart from '../components/TrendChart'
import WrongQuestionList from '../components/WrongQuestionList'
import WrongQuestionUpload, { WrongQuestionFilters } from '../components/WrongQuestionUpload'
import ExamReviewPanel from '../components/ExamReviewPanel'
import CompositionPanel from '../components/CompositionPanel'
import { formatStudentMeta, SCHOOL_LEVEL_LABELS } from '../constants/school'
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]
export default function StudentDetailPage() {
@@ -114,11 +115,30 @@ export default function StudentDetailPage() {
const handleExport = async () => {
if (!id) return
try {
const { data } = await examApi.exportCsv(id)
const url = URL.createObjectURL(data)
const res = await examApi.exportCsv(id)
const blob = res.data
if (blob.type?.includes('application/json')) {
const text = await blob.text()
try {
const err = JSON.parse(text) as { detail?: string }
message.error(err.detail || '导出失败')
} catch {
message.error('导出失败')
}
return
}
const disposition = res.headers['content-disposition'] as string | undefined
let filename = `${student?.name || 'student'}_scores.csv`
if (disposition) {
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i)
const plainMatch = disposition.match(/filename="?([^";]+)"?/i)
if (utf8Match?.[1]) filename = decodeURIComponent(utf8Match[1])
else if (plainMatch?.[1]) filename = plainMatch[1]
}
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${student?.name || 'student'}_scores.csv`
a.download = filename
a.click()
URL.revokeObjectURL(url)
} catch {
@@ -198,6 +218,11 @@ export default function StudentDetailPage() {
label: '成绩复盘',
children: <ExamReviewPanel studentId={id!} exams={exams} onRefresh={loadExams} />,
},
{
key: 'composition',
label: '作文区',
children: <CompositionPanel studentId={id!} student={student} />,
},
{
key: 'wrong',
label: '错题库',
+28
View File
@@ -15,8 +15,13 @@ 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
@@ -167,3 +172,26 @@ export const STATUS_LABELS: Record<WrongQuestionStatus, string> = {
solved: '已生成解法',
failed: '失败',
}
export type CompositionStatus = 'pending' | 'generating' | 'done' | 'failed'
export type CompositionInputMode = 'manual' | 'ocr'
export interface Composition {
id: string
student_id: string
topic: string
input_mode: CompositionInputMode
writing_plan: string | null
sample_essay: string | null
error_message: string | null
status: CompositionStatus
created_at: string
updated_at: string
}
export const COMPOSITION_STATUS_LABELS: Record<CompositionStatus, string> = {
pending: '等待生成',
generating: '生成中',
done: '已完成',
failed: '失败',
}