移动端拍照上传、奥数区、学段约束解题与 AI 模型配置。

- 手机/平板响应式布局,支持拍照与相册上传

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

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

- 更新 frontend/dist 与使用说明
This commit is contained in:
dekun
2026-06-28 13:39:54 +08:00
parent 4375ea491e
commit 43483bf56f
26 changed files with 1193 additions and 592 deletions
+2
View File
@@ -16,6 +16,8 @@ CORS_ORIGINS=http://127.0.0.1:23566,http://localhost:23566
OLLAMA_BASE_URL=http://127.0.0.1:11434
OLLAMA_MODEL=qwen2.5:7b
OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_MODEL=gpt-4o-mini
FLUCTUATION_THRESHOLD=0.08
ADMIN_DEFAULT_USERNAME=admin
+2
View File
@@ -11,6 +11,8 @@ class Settings(BaseSettings):
MAX_UPLOAD_SIZE: int = 10 * 1024 * 1024
OLLAMA_BASE_URL: str = "http://127.0.0.1:11434"
OLLAMA_MODEL: str = "qwen2.5:7b"
OPENAI_BASE_URL: str = "https://api.openai.com/v1"
OPENAI_MODEL: str = "gpt-4o-mini"
FLUCTUATION_THRESHOLD: float = 0.08
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:23566,http://localhost"
WEB_PORT: int = 23566
+19
View File
@@ -22,6 +22,16 @@ class WrongQuestionStatus(str, enum.Enum):
failed = "failed"
class WrongQuestionCategory(str, enum.Enum):
regular = "regular"
olympiad = "olympiad"
class AIProvider(str, enum.Enum):
ollama = "ollama"
openai = "openai"
class SchoolLevel(str, enum.Enum):
junior_high = "junior_high"
senior_high = "senior_high"
@@ -123,6 +133,9 @@ class WrongQuestion(Base):
status: Mapped[WrongQuestionStatus] = mapped_column(
Enum(WrongQuestionStatus), default=WrongQuestionStatus.pending
)
category: Mapped[WrongQuestionCategory] = mapped_column(
Enum(WrongQuestionCategory), default=WrongQuestionCategory.regular
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
@@ -136,6 +149,12 @@ class SystemSettings(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
registration_enabled: Mapped[bool] = mapped_column(default=True)
ai_provider: Mapped[str] = mapped_column(String(16), default="ollama")
ollama_base_url: Mapped[str | None] = mapped_column(String(256), nullable=True)
ollama_model: Mapped[str | None] = mapped_column(String(128), nullable=True)
openai_base_url: Mapped[str | None] = mapped_column(String(256), nullable=True)
openai_model: Mapped[str | None] = mapped_column(String(128), nullable=True)
openai_api_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
+28 -2
View File
@@ -13,6 +13,7 @@ from app.schemas import (
AdminUserCreate,
AdminUserOut,
AdminUserPasswordUpdate,
AIProviderEnum,
PublicSettingsOut,
SystemSettingsOut,
SystemSettingsUpdate,
@@ -21,6 +22,19 @@ from app.schemas import (
router = APIRouter(prefix="/admin", tags=["admin"])
def settings_to_out(row: SystemSettings) -> SystemSettingsOut:
return SystemSettingsOut(
registration_enabled=row.registration_enabled,
ai_provider=AIProviderEnum(row.ai_provider or "ollama"),
ollama_base_url=row.ollama_base_url,
ollama_model=row.ollama_model,
openai_base_url=row.openai_base_url,
openai_model=row.openai_model,
openai_api_key_set=bool(row.openai_api_key),
updated_at=row.updated_at,
)
def get_or_create_settings(db: Session) -> SystemSettings:
row = db.get(SystemSettings, 1)
if row is None:
@@ -36,7 +50,7 @@ def get_settings(
db: Session = Depends(get_db),
_: User = Depends(get_superuser),
):
return get_or_create_settings(db)
return settings_to_out(get_or_create_settings(db))
@router.patch("/settings", response_model=SystemSettingsOut)
@@ -48,10 +62,22 @@ 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_provider is not None:
row.ai_provider = data.ai_provider.value
if data.ollama_base_url is not None:
row.ollama_base_url = data.ollama_base_url or None
if data.ollama_model is not None:
row.ollama_model = data.ollama_model or None
if data.openai_base_url is not None:
row.openai_base_url = data.openai_base_url or None
if data.openai_model is not None:
row.openai_model = data.openai_model or None
if data.openai_api_key is not None and data.openai_api_key.strip():
row.openai_api_key = data.openai_api_key.strip()
row.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(row)
return row
return settings_to_out(row)
@router.patch("/profile", response_model=AdminUserOut)
+31 -10
View File
@@ -8,10 +8,10 @@ from sqlalchemy.orm import Session, joinedload
from app.core.config import settings
from app.core.database import SessionLocal, get_db
from app.core.deps import get_current_user
from app.models.user import Subject, User, WrongQuestion, WrongQuestionStatus
from app.schemas import WrongQuestionOut, WrongQuestionUpdate
from app.models.user import Subject, User, WrongQuestion, WrongQuestionCategory, WrongQuestionStatus
from app.schemas import WrongQuestionCategoryEnum, WrongQuestionOut, WrongQuestionUpdate
from app.services import llm as llm_service
from app.services import ocr as ocr_service
from app.services import ollama as ollama_service
from app.services.student_access import get_student_for_user
router = APIRouter(tags=["wrong_questions"])
@@ -23,6 +23,7 @@ def _wq_to_out(wq: WrongQuestion) -> WrongQuestionOut:
student_id=wq.student_id,
subject_id=wq.subject_id,
subject_name=wq.subject.name if wq.subject else None,
category=wq.category,
image_path=wq.image_path,
ocr_raw_text=wq.ocr_raw_text,
question_text=wq.question_text,
@@ -60,16 +61,25 @@ def _process_wrong_question(question_id: uuid.UUID):
subject_name = wq.subject.name if wq.subject else "综合"
school_level = wq.student.school_level if wq.student else None
olympiad = wq.category == WrongQuestionCategory.olympiad
ai_cfg = llm_service.load_ai_config(db)
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
question_text = loop.run_until_complete(
ollama_service.format_question(subject_name, ocr_text, school_level)
llm_service.format_question(ai_cfg, subject_name, ocr_text, school_level)
)
solution_text = loop.run_until_complete(
ollama_service.generate_solution(subject_name, question_text, school_level)
llm_service.generate_solution(
ai_cfg,
subject_name,
question_text,
school_level,
olympiad=olympiad,
)
)
wq.question_text = question_text
wq.solution_text = solution_text
@@ -88,6 +98,7 @@ def _process_wrong_question(question_id: uuid.UUID):
def list_wrong_questions(
student_id: uuid.UUID,
subject_id: int | None = Query(None),
category: WrongQuestionCategoryEnum | None = Query(None),
q: str | None = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
@@ -100,6 +111,8 @@ def list_wrong_questions(
)
if subject_id is not None:
query = query.filter(WrongQuestion.subject_id == subject_id)
if category is not None:
query = query.filter(WrongQuestion.category == category.value)
if q:
pattern = f"%{q}%"
query = query.filter(
@@ -121,6 +134,7 @@ async def upload_wrong_question(
background_tasks: BackgroundTasks,
subject_id: int = Form(...),
file: UploadFile = File(...),
category: WrongQuestionCategoryEnum = Form(WrongQuestionCategoryEnum.regular),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -137,6 +151,7 @@ async def upload_wrong_question(
student_id=student_id,
subject_id=subject_id,
image_path="",
category=WrongQuestionCategory(category.value),
status=WrongQuestionStatus.pending,
)
db.add(wq)
@@ -270,21 +285,27 @@ async def regenerate_solution(
subject_name = wq.subject.name if wq.subject else "综合"
school_level = wq.student.school_level if wq.student else None
olympiad = wq.category == WrongQuestionCategory.olympiad
question_text = wq.question_text or wq.ocr_raw_text or ""
ai_cfg = llm_service.load_ai_config(db)
try:
if not wq.question_text and wq.ocr_raw_text:
wq.question_text = await ollama_service.format_question(
subject_name, wq.ocr_raw_text, school_level
wq.question_text = await llm_service.format_question(
ai_cfg, subject_name, wq.ocr_raw_text, school_level
)
question_text = wq.question_text
wq.solution_text = await ollama_service.generate_solution(
subject_name, question_text, school_level
wq.solution_text = await llm_service.generate_solution(
ai_cfg,
subject_name,
question_text,
school_level,
olympiad=olympiad,
)
wq.status = WrongQuestionStatus.solved
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Ollama 调用失败: {exc}"
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"AI 调用失败: {exc}"
) from exc
db.commit()
+23
View File
@@ -18,6 +18,16 @@ class WrongQuestionStatusEnum(str, Enum):
failed = "failed"
class WrongQuestionCategoryEnum(str, Enum):
regular = "regular"
olympiad = "olympiad"
class AIProviderEnum(str, Enum):
ollama = "ollama"
openai = "openai"
class SchoolLevelEnum(str, Enum):
junior_high = "junior_high"
senior_high = "senior_high"
@@ -58,6 +68,12 @@ class PublicSettingsOut(BaseModel):
class SystemSettingsOut(BaseModel):
registration_enabled: bool
ai_provider: AIProviderEnum
ollama_base_url: str | None = None
ollama_model: str | None = None
openai_base_url: str | None = None
openai_model: str | None = None
openai_api_key_set: bool = False
updated_at: datetime
model_config = {"from_attributes": True}
@@ -65,6 +81,12 @@ class SystemSettingsOut(BaseModel):
class SystemSettingsUpdate(BaseModel):
registration_enabled: bool | None = None
ai_provider: AIProviderEnum | None = None
ollama_base_url: str | None = None
ollama_model: str | None = None
openai_base_url: str | None = None
openai_model: str | None = None
openai_api_key: str | None = None
class AdminProfileUpdate(BaseModel):
@@ -207,6 +229,7 @@ class WrongQuestionOut(BaseModel):
student_id: UUID
subject_id: int
subject_name: str | None = None
category: WrongQuestionCategoryEnum
image_path: str
ocr_raw_text: str | None
question_text: str | None
+169
View File
@@ -0,0 +1,169 @@
import enum
import httpx
from sqlalchemy.orm import Session
from app.core.config import settings as app_settings
from app.models.user import SchoolLevel, SystemSettings
from app.services.school_level import school_level_label
CURRICULUM_JUNIOR = """初中课程标准:代数、几何(全等/相似/勾股)、一次函数与简单二次函数、基础概率统计。
严禁使用:高中导数、向量、解析几何、排列组合进阶、复数、微积分、大学线性代数等。"""
CURRICULUM_SENIOR = """高中课程标准:课内函数、三角、向量、解析几何、概率统计、导数(课内范围)等。
严禁使用:大学数学分析、抽象代数、高等几何、超出课内的竞赛高阶技巧。"""
CURRICULUM_JUNIOR_OLYMPIAD = """初中奥数培优范围:整数/整除、因数分解、简单数论、代数恒等变形、几何辅助线与全等相似、简单组合计数。
严禁使用:高中及以上方法(导数、向量、解析几何、微积分、复数运算等)。"""
CURRICULUM_SENIOR_OLYMPIAD = """高中奥数/竞赛入门范围:课内知识+常规竞赛技巧(不等式、构造、归纳、简单数论等)。
严禁使用:大学数学、超出高中奥数培优体系的 IMO 高阶理论。"""
def _curriculum_block(level: SchoolLevel | str | None, olympiad: bool) -> str:
label = school_level_label(level)
is_senior = level == SchoolLevel.senior_high or level == "senior_high"
if olympiad:
return CURRICULUM_SENIOR_OLYMPIAD if is_senior else CURRICULUM_JUNIOR_OLYMPIAD
return CURRICULUM_SENIOR if is_senior else CURRICULUM_JUNIOR
QUESTION_PROMPT = """你是一位{stage}老师。以下是从试卷 OCR 识别出的文字,可能含有噪声。
科目:{subject}
请整理出清晰的题目内容(保留题号、选项、公式),只输出题目正文,不要解释。
OCR 原文:
{ocr_text}
"""
SOLUTION_PROMPT = """你是一位耐心的{stage}{subject}老师。请为以下题目给出详细解法。
【学段要求 — 严禁超纲】
{curriculum}
题目:
{question_text}
请按以下结构输出:
1. 考点分析({stage}范围内)
2. 解题步骤(逐步推导,每步说明依据)
3. 易错点提醒
4. 若必须使用超纲方法才能解,请改用{stage}可理解的方法重新解答,不得输出超纲解法。
"""
OLYMPIAD_SOLUTION_PROMPT = """你是一位{stage}奥数教练。请为以下奥数题给出详细解题思路与完整解答。
【奥数学段要求 — 严禁超纲】
{curriculum}
题目:
{question_text}
请按以下结构输出:
1. 题型与思路切入点({stage}奥数常见技巧)
2. 详细解答步骤
3. 关键技巧总结(仅限{stage}奥数范围)
4. 严禁使用超出上述范围的方法;若题目过难,给出{stage}可接受的培优思路。
"""
class AIConfig:
def __init__(
self,
provider: str,
ollama_base_url: str,
ollama_model: str,
openai_base_url: str,
openai_model: str,
openai_api_key: str | None,
):
self.provider = provider
self.ollama_base_url = ollama_base_url
self.ollama_model = ollama_model
self.openai_base_url = openai_base_url
self.openai_model = openai_model
self.openai_api_key = openai_api_key
def load_ai_config(db: Session) -> AIConfig:
row = db.get(SystemSettings, 1)
if row is None:
return AIConfig(
provider="ollama",
ollama_base_url=app_settings.OLLAMA_BASE_URL,
ollama_model=app_settings.OLLAMA_MODEL,
openai_base_url=app_settings.OPENAI_BASE_URL,
openai_model=app_settings.OPENAI_MODEL,
openai_api_key=None,
)
return AIConfig(
provider=row.ai_provider or "ollama",
ollama_base_url=row.ollama_base_url or app_settings.OLLAMA_BASE_URL,
ollama_model=row.ollama_model or app_settings.OLLAMA_MODEL,
openai_base_url=row.openai_base_url or app_settings.OPENAI_BASE_URL,
openai_model=row.openai_model or app_settings.OPENAI_MODEL,
openai_api_key=row.openai_api_key,
)
async def _ollama_generate(prompt: str, cfg: AIConfig) -> str:
url = f"{cfg.ollama_base_url.rstrip('/')}/api/generate"
payload = {"model": cfg.ollama_model, "prompt": prompt, "stream": False}
async with httpx.AsyncClient(timeout=180.0) as client:
response = await client.post(url, json=payload)
response.raise_for_status()
return (response.json().get("response") or "").strip()
async def _openai_generate(prompt: str, cfg: AIConfig) -> str:
if not cfg.openai_api_key:
raise ValueError("未配置 OpenAI API Key")
url = f"{cfg.openai_base_url.rstrip('/')}/chat/completions"
headers = {"Authorization": f"Bearer {cfg.openai_api_key}"}
payload = {
"model": cfg.openai_model,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.3,
}
async with httpx.AsyncClient(timeout=180.0) as client:
response = await client.post(url, json=payload, headers=headers)
response.raise_for_status()
data = response.json()
return (data["choices"][0]["message"]["content"] or "").strip()
async def generate_text(prompt: str, cfg: AIConfig) -> str:
if cfg.provider == "openai":
return await _openai_generate(prompt, cfg)
return await _ollama_generate(prompt, cfg)
async def format_question(
cfg: AIConfig,
subject: str,
ocr_text: str,
school_level=None,
) -> str:
stage = school_level_label(school_level)
prompt = QUESTION_PROMPT.format(stage=stage, subject=subject, ocr_text=ocr_text)
return await generate_text(prompt, cfg)
async def generate_solution(
cfg: AIConfig,
subject: str,
question_text: str,
school_level=None,
*,
olympiad: bool = False,
) -> str:
stage = school_level_label(school_level)
curriculum = _curriculum_block(school_level, olympiad)
template = OLYMPIAD_SOLUTION_PROMPT if olympiad else SOLUTION_PROMPT
prompt = template.format(
stage=stage,
subject=subject,
curriculum=curriculum,
question_text=question_text,
)
return await generate_text(prompt, cfg)
+34 -2
View File
@@ -7,7 +7,8 @@ from app.core.database import engine
def run_migrations() -> None:
"""Apply lightweight schema updates for existing databases."""
inspector = inspect(engine)
if "students" not in inspector.get_table_names():
tables = set(inspector.get_table_names())
if "students" not in tables:
return
columns = {col["name"] for col in inspector.get_columns("students")}
@@ -20,7 +21,7 @@ def run_migrations() -> None:
)
)
if "users" in inspector.get_table_names():
if "users" in tables:
user_columns = {col["name"] for col in inspector.get_columns("users")}
if "is_superuser" not in user_columns:
with engine.begin() as conn:
@@ -33,3 +34,34 @@ def run_migrations() -> None:
f"WHERE username = '{settings.ADMIN_DEFAULT_USERNAME}'"
)
)
if "wrong_questions" in tables:
wq_columns = {col["name"] for col in inspector.get_columns("wrong_questions")}
if "category" not in wq_columns:
with engine.begin() as conn:
conn.execute(
text(
"ALTER TABLE wrong_questions ADD COLUMN category VARCHAR(32) "
"NOT NULL DEFAULT 'regular'"
)
)
if "system_settings" in tables:
ss_columns = {col["name"] for col in inspector.get_columns("system_settings")}
alters: list[str] = []
if "ai_provider" not in ss_columns:
alters.append("ADD COLUMN ai_provider VARCHAR(16) NOT NULL DEFAULT 'ollama'")
if "ollama_base_url" not in ss_columns:
alters.append("ADD COLUMN ollama_base_url VARCHAR(256)")
if "ollama_model" not in ss_columns:
alters.append("ADD COLUMN ollama_model VARCHAR(128)")
if "openai_base_url" not in ss_columns:
alters.append("ADD COLUMN openai_base_url VARCHAR(256)")
if "openai_model" not in ss_columns:
alters.append("ADD COLUMN openai_model VARCHAR(128)")
if "openai_api_key" not in ss_columns:
alters.append("ADD COLUMN openai_api_key VARCHAR(512)")
if alters:
with engine.begin() as conn:
for clause in alters:
conn.execute(text(f"ALTER TABLE system_settings {clause}"))
+3 -45
View File
@@ -1,47 +1,5 @@
import httpx
"""Backward-compatible wrapper; prefer app.services.llm."""
from app.core.config import settings
from app.services.school_level import school_level_label
from app.services.llm import format_question, generate_solution, load_ai_config
QUESTION_PROMPT = """你是一位{stage}老师。以下是从试卷 OCR 识别出的文字,可能含有噪声。
科目:{subject}
请整理出清晰的题目内容(保留题号、选项、公式),只输出题目正文,不要解释。
OCR 原文:
{ocr_text}
"""
SOLUTION_PROMPT = """你是一位耐心的{stage}{subject}老师。请为以下题目给出详细解法。
要求:步骤清晰,语言适合{stage}学生理解,指出考点和易错点。
题目:
{question_text}
"""
async def ollama_generate(prompt: str) -> str:
url = f"{settings.OLLAMA_BASE_URL.rstrip('/')}/api/generate"
payload = {
"model": settings.OLLAMA_MODEL,
"prompt": prompt,
"stream": False,
}
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(url, json=payload)
response.raise_for_status()
data = response.json()
return (data.get("response") or "").strip()
async def format_question(subject: str, ocr_text: str, school_level=None) -> str:
stage = school_level_label(school_level)
prompt = QUESTION_PROMPT.format(stage=stage, subject=subject, ocr_text=ocr_text)
return await ollama_generate(prompt)
async def generate_solution(subject: str, question_text: str, school_level=None) -> str:
stage = school_level_label(school_level)
prompt = SOLUTION_PROMPT.format(
stage=stage, subject=subject, question_text=question_text
)
return await ollama_generate(prompt)
__all__ = ["format_question", "generate_solution", "load_ai_config"]
+24 -2
View File
@@ -26,8 +26,30 @@ def seed_subjects(db: Session) -> None:
def seed_admin_and_settings(db: Session) -> None:
if db.get(SystemSettings, 1) is None:
db.add(SystemSettings(id=1, registration_enabled=True))
row = db.get(SystemSettings, 1)
if row is None:
db.add(
SystemSettings(
id=1,
registration_enabled=True,
ai_provider="ollama",
ollama_base_url=settings.OLLAMA_BASE_URL,
ollama_model=settings.OLLAMA_MODEL,
openai_base_url=settings.OPENAI_BASE_URL,
openai_model=settings.OPENAI_MODEL,
)
)
else:
if not row.ollama_base_url:
row.ollama_base_url = settings.OLLAMA_BASE_URL
if not row.ollama_model:
row.ollama_model = settings.OLLAMA_MODEL
if not row.openai_base_url:
row.openai_base_url = settings.OPENAI_BASE_URL
if not row.openai_model:
row.openai_model = settings.OPENAI_MODEL
if not row.ai_provider:
row.ai_provider = "ollama"
admin = db.query(User).filter(User.username == settings.ADMIN_DEFAULT_USERNAME).first()
if admin is None:
+22 -7
View File
@@ -99,18 +99,33 @@
## 4. 错题库
### 4.1 上传错题
### 4.1 上传错题(手机 / 平板)
进入 **错题库** 标签:
进入 **错题库****奥数区** 标签:
1. 选择 **科目**
2. 点击 **上传错题图片**(支持 jpg/png/webp,最大 10MB
2. 点击 **拍照上传**(调用摄像头)或 **相册选图**
3. 上传后后台自动:**OCR 识别 → AI 整理题目 → 生成解法**
4. 处理状态:处理中 → 已识别 → 已生成解法
> AI 解法依赖服务器上的 **Ollama**。未配置时仍可 OCR,解法需手动填写
> 手机和平板已适配触控操作;拍照上传需浏览器授权摄像头
### 4.2 查看与编辑
### 4.2 奥数区
学生详情页 **奥数区** 标签专门存放奥数题:
- 按学生**学段**(初中 / 高中)生成奥数解题思路
- **严禁超纲**:初中不用高中/大学方法,高中不用大学高阶理论
- 上传方式与错题库相同,支持拍照
### 4.3 解题 AI 与学段约束
- **错题库**:按初中/高中**课内**标准解题,禁止超纲
- **奥数区**:按对应学段**奥数培优**范围解题
- 超级管理员可在 **系统设置 → AI 模型** 中选择:
- **本地 Ollama**(地址 + 模型名)
- **OpenAI 兼容 API**Base URL + 模型 + API Key
### 4.4 查看与编辑
点击错题卡片打开详情:
@@ -122,7 +137,7 @@
> 解法标注「AI 生成,请核对」,使用前请人工确认。
### 4.3 筛选与搜索
### 4.5 筛选与搜索
-**科目** 筛选
- **搜索** 题目/解法关键词
+1
View File
@@ -0,0 +1 @@
*{box-sizing:border-box}body{-webkit-font-smoothing:antialiased;-webkit-tap-highlight-color:transparent;background:#f5f5f5;margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif}#root{min-height:100dvh}a{color:inherit}.page-container{max-width:1100px;padding:16px 16px 32px;padding-bottom:max(32px, env(safe-area-inset-bottom));margin:0 auto}.page-header{width:100%;margin-bottom:16px}.student-tabs .ant-tabs-nav{margin-bottom:12px}.wq-grid{grid-template-columns:repeat(auto-fill,minmax(min(100%,260px),1fr));gap:16px;display:grid}.wq-card{cursor:pointer;background:#fff;border:1px solid #f0f0f0;border-radius:8px;transition:box-shadow .2s;overflow:hidden}.wq-card:active{box-shadow:0 2px 8px #00000014}.wq-card-img{object-fit:cover;background:#fafafa;width:100%;height:140px}.wq-card-body{padding:12px}.upload-actions .ant-btn{min-height:44px}@media (width<=768px){.page-container{padding:12px 12px 24px}.ant-tabs-tab{font-size:14px;padding:8px 10px!important}.ant-modal{max-width:calc(100vw - 16px)!important;margin:8px auto!important}.ant-table{font-size:12px}.upload-actions{width:100%}.upload-actions .ant-btn{flex:1;min-width:120px}}@media (width<=576px){.wq-card-img{height:120px}}
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
*{box-sizing:border-box}body{-webkit-font-smoothing:antialiased;background:#f5f5f5;margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif}#root{min-height:100vh}a{color:inherit}@media (width<=576px){.ant-table{font-size:12px}}
File diff suppressed because one or more lines are too long
+5 -3
View File
@@ -3,12 +3,14 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="author" content="马建军" />
<meta name="copyright" content="Copyright (c) 马建军. All rights reserved." />
<title>中学成绩档案</title>
<script type="module" crossorigin src="/assets/index-Ds_4iUm1.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DHafbxey.css">
<script type="module" crossorigin src="/assets/index-BbvdgaGu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-8NG7km60.css">
</head>
<body>
<div id="root"></div>
+3 -1
View File
@@ -3,7 +3,9 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="author" content="马建军" />
<meta name="copyright" content="Copyright (c) 马建军. All rights reserved." />
<title>中学成绩档案</title>
+14 -4
View File
@@ -1,6 +1,7 @@
import axios from 'axios'
import type {
AdminUser,
AIProvider,
Exam,
PublicSettings,
ScoreInput,
@@ -12,6 +13,7 @@ import type {
TrendResponse,
User,
WrongQuestion,
WrongQuestionCategory,
} from '../types'
import type { ExamType } from '../types'
@@ -68,8 +70,15 @@ export const settingsApi = {
export const adminApi = {
getSettings: () => api.get<SystemSettings>('/admin/settings'),
updateSettings: (data: { registration_enabled?: boolean }) =>
api.patch<SystemSettings>('/admin/settings', data),
updateSettings: (data: {
registration_enabled?: boolean
ai_provider?: AIProvider
ollama_base_url?: string | null
ollama_model?: string | null
openai_base_url?: string | null
openai_model?: string | null
openai_api_key?: string
}) => api.patch<SystemSettings>('/admin/settings', data),
updateProfile: (data: {
username?: string
current_password?: string
@@ -120,11 +129,12 @@ export const examApi = {
}
export const wrongQuestionApi = {
list: (studentId: string, params?: { subject_id?: number; q?: string }) =>
list: (studentId: string, params?: { subject_id?: number; q?: string; category?: WrongQuestionCategory }) =>
api.get<WrongQuestion[]>(`/students/${studentId}/wrong-questions`, { params }),
upload: (studentId: string, subjectId: number, file: File) => {
upload: (studentId: string, subjectId: number, file: File, category: WrongQuestionCategory = 'regular') => {
const form = new FormData()
form.append('subject_id', String(subjectId))
form.append('category', category)
form.append('file', file)
return api.post<WrongQuestion>(`/students/${studentId}/wrong-questions`, form)
},
@@ -0,0 +1,61 @@
import { Tag, Typography } from 'antd'
import { wrongQuestionApi } from '../api/client'
import type { WrongQuestion } from '../types'
import { STATUS_LABELS } from '../types'
import WrongQuestionDetail from '../pages/WrongQuestionDetail'
interface Props {
items: WrongQuestion[]
selectedId: string | null
onSelect: (id: string | null) => void
onRefresh: () => void
emptyText?: string
}
export default function WrongQuestionList({
items,
selectedId,
onSelect,
onRefresh,
emptyText = '暂无记录',
}: Props) {
return (
<>
<div className="wq-grid">
{items.map((wq) => (
<div key={wq.id} className="wq-card" onClick={() => onSelect(wq.id)}>
<img
src={wrongQuestionApi.imageUrl(wq.id)}
alt="题目"
className="wq-card-img"
loading="lazy"
/>
<div className="wq-card-body">
<Typography.Text strong>{wq.subject_name}</Typography.Text>
{wq.category === 'olympiad' && (
<Tag color="gold" style={{ marginLeft: 4 }}>
</Tag>
)}
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
{STATUS_LABELS[wq.status]}
</Typography.Text>
<Typography.Paragraph ellipsis={{ rows: 2 }} style={{ margin: '8px 0 0', fontSize: 13 }}>
{wq.question_text || wq.ocr_raw_text || '处理中…'}
</Typography.Paragraph>
</div>
</div>
))}
</div>
{items.length === 0 && <Typography.Text type="secondary">{emptyText}</Typography.Text>}
{selectedId && (
<WrongQuestionDetail
questionId={selectedId}
open={!!selectedId}
onClose={() => onSelect(null)}
onUpdated={onRefresh}
/>
)}
</>
)
}
+61 -17
View File
@@ -1,51 +1,95 @@
import { ReloadOutlined, UploadOutlined } from '@ant-design/icons'
import { CameraOutlined, PictureOutlined, ReloadOutlined, UploadOutlined } from '@ant-design/icons'
import { Button, Input, Select, Space, Upload, message } from 'antd'
import { useState } from 'react'
import { useRef, useState } from 'react'
import { wrongQuestionApi } from '../api/client'
import type { Subject } from '../types'
import type { Subject, WrongQuestionCategory } from '../types'
interface Props {
studentId: string
subjects: Subject[]
category: WrongQuestionCategory
onUploaded: () => void
}
export default function WrongQuestionUpload({ studentId, subjects, onUploaded }: Props) {
export default function WrongQuestionUpload({ studentId, subjects, category, onUploaded }: Props) {
const [subjectId, setSubjectId] = useState<number | undefined>(subjects[0]?.id)
const [uploading, setUploading] = useState(false)
const cameraRef = useRef<HTMLInputElement>(null)
const handleUpload = async (file: File) => {
const doUpload = async (file: File) => {
if (!subjectId) {
message.warning('请选择科目')
return false
return
}
setUploading(true)
try {
await wrongQuestionApi.upload(studentId, subjectId, file)
message.success('上传成功,正在 OCR 识别并生成解法…')
await wrongQuestionApi.upload(studentId, subjectId, file, category)
message.success('上传成功,正在识别并生成解法…')
onUploaded()
} catch {
message.error('上传失败')
} finally {
setUploading(false)
}
}
const handleUpload = async (file: File) => {
await doUpload(file)
return false
}
const handleCamera = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ''
if (file) await doUpload(file)
}
const isOlympiad = category === 'olympiad'
return (
<Space wrap style={{ marginBottom: 16 }}>
<Space direction="vertical" size="middle" style={{ width: '100%', marginBottom: 16 }}>
<Select
style={{ width: 120 }}
style={{ width: '100%', maxWidth: 200 }}
placeholder="选择科目"
value={subjectId}
onChange={setSubjectId}
options={subjects.map((s) => ({ value: s.id, label: s.name }))}
/>
<Upload beforeUpload={handleUpload} showUploadList={false} accept="image/*">
<Button icon={<UploadOutlined />} loading={uploading} type="primary">
<Space wrap className="upload-actions">
<Upload beforeUpload={handleUpload} showUploadList={false} accept="image/*">
<Button icon={<PictureOutlined />} loading={uploading} type="primary" size="large">
</Button>
</Upload>
<Button
icon={<CameraOutlined />}
loading={uploading}
size="large"
onClick={() => cameraRef.current?.click()}
>
</Button>
</Upload>
<input
ref={cameraRef}
type="file"
accept="image/*"
capture="environment"
style={{ display: 'none' }}
onChange={handleCamera}
/>
{!isOlympiad && (
<Upload beforeUpload={handleUpload} showUploadList={false} accept="image/*">
<Button icon={<UploadOutlined />} loading={uploading} size="large">
</Button>
</Upload>
)}
</Space>
{isOlympiad && (
<span style={{ color: '#666', fontSize: 13 }}>
/
</span>
)}
</Space>
)
}
@@ -68,10 +112,10 @@ export function WrongQuestionFilters({
subjects,
}: SearchProps) {
return (
<Space wrap style={{ marginBottom: 16 }}>
<Space wrap style={{ marginBottom: 16, width: '100%' }}>
<Select
allowClear
style={{ width: 140 }}
style={{ width: '100%', maxWidth: 140 }}
placeholder="全部科目"
value={subjectId}
onChange={onSubjectChange}
@@ -82,7 +126,7 @@ export function WrongQuestionFilters({
value={search}
onChange={(e) => onSearchChange(e.target.value)}
onSearch={onRefresh}
style={{ width: 220 }}
style={{ width: '100%', maxWidth: 260 }}
allowClear
/>
<Button icon={<ReloadOutlined />} onClick={onRefresh}>
+82 -1
View File
@@ -8,18 +8,99 @@ body {
sans-serif;
background: #f5f5f5;
-webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent;
}
#root {
min-height: 100vh;
min-height: 100dvh;
}
a {
color: inherit;
}
@media (max-width: 576px) {
.page-container {
max-width: 1100px;
margin: 0 auto;
padding: 16px 16px 32px;
padding-bottom: max(32px, env(safe-area-inset-bottom));
}
.page-header {
margin-bottom: 16px;
width: 100%;
}
.student-tabs .ant-tabs-nav {
margin-bottom: 12px;
}
.wq-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr));
gap: 16px;
}
.wq-card {
border: 1px solid #f0f0f0;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
background: #fff;
transition: box-shadow 0.2s;
}
.wq-card:active {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.wq-card-img {
width: 100%;
height: 140px;
object-fit: cover;
background: #fafafa;
}
.wq-card-body {
padding: 12px;
}
.upload-actions .ant-btn {
min-height: 44px;
}
@media (max-width: 768px) {
.page-container {
padding: 12px 12px 24px;
}
.ant-tabs-tab {
padding: 8px 10px !important;
font-size: 14px;
}
.ant-modal {
max-width: calc(100vw - 16px) !important;
margin: 8px auto !important;
}
.ant-table {
font-size: 12px;
}
.upload-actions {
width: 100%;
}
.upload-actions .ant-btn {
flex: 1;
min-width: 120px;
}
}
@media (max-width: 576px) {
.wq-card-img {
height: 120px;
}
}
+90 -2
View File
@@ -6,6 +6,7 @@ import {
Input,
Modal,
Popconfirm,
Radio,
Space,
Switch,
Table,
@@ -17,7 +18,7 @@ import { useEffect, useState } from 'react'
import { Link, Navigate } from 'react-router-dom'
import { adminApi } from '../api/client'
import { useAuth } from '../context/AuthContext'
import type { AdminUser, SystemSettings } from '../types'
import type { AdminUser, AIProvider, SystemSettings } from '../types'
export default function SettingsPage() {
const { user } = useAuth()
@@ -29,6 +30,8 @@ export default function SettingsPage() {
const [createOpen, setCreateOpen] = useState(false)
const [resetUser, setResetUser] = useState<AdminUser | null>(null)
const [resetForm] = Form.useForm()
const [aiForm] = Form.useForm()
const aiProvider = Form.useWatch('ai_provider', aiForm) as AIProvider | undefined
if (!user?.is_superuser) return <Navigate to="/" replace />
@@ -42,6 +45,14 @@ export default function SettingsPage() {
setSettings(settingsRes.data)
setUsers(usersRes.data)
profileForm.setFieldsValue({ username: user.username })
aiForm.setFieldsValue({
ai_provider: settingsRes.data.ai_provider,
ollama_base_url: settingsRes.data.ollama_base_url || '',
ollama_model: settingsRes.data.ollama_model || '',
openai_base_url: settingsRes.data.openai_base_url || '',
openai_model: settingsRes.data.openai_model || '',
openai_api_key: '',
})
} finally {
setLoading(false)
}
@@ -97,8 +108,32 @@ export default function SettingsPage() {
load()
}
const saveAiSettings = async (values: {
ai_provider: AIProvider
ollama_base_url?: string
ollama_model?: string
openai_base_url?: string
openai_model?: string
openai_api_key?: string
}) => {
const payload: Parameters<typeof adminApi.updateSettings>[0] = {
ai_provider: values.ai_provider,
ollama_base_url: values.ollama_base_url || null,
ollama_model: values.ollama_model || null,
openai_base_url: values.openai_base_url || null,
openai_model: values.openai_model || null,
}
if (values.openai_api_key?.trim()) {
payload.openai_api_key = values.openai_api_key.trim()
}
const { data } = await adminApi.updateSettings(payload)
setSettings(data)
aiForm.setFieldValue('openai_api_key', '')
message.success('AI 模型配置已保存')
}
return (
<div style={{ maxWidth: 960, margin: '0 auto', padding: '16px 16px 32px' }}>
<div className="page-container">
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Typography.Title level={3} style={{ margin: 0 }}>
@@ -156,6 +191,59 @@ export default function SettingsPage() {
</Space>
),
},
{
key: 'ai',
label: 'AI 模型',
children: (
<Card title="解题 AI 配置" loading={loading}>
<Form form={aiForm} layout="vertical" onFinish={saveAiSettings}>
<Form.Item name="ai_provider" label="AI 提供商" rules={[{ required: true }]}>
<Radio.Group>
<Radio.Button value="ollama"> Ollama</Radio.Button>
<Radio.Button value="openai">OpenAI API</Radio.Button>
</Radio.Group>
</Form.Item>
{(aiProvider || settings?.ai_provider) === 'ollama' && (
<>
<Form.Item name="ollama_base_url" label="Ollama 地址">
<Input placeholder="http://127.0.0.1:11434" />
</Form.Item>
<Form.Item name="ollama_model" label="Ollama 模型">
<Input placeholder="qwen2.5:7b" />
</Form.Item>
</>
)}
{(aiProvider || settings?.ai_provider) === 'openai' && (
<>
<Form.Item name="openai_base_url" label="API Base URL">
<Input placeholder="https://api.openai.com/v1" />
</Form.Item>
<Form.Item name="openai_model" label="模型名称">
<Input placeholder="gpt-4o-mini" />
</Form.Item>
<Form.Item
name="openai_api_key"
label="API Key"
extra={
settings?.openai_api_key_set
? '已配置 Key,留空则不修改'
: '请输入 API Key'
}
>
<Input.Password placeholder="sk-..." />
</Form.Item>
</>
)}
<Typography.Paragraph type="secondary">
//
</Typography.Paragraph>
<Button type="primary" htmlType="submit">
AI
</Button>
</Form>
</Card>
),
},
{
key: 'users',
label: '用户管理',
+69 -60
View File
@@ -6,11 +6,10 @@ import { examApi, studentApi, subjectApi, wrongQuestionApi } from '../api/client
import ScoreForm from '../components/ScoreForm'
import ScoreOverview from '../components/ScoreOverview'
import TrendChart from '../components/TrendChart'
import WrongQuestionList from '../components/WrongQuestionList'
import WrongQuestionUpload, { WrongQuestionFilters } from '../components/WrongQuestionUpload'
import { formatStudentMeta, SCHOOL_LEVEL_LABELS } from '../constants/school'
import type { Exam, Student, Subject, TrendResponse, WrongQuestion } from '../types'
import { STATUS_LABELS } from '../types'
import WrongQuestionDetail from './WrongQuestionDetail'
export default function StudentDetailPage() {
const { id } = useParams<{ id: string }>()
@@ -20,9 +19,13 @@ export default function StudentDetailPage() {
const [trend, setTrend] = useState<TrendResponse | null>(null)
const [selectedSubject, setSelectedSubject] = useState<number>()
const [wrongQuestions, setWrongQuestions] = useState<WrongQuestion[]>([])
const [olympiadQuestions, setOlympiadQuestions] = useState<WrongQuestion[]>([])
const [wqSubjectFilter, setWqSubjectFilter] = useState<number>()
const [olympiadSubjectFilter, setOlympiadSubjectFilter] = useState<number>()
const [wqSearch, setWqSearch] = useState('')
const [olympiadSearch, setOlympiadSearch] = useState('')
const [selectedWq, setSelectedWq] = useState<string | null>(null)
const [selectedOlympiad, setSelectedOlympiad] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const subjectNames = Object.fromEntries(subjects.map((s) => [s.id, s.name]))
@@ -44,10 +47,21 @@ export default function StudentDetailPage() {
const { data } = await wrongQuestionApi.list(id, {
subject_id: wqSubjectFilter,
q: wqSearch || undefined,
category: 'regular',
})
setWrongQuestions(data)
}, [id, wqSubjectFilter, wqSearch])
const loadOlympiadQuestions = useCallback(async () => {
if (!id) return
const { data } = await wrongQuestionApi.list(id, {
subject_id: olympiadSubjectFilter,
q: olympiadSearch || undefined,
category: 'olympiad',
})
setOlympiadQuestions(data)
}, [id, olympiadSubjectFilter, olympiadSearch])
useEffect(() => {
if (!id) return
const init = async () => {
@@ -62,12 +76,13 @@ export default function StudentDetailPage() {
if (subjectRes.data.length) setSelectedSubject(subjectRes.data[0].id)
await loadExams()
await loadWrongQuestions()
await loadOlympiadQuestions()
} finally {
setLoading(false)
}
}
init()
}, [id, loadExams, loadWrongQuestions])
}, [id, loadExams, loadWrongQuestions, loadOlympiadQuestions])
useEffect(() => {
loadTrend()
@@ -98,18 +113,18 @@ export default function StudentDetailPage() {
if (!student) return <Typography.Text></Typography.Text>
const stageLabel = SCHOOL_LEVEL_LABELS[student.school_level]
return (
<div style={{ maxWidth: 1100, margin: '0 auto', padding: '16px 16px 32px' }}>
<Space style={{ marginBottom: 16 }} wrap>
<div className="page-container">
<Space className="page-header" wrap>
<Link to="/">
<Button icon={<ArrowLeftOutlined />}></Button>
</Link>
<Typography.Title level={4} style={{ margin: 0 }}>
{student.name}
</Typography.Title>
<Tag color={student.school_level === 'senior_high' ? 'purple' : 'blue'}>
{SCHOOL_LEVEL_LABELS[student.school_level]}
</Tag>
<Tag color={student.school_level === 'senior_high' ? 'purple' : 'blue'}>{stageLabel}</Tag>
<Typography.Text type="secondary">{formatStudentMeta(student)}</Typography.Text>
<Button icon={<DownloadOutlined />} onClick={handleExport}>
CSV
@@ -117,17 +132,13 @@ export default function StudentDetailPage() {
</Space>
<Tabs
className="student-tabs"
items={[
{
key: 'scores',
label: '成绩录入',
children: (
<ScoreForm
studentId={id!}
subjects={subjects}
exams={exams}
onRefresh={loadExams}
/>
<ScoreForm studentId={id!} subjects={subjects} exams={exams} onRefresh={loadExams} />
),
},
{
@@ -141,7 +152,7 @@ export default function StudentDetailPage() {
children: (
<div>
<Select
style={{ width: 140, marginBottom: 16 }}
style={{ width: '100%', maxWidth: 160, marginBottom: 16 }}
value={selectedSubject}
onChange={setSelectedSubject}
options={subjects.map((s) => ({ value: s.id, label: s.name }))}
@@ -161,9 +172,13 @@ export default function StudentDetailPage() {
label: '错题库',
children: (
<div>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
{stageLabel}
</Typography.Paragraph>
<WrongQuestionUpload
studentId={id!}
subjects={subjects}
category="regular"
onUploaded={loadWrongQuestions}
/>
<WrongQuestionFilters
@@ -174,51 +189,45 @@ export default function StudentDetailPage() {
onRefresh={loadWrongQuestions}
subjects={subjects}
/>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 16 }}>
{wrongQuestions.map((wq) => (
<div
key={wq.id}
onClick={() => setSelectedWq(wq.id)}
style={{
border: '1px solid #f0f0f0',
borderRadius: 8,
overflow: 'hidden',
cursor: 'pointer',
}}
>
<img
src={wrongQuestionApi.imageUrl(wq.id)}
alt="错题"
style={{ width: '100%', height: 140, objectFit: 'cover', background: '#fafafa' }}
/>
<div style={{ padding: 12 }}>
<Space>
<Typography.Text strong>{wq.subject_name}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{STATUS_LABELS[wq.status]}
</Typography.Text>
</Space>
<Typography.Paragraph
ellipsis={{ rows: 2 }}
style={{ margin: '8px 0 0', fontSize: 13 }}
>
{wq.question_text || wq.ocr_raw_text || '处理中…'}
</Typography.Paragraph>
</div>
</div>
))}
</div>
{wrongQuestions.length === 0 && (
<Typography.Text type="secondary"></Typography.Text>
)}
{selectedWq && (
<WrongQuestionDetail
questionId={selectedWq}
open={!!selectedWq}
onClose={() => setSelectedWq(null)}
onUpdated={loadWrongQuestions}
/>
)}
<WrongQuestionList
items={wrongQuestions}
selectedId={selectedWq}
onSelect={setSelectedWq}
onRefresh={loadWrongQuestions}
emptyText="暂无错题"
/>
</div>
),
},
{
key: 'olympiad',
label: '奥数区',
children: (
<div>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
{stageLabel}{stageLabel}
</Typography.Paragraph>
<WrongQuestionUpload
studentId={id!}
subjects={subjects}
category="olympiad"
onUploaded={loadOlympiadQuestions}
/>
<WrongQuestionFilters
subjectId={olympiadSubjectFilter}
onSubjectChange={setOlympiadSubjectFilter}
search={olympiadSearch}
onSearchChange={setOlympiadSearch}
onRefresh={loadOlympiadQuestions}
subjects={subjects}
/>
<WrongQuestionList
items={olympiadQuestions}
selectedId={selectedOlympiad}
onSelect={setSelectedOlympiad}
onRefresh={loadOlympiadQuestions}
emptyText="暂无奥数题"
/>
</div>
),
},
+1 -1
View File
@@ -44,7 +44,7 @@ export default function StudentsPage() {
}
return (
<div style={{ maxWidth: 960, margin: '0 auto', padding: '16px 16px 32px' }}>
<div className="page-container">
<div
style={{
display: 'flex',
+6 -2
View File
@@ -60,7 +60,7 @@ export default function WrongQuestionDetail({ questionId, open, onClose, onUpdat
message.success('解法已重新生成')
onUpdated()
} catch {
message.error('生成失败,请确认 Ollama 已启动')
message.error('生成失败,请检查 AI 模型配置')
} finally {
setRegenerating(false)
}
@@ -75,7 +75,11 @@ export default function WrongQuestionDetail({ questionId, open, onClose, onUpdat
return (
<Modal
title={wq ? `${wq.subject_name} · 错题详情` : '错题详情'}
title={
wq
? `${wq.subject_name}${wq.category === 'olympiad' ? ' · 奥数' : ''} · 详情`
: '详情'
}
open={open}
onCancel={onClose}
width="90%"
+11
View File
@@ -17,6 +17,12 @@ export interface PublicSettings {
export interface SystemSettings {
registration_enabled: boolean
ai_provider: AIProvider
ollama_base_url: string | null
ollama_model: string | null
openai_base_url: string | null
openai_model: string | null
openai_api_key_set: boolean
updated_at: string
}
@@ -27,6 +33,10 @@ export interface AdminUser {
created_at: string
}
export type WrongQuestionCategory = 'regular' | 'olympiad'
export type AIProvider = 'ollama' | 'openai'
export type SchoolLevel = 'junior_high' | 'senior_high'
export interface Student {
@@ -96,6 +106,7 @@ export interface WrongQuestion {
student_id: string
subject_id: number
subject_name?: string
category: WrongQuestionCategory
image_path: string
ocr_raw_text: string | null
question_text: string | null