移动端拍照上传、奥数区、学段约束解题与 AI 模型配置。
- 手机/平板响应式布局,支持拍照与相册上传 - 学生详情新增奥数区,按初/高中学段生成解法并禁止超纲 - 系统设置可配置 Ollama 或 OpenAI 兼容 API - 更新 frontend/dist 与使用说明
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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}"))
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
@@ -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}}
|
||||
+432
File diff suppressed because one or more lines are too long
-1
@@ -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}}
|
||||
-432
File diff suppressed because one or more lines are too long
Vendored
+5
-3
@@ -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
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 }))}
|
||||
/>
|
||||
<Space wrap className="upload-actions">
|
||||
<Upload beforeUpload={handleUpload} showUploadList={false} accept="image/*">
|
||||
<Button icon={<UploadOutlined />} loading={uploading} type="primary">
|
||||
上传错题图片
|
||||
<Button icon={<PictureOutlined />} loading={uploading} type="primary" size="large">
|
||||
相册选图
|
||||
</Button>
|
||||
</Upload>
|
||||
<Button
|
||||
icon={<CameraOutlined />}
|
||||
loading={uploading}
|
||||
size="large"
|
||||
onClick={() => cameraRef.current?.click()}
|
||||
>
|
||||
拍照上传
|
||||
</Button>
|
||||
<input
|
||||
ref={cameraRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleCamera}
|
||||
/>
|
||||
{!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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: '用户管理',
|
||||
|
||||
@@ -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' }}
|
||||
<WrongQuestionList
|
||||
items={wrongQuestions}
|
||||
selectedId={selectedWq}
|
||||
onSelect={setSelectedWq}
|
||||
onRefresh={loadWrongQuestions}
|
||||
emptyText="暂无错题"
|
||||
/>
|
||||
<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 || '处理中…'}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'olympiad',
|
||||
label: '奥数区',
|
||||
children: (
|
||||
<div>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||
{stageLabel}奥数解题思路,严格限制在{stageLabel}奥数培优范围内,禁止超纲
|
||||
</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}
|
||||
<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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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%"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user