first commit

This commit is contained in:
2026-03-05 11:50:15 +08:00
commit b1b14fd964
45 changed files with 7779 additions and 0 deletions

10
.env.example Normal file
View File

@@ -0,0 +1,10 @@
API_KEY=
MODEL_NAME=gpt-4.1
DMXAPI_URL=https://www.dmxapi.cn/v1/responses
JWT_SECRET_KEY=please_change_me
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=720
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123
DATABASE_URL=sqlite:///./backend/problem_bank.db
UPLOAD_DIR=./backend/uploads

56
.gitignore vendored Normal file
View File

@@ -0,0 +1,56 @@
# OS
.DS_Store
Thumbs.db
# IDE / Editor
.idea/
.vscode/
*.swp
*.swo
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
.Python
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
.coverage.*
htmlcov/
.tox/
.nox/
.venv/
venv/
env/
# Environment variables / secrets
.env
.env.*
!.env.example
# Backend runtime artifacts
backend/problem_bank.db
backend/*.db
backend/uploads/
# Frontend (Node/Vite)
frontend/node_modules/
frontend/dist/
frontend/.vite/
frontend/.cache/
frontend/*.tsbuildinfo
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Cursor / local generated artifacts
.cursor/
!.cursor/rules/
!.cursor/rules/**

45
README.md Normal file
View File

@@ -0,0 +1,45 @@
# 网页版题库管理系统
基于 FastAPI + React + SQLite 的题库系统支持题目管理、AI 导入、Excel 导入导出、分类管理、练习模式与统计仪表盘。
## 目录
- `backend/`: FastAPI 后端
- `frontend/`: React 前端
- `analysis.py`: 原始解析脚本(参考)
## 后端启动
1. 安装依赖:
- `cd backend`
- `python3 -m venv .venv && source .venv/bin/activate`
- `pip install -r requirements.txt`
2. 配置环境变量:
- 复制项目根目录 `.env.example``.env`
- 填写 `API_KEY``ADMIN_PASSWORD``JWT_SECRET_KEY`
3. 运行:
- 在项目根目录执行 `uvicorn backend.main:app --reload --port 8000`
## 前端启动
1. 安装依赖:
- `cd frontend`
- `npm install`
2. 运行:
- `npm run dev`
3. 打开:
- `http://127.0.0.1:5173`
## 默认登录
- 用户名:`admin`
- 密码:`admin123`(请在 `.env` 修改)
## 功能清单
- 题目 CRUD、搜索、筛选、批量删除、批量更新
- AI 智能导入PDF/Word -> DMXAPI -> 预览 -> 确认保存)
- Excel 批量导入、模板下载、导出 JSON/CSV/Excel
- 分类树管理(章节/知识点)
- 练习模式(抽题、判题、解析反馈)
- 仪表盘统计(总量、题型、难度、章节、导入历史)

550
analysis.py Normal file
View File

@@ -0,0 +1,550 @@
#!/usr/bin/env python3
"""
DMXAPI直接解析 - 无需本地文本提取
将文件上传到临时托管服务然后使用DMXAPI的responses接口分析
"""
import sys
import json
import requests
from pathlib import Path
# 添加项目路径
sys.path.insert(0, str(Path(__file__).parent))
from config import settings
from config.database import get_db_manager
class DMXAPIParser:
"""DMXAPI解析器"""
def __init__(self):
self.api_key = settings.API_KEY
self.model_name = settings.MODEL_NAME
self.api_url = "https://www.dmxapi.cn/v1/responses"
def upload_to_temp_host(self, file_path):
"""
上传文件到临时托管服务获取公网URL
Args:
file_path: 文件路径
Returns:
文件的公网访问URL
"""
path = Path(file_path)
print(f' 上传PDF到临时托管服务...')
print(f' 文件: {path.name}')
print(f' 大小: {path.stat().st_size / 1024:.2f} KB')
# 使用 file.io 临时文件托管24小时有效一次下载后删除
try:
with open(file_path, 'rb') as f:
files = {'file': f}
response = requests.post(
'https://file.io',
files=files,
timeout=60
)
if response.status_code == 200:
result = response.json()
if result.get('success'):
file_url = result['link']
print(f' ✓ 上传成功')
print(f' URL: {file_url}')
return file_url
else:
raise Exception(f'上传失败: {result}')
else:
raise Exception(f'HTTP错误: {response.status_code} - {response.text}')
except Exception as e:
print(f' ✗ file.io上传失败: {e}')
print(' 尝试备用服务...')
# 备用方案:使用 tmpfiles.org (7天有效)
try:
with open(file_path, 'rb') as f:
files = {'file': f}
response = requests.post(
'https://tmpfiles.org/api/v1/upload',
files=files,
timeout=60
)
if response.status_code == 200:
result = response.json()
if result.get('status') == 'success':
# tmpfiles.org返回的URL需要调整
url = result['data']['url']
# 转换为直接下载链接
file_url = url.replace('tmpfiles.org/', 'tmpfiles.org/dl/')
print(f' ✓ 上传成功(备用服务)')
print(f' URL: {file_url}')
return file_url
raise Exception(f'备用服务也失败: {response.text}')
except Exception as e2:
raise Exception(f'所有上传服务均失败: {e2}')
def parse_file(self, file_path):
"""
使用DMXAPI分析文件
Args:
file_path: 文件路径
Returns:
解析出的题目列表
"""
print(f'\n[2/4] 准备文件并调用DMXAPI...')
path = Path(file_path)
# 判断文件类型如果不是PDF则转换
if path.suffix.lower() != '.pdf':
print(f' 检测到 {path.suffix} 文件使用pandoc转换为PDF')
pdf_path = self._convert_to_pdf(file_path)
else:
print(f' 检测到PDF文件直接使用')
pdf_path = file_path
# 上传PDF到临时服务
file_url = self.upload_to_temp_host(pdf_path)
# 使用DMXAPI分析
questions = self._parse_with_file_url(file_url, path.name)
# 清理临时PDF如果是转换生成的
if pdf_path != file_path:
try:
Path(pdf_path).unlink()
print(f' ✓ 已清理临时PDF文件')
except:
pass
return questions
def _convert_to_pdf(self, file_path):
"""
使用pandoc将文件转换为PDF
Args:
file_path: 原始文件路径
Returns:
转换后的PDF文件路径
"""
import subprocess
path = Path(file_path)
pdf_path = path.with_suffix('.pdf')
# 如果PDF已存在先删除
if pdf_path.exists():
pdf_path.unlink()
print(f' 运行: pandoc (使用xelatex引擎)')
try:
# 使用xelatex引擎支持中文
result = subprocess.run(
[
'pandoc',
str(path),
'-o', str(pdf_path),
'--pdf-engine=xelatex',
'-V', 'CJKmainfont=PingFang SC' # macOS中文字体
],
capture_output=True,
text=True,
timeout=60
)
if result.returncode != 0:
# xelatex失败尝试使用weasyprint
print(f' xelatex失败尝试weasyprint引擎...')
result = subprocess.run(
[
'pandoc',
str(path),
'-o', str(pdf_path),
'--pdf-engine=weasyprint'
],
capture_output=True,
text=True,
timeout=60
)
if result.returncode != 0:
raise Exception(f'所有PDF引擎均失败\nxelatex错误: {result.stderr}')
if not pdf_path.exists():
raise Exception('PDF文件未生成')
print(f' ✓ 转换成功: {pdf_path.name}')
return str(pdf_path)
except FileNotFoundError:
raise Exception('未找到pandoc命令请先安装pandoc\n macOS: brew install pandoc\n Ubuntu: sudo apt install pandoc')
except subprocess.TimeoutExpired:
raise Exception('pandoc转换超时')
def _parse_with_file_url(self, file_url, original_filename):
"""使用file_url方式解析PDF"""
print(f' 文件URL: {file_url}')
print(f' 模型: {self.model_name}')
print(f' 正在分析...')
# 构建请求
payload = {
"model": self.model_name,
"input": [{
"role": "user",
"content": [
{
"type": "input_file",
"file_url": file_url
},
{
"type": "input_text",
"text": self._build_instruction(original_filename)
}
]
}]
}
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
}
# 发送请求
print(f' 模型: {self.model_name}')
print(f' 正在分析...')
response = requests.post(
self.api_url,
headers=headers,
data=json.dumps(payload),
timeout=180 # 3分钟超时
)
if response.status_code != 200:
raise Exception(f'API请求失败: {response.status_code} - {response.text}')
result = response.json()
# 提取题目
questions = self._extract_questions(result)
print(f' ✓ 解析完成,共 {len(questions)} 个题目')
return questions
def _build_instruction(self, filename):
"""构建分析指令"""
return f"""请从文件"{filename}"中提取所有题目信息并以JSON数组格式返回。
**提取要求:**
1. **题目识别**
- 如果文档中有"修改试题"作为分隔符,请以此分割题目
- 否则根据题目序号、题干、选项、答案、解析的结构识别每个题目
2. **字段提取**
- 题干:题目的问题部分
- 选项A、选项B、选项C、选项D选择题的选项填空题或解答题为空字符串
- 正确答案:答案内容(单选如"A",多选如"ABD"
- 解析:题目的解答过程
- 备注:提取"难度 难""属性:共享 难度:易 采用:是"等元数据
3. **数学公式格式要求(重要):**
- **所有数学公式必须转换为LaTeX格式**
- 内联公式使用 $...$ 包围
- 独立公式使用 $$...$$ 包围
- 示例:
* "𝑥²""$x^2$"
* "sin x""$\\sin x$"
* "∫₀¹ x dx""$\\int_0^1 x \\, dx$"
* "f(x) = x² + 1""$f(x) = x^2 + 1$"
* 分数a/b → "$\\frac{{a}}{{b}}$"
* 根号√x → "$\\sqrt{{x}}$"
* 上下标xₙ → "$x_n$"x² → "$x^2$"
4. **其他格式要求**
- 答案统一为大写字母(选择题)
- 移除答案前缀(如"答案:"""等)
- 保留其他文本格式
**返回格式只返回JSON数组**
```json
[
{{
"题干": "下列函数中定义域为R的是",
"选项A": "$y = \\frac{{1}}{{x}}$",
"选项B": "$y = \\sqrt{{x}}$",
"选项C": "$y = x^2$",
"选项D": "$y = \\ln(x)$",
"正确答案": "C",
"解析": "$x^2$对所有实数都有定义",
"备注": "难度 易"
}}
]
```
**注意请确保所有数学符号、公式都转换为LaTeX格式**"""
def _extract_questions(self, response):
"""从DMXAPI响应中提取题目"""
# 检查状态
if response.get('status') != 'completed':
raise ValueError(f'响应状态异常: {response.get("status")}')
# 提取文本内容
output = response.get('output', [])
text_content = None
for item in output:
if item.get('type') == 'message':
content = item.get('content', [])
for c in content:
if c.get('type') == 'output_text':
text_content = c.get('text', '')
break
if text_content:
break
if not text_content:
raise ValueError('未找到文本内容')
# 解析JSON
questions = self._parse_json(text_content)
# 补充缺失字段
for q in questions:
for field in ['选项A', '选项B', '选项C', '选项D', '解析', '备注']:
if field not in q:
q[field] = ''
return questions
def _parse_json(self, text):
"""从文本中解析JSON"""
# 查找JSON数组
start_idx = text.find('[')
end_idx = text.rfind(']')
if start_idx == -1 or end_idx == -1:
# 尝试查找被```包围的JSON
if '```json' in text:
lines = text.split('\n')
json_lines = []
in_json = False
for line in lines:
if '```json' in line:
in_json = True
continue
elif '```' in line and in_json:
break
elif in_json:
json_lines.append(line)
text = '\n'.join(json_lines)
start_idx = text.find('[')
end_idx = text.rfind(']')
if start_idx == -1 or end_idx == -1:
raise ValueError(f'未找到JSON数组\n文本: {text[:200]}...')
json_str = text[start_idx:end_idx + 1]
try:
questions = json.loads(json_str)
if not isinstance(questions, list):
raise ValueError('解析结果不是数组')
# 验证必填字段
for i, q in enumerate(questions):
if '题干' not in q or '正确答案' not in q:
raise ValueError(f'{i+1} 个题目缺少必填字段')
return questions
except json.JSONDecodeError as e:
raise ValueError(f'JSON解析失败: {e}\nJSON: {json_str[:300]}...')
def extract_metadata(filename):
"""从文件名提取元数据"""
from config.settings import TYPE_MAP
path = Path(filename)
basename = path.stem
# 解析文件名
separators = ['+', ' ', '-', '_']
parts = [basename]
for sep in separators:
if sep in basename:
parts = basename.split(sep)
break
secondary_knowledge = parts[0].strip() if len(parts) > 0 else ''
question_type_raw = parts[1].strip() if len(parts) > 1 else ''
difficulty = parts[2].strip() if len(parts) > 2 else ''
# 映射题型
question_type = TYPE_MAP.get(question_type_raw, question_type_raw)
valid_types = ['单选', '多选', '不定项', '填空', '解答']
if question_type not in valid_types and question_type_raw in valid_types:
question_type = question_type_raw
# 推断章节
chapter = ''
if '' in secondary_knowledge:
chapter = secondary_knowledge.split('')[0]
else:
chapter = secondary_knowledge
if not chapter:
chapter = '未分类'
return {
'章节': chapter,
'二级知识点': secondary_knowledge,
'题目类型': question_type,
'难度': difficulty,
'文件路径': filename
}
def save_to_database(questions, metadata):
"""保存题目到数据库"""
print(f' 保存题目到数据库...')
import re
db = get_db_manager()
saved_ids = []
for i, q_data in enumerate(questions, 1):
# 标准化答案
answer = q_data.get('正确答案', '').strip().upper()
answer = re.sub(r'[^A-D0-9]', '', answer)
sql = """
INSERT INTO questions
(章节, 一级知识点, 二级知识点, 题目类型, 难度, 题干,
选项A, 选项B, 选项C, 选项D, 正确答案, 解析, 备注, 文件路径)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"""
params = (
metadata['章节'], '', metadata['二级知识点'],
metadata['题目类型'], metadata['难度'], q_data.get('题干', ''),
q_data.get('选项A', ''), q_data.get('选项B', ''),
q_data.get('选项C', ''), q_data.get('选项D', ''),
answer, q_data.get('解析', ''),
q_data.get('备注', ''), metadata['文件路径']
)
question_id = db.execute(sql, params)
saved_ids.append(question_id)
print(f' ✓ 题目 {i}/{len(questions)}, ID={question_id}')
return saved_ids
def display_results(questions, metadata, saved_ids):
"""显示结果"""
print(f'\n[4/4] 完成')
print('='*60)
print(f'文件: {metadata["文件路径"]}')
print(f'\n元数据:')
print(f' 章节: {metadata["章节"]}')
print(f' 知识点: {metadata["二级知识点"]}')
print(f' 题型: {metadata["题目类型"]}')
print(f' 难度: {metadata["难度"]}')
print(f'\n题目列表 (共 {len(questions)} 个):')
print('='*60)
for i, q in enumerate(questions, 1):
print(f'\n题目 {i} (ID={saved_ids[i-1] if saved_ids else "未保存"}):')
print(f' 题干: {q["题干"][:60]}...')
if q.get('选项A'):
print(f' 选项A: {q["选项A"][:40]}...')
print(f' 答案: {q["正确答案"]}')
if q.get('备注'):
print(f' 备注: {q["备注"]}')
print('='*60)
print(f'\n✓ 所有题目已保存到数据库')
# 显示数据库统计
db = get_db_manager()
total = db.execute("SELECT COUNT(*) as count FROM questions", fetch_one=True)
print(f'✓ 数据库中共有 {total["count"]} 个题目')
def main():
"""主函数"""
if len(sys.argv) < 2:
print('用法: python dmxapi_parse.py <文件路径>')
print('\n示例:')
print(' python dmxapi_parse.py "函数的周期性 单选题 难.docx"')
print(' python dmxapi_parse.py test.pdf')
print('\n说明:')
print(' - 支持格式: PDF, Word (.docx, .doc)')
print(' - 文件名格式: 知识点+题型+难度.扩展名')
print(' - 不在本地解析直接上传到DMXAPI分析')
sys.exit(1)
file_path = sys.argv[1]
path = Path(file_path)
if not path.exists():
print(f'✗ 文件不存在: {file_path}')
sys.exit(1)
print('='*60)
print('DMXAPI 文件解析 (无本地解析)')
print('='*60)
try:
# 创建解析器
parser = DMXAPIParser()
# [1/4] 文件转换和上传在parse_file中完成
# [2/4] DMXAPI分析在parse_file中完成
questions = parser.parse_file(file_path)
if not questions:
print('\n✗ 未解析到任何题目')
sys.exit(1)
# [3/4] 提取元数据
print(f'\n[3/4] 提取元数据...')
metadata = extract_metadata(path.name)
# [4/4] 保存到数据库和显示
saved_ids = save_to_database(questions, metadata)
display_results(questions, metadata, saved_ids)
print('\n✓ 处理完成!')
sys.exit(0)
except Exception as e:
print(f'\n✗ 处理失败: {e}')
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == '__main__':
main()

1
backend/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Backend package marker.

54
backend/auth.py Normal file
View File

@@ -0,0 +1,54 @@
from datetime import datetime, timedelta, timezone
import importlib.util
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
_jose_spec = importlib.util.find_spec("jose")
_jose_origin = _jose_spec.origin if _jose_spec else None
_incompatible_jose = bool(_jose_origin and _jose_origin.endswith("site-packages/jose.py"))
try:
import jwt
from jwt import InvalidTokenError as JWTError
except Exception as exc:
if not _incompatible_jose:
try:
from jose import JWTError, jwt
except Exception as fallback_exc:
raise RuntimeError(
"JWT 依赖不可用,请安装 python-jose[cryptography] 或 PyJWT"
) from fallback_exc
else:
raise RuntimeError(
"检测到不兼容依赖 jose请卸载 jose 后安装 python-jose[cryptography] 或安装 PyJWT"
) from exc
from backend.config import settings
security = HTTPBearer()
def create_access_token(subject: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
payload = {"sub": subject, "exp": expire}
return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
def verify_password(username: str, password: str) -> bool:
return username == settings.admin_username and password == settings.admin_password
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> str:
token = credentials.credentials
try:
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
username: str | None = payload.get("sub")
if not username:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效Token")
return username
except JWTError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期") from exc

32
backend/config.py Normal file
View File

@@ -0,0 +1,32 @@
from pathlib import Path
from typing import Dict
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
api_key: str = ""
model_name: str = "gpt-4.1"
dmxapi_url: str = "https://www.dmxapi.cn/v1/responses"
jwt_secret_key: str = "change-me-in-env"
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 60 * 12
admin_username: str = "admin"
admin_password: str = "admin123"
database_url: str = f"sqlite:///{Path(__file__).resolve().parent / 'problem_bank.db'}"
upload_dir: str = str(Path(__file__).resolve().parent / "uploads")
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
@property
def type_map(self) -> Dict[str, str]:
return {
"单选题": "单选",
"多选题": "多选",
"不定项选择题": "不定项",
"填空题": "填空",
"解答题": "解答",
}
settings = Settings()

23
backend/database.py Normal file
View File

@@ -0,0 +1,23 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from backend.config import settings
class Base(DeclarativeBase):
pass
engine = create_engine(
settings.database_url,
connect_args={"check_same_thread": False} if settings.database_url.startswith("sqlite") else {},
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

30
backend/main.py Normal file
View File

@@ -0,0 +1,30 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from backend.database import Base, engine
from backend.routers import auth, categories, exports, imports, practice, questions, stats
app = FastAPI(title="Problem Bank API", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
Base.metadata.create_all(bind=engine)
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(questions.router, prefix="/api/questions", tags=["questions"])
app.include_router(imports.router, prefix="/api/import", tags=["imports"])
app.include_router(exports.router, prefix="/api/export", tags=["exports"])
app.include_router(categories.router, prefix="/api/categories", tags=["categories"])
app.include_router(practice.router, prefix="/api/practice", tags=["practice"])
app.include_router(stats.router, prefix="/api/stats", tags=["stats"])
@app.get("/api/health")
def health() -> dict:
return {"status": "ok"}

54
backend/models.py Normal file
View File

@@ -0,0 +1,54 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.database import Base
class Question(Base):
__tablename__ = "questions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
chapter: Mapped[str] = mapped_column(Text, default="")
primary_knowledge: Mapped[str] = mapped_column(Text, default="")
secondary_knowledge: Mapped[str] = mapped_column(Text, default="")
question_type: Mapped[str] = mapped_column(Text, default="")
difficulty: Mapped[str] = mapped_column(Text, default="")
stem: Mapped[str] = mapped_column(Text)
option_a: Mapped[str] = mapped_column(Text, default="")
option_b: Mapped[str] = mapped_column(Text, default="")
option_c: Mapped[str] = mapped_column(Text, default="")
option_d: Mapped[str] = mapped_column(Text, default="")
answer: Mapped[str] = mapped_column(Text, default="")
explanation: Mapped[str] = mapped_column(Text, default="")
notes: Mapped[str] = mapped_column(Text, default="")
source_file: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)
class Category(Base):
__tablename__ = "categories"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(Text, nullable=False)
parent_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"), nullable=True)
level: Mapped[int] = mapped_column(Integer, default=1)
children: Mapped[list["Category"]] = relationship(
"Category", backref="parent", remote_side=[id], lazy="joined"
)
class ImportHistory(Base):
__tablename__ = "import_history"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
filename: Mapped[str] = mapped_column(Text, default="")
method: Mapped[str] = mapped_column(Text, default="manual")
question_count: Mapped[int] = mapped_column(Integer, default=0)
status: Mapped[str] = mapped_column(Text, default="success")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

12
backend/requirements.txt Normal file
View File

@@ -0,0 +1,12 @@
fastapi
uvicorn[standard]
sqlalchemy
pydantic
pydantic-settings
python-jose[cryptography]
PyJWT
passlib[bcrypt]
python-multipart
requests
openpyxl
python-dotenv

View File

@@ -0,0 +1 @@
# Routers package marker.

13
backend/routers/auth.py Normal file
View File

@@ -0,0 +1,13 @@
from fastapi import APIRouter, HTTPException, status
from backend.auth import create_access_token, verify_password
from backend.schemas import LoginRequest, Token
router = APIRouter()
@router.post("/login", response_model=Token)
def login(payload: LoginRequest) -> Token:
if not verify_password(payload.username, payload.password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
return Token(access_token=create_access_token(payload.username))

View File

@@ -0,0 +1,74 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func
from sqlalchemy.orm import Session
from backend.auth import get_current_user
from backend.database import get_db
from backend.models import Category, Question
from backend.schemas import CategoryCreate, CategoryOut
router = APIRouter(dependencies=[Depends(get_current_user)])
def _build_tree(rows: list[Category], counts: dict[str, int]) -> list[dict]:
nodes = {
row.id: {
"id": row.id,
"name": row.name,
"level": row.level,
"parent_id": row.parent_id,
"count": counts.get(row.name, 0),
"children": [],
}
for row in rows
}
tree = []
for node in nodes.values():
pid = node["parent_id"]
if pid and pid in nodes:
nodes[pid]["children"].append(node)
else:
tree.append(node)
return tree
@router.get("")
def list_categories(db: Session = Depends(get_db)) -> dict:
rows = db.query(Category).order_by(Category.level.asc(), Category.id.asc()).all()
counts = dict(db.query(Question.chapter, func.count(Question.id)).group_by(Question.chapter).all())
return {"items": _build_tree(rows, counts)}
@router.post("", response_model=CategoryOut)
def create_category(payload: CategoryCreate, db: Session = Depends(get_db)) -> CategoryOut:
if payload.parent_id:
parent = db.get(Category, payload.parent_id)
if not parent:
raise HTTPException(status_code=404, detail="父分类不存在")
item = Category(**payload.model_dump())
db.add(item)
db.commit()
db.refresh(item)
return CategoryOut.model_validate(item)
@router.put("/{category_id}", response_model=CategoryOut)
def update_category(category_id: int, payload: CategoryCreate, db: Session = Depends(get_db)) -> CategoryOut:
item = db.get(Category, category_id)
if not item:
raise HTTPException(status_code=404, detail="分类不存在")
for key, value in payload.model_dump().items():
setattr(item, key, value)
db.commit()
db.refresh(item)
return CategoryOut.model_validate(item)
@router.delete("/{category_id}")
def delete_category(category_id: int, db: Session = Depends(get_db)) -> dict:
item = db.get(Category, category_id)
if not item:
raise HTTPException(status_code=404, detail="分类不存在")
db.delete(item)
db.commit()
return {"ok": True}

View File

@@ -0,0 +1,68 @@
import csv
import json
from io import StringIO
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from backend.auth import get_current_user
from backend.database import get_db
from backend.models import Question
from backend.services.excel_service import export_excel_bytes
router = APIRouter(dependencies=[Depends(get_current_user)])
def _filter_questions(
db: Session, chapter: str = "", question_type: str = "", difficulty: str = ""
) -> list[Question]:
query = db.query(Question)
if chapter:
query = query.filter(Question.chapter == chapter)
if question_type:
query = query.filter(Question.question_type == question_type)
if difficulty:
query = query.filter(Question.difficulty == difficulty)
return query.order_by(Question.id.desc()).all()
@router.get("")
def export_questions(
format: str = Query(default="json", pattern="^(json|csv|xlsx)$"),
chapter: str = "",
question_type: str = "",
difficulty: str = "",
db: Session = Depends(get_db),
) -> dict:
items = _filter_questions(db, chapter, question_type, difficulty)
rows = [
{
"id": i.id,
"chapter": i.chapter,
"primary_knowledge": i.primary_knowledge,
"secondary_knowledge": i.secondary_knowledge,
"question_type": i.question_type,
"difficulty": i.difficulty,
"stem": i.stem,
"option_a": i.option_a,
"option_b": i.option_b,
"option_c": i.option_c,
"option_d": i.option_d,
"answer": i.answer,
"explanation": i.explanation,
"notes": i.notes,
"source_file": i.source_file,
}
for i in items
]
if format == "json":
return {"format": "json", "content": json.dumps(rows, ensure_ascii=False)}
if format == "csv":
out = StringIO()
writer = csv.DictWriter(out, fieldnames=rows[0].keys() if rows else ["id"])
writer.writeheader()
for row in rows:
writer.writerow(row)
return {"format": "csv", "content": out.getvalue()}
xlsx_bytes = export_excel_bytes(rows)
return {"format": "xlsx", "content_base64": xlsx_bytes.hex()}

View File

@@ -0,0 +1,97 @@
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from sqlalchemy.orm import Session
from backend.auth import get_current_user
from backend.database import get_db
from backend.models import ImportHistory, Question
from backend.schemas import ImportHistoryOut, QuestionOut
from backend.services.excel_service import create_template_bytes, parse_excel_file
from backend.services.file_utils import save_upload
from backend.services.parser import DMXAPIService, extract_metadata
router = APIRouter(dependencies=[Depends(get_current_user)])
@router.post("/ai/parse")
def parse_by_ai(file: UploadFile = File(...)) -> dict:
path = save_upload(file)
parser = DMXAPIService()
metadata = extract_metadata(file.filename or path.name)
questions = parser.parse_file(str(path))
preview = []
for q in questions:
preview.append(
{
"chapter": metadata["chapter"],
"primary_knowledge": "",
"secondary_knowledge": metadata["secondary_knowledge"],
"question_type": metadata["question_type"],
"difficulty": metadata["difficulty"],
"stem": q.get("题干", ""),
"option_a": q.get("选项A", ""),
"option_b": q.get("选项B", ""),
"option_c": q.get("选项C", ""),
"option_d": q.get("选项D", ""),
"answer": q.get("正确答案", ""),
"explanation": q.get("解析", ""),
"notes": q.get("备注", ""),
"source_file": metadata["source_file"],
}
)
return {"filename": file.filename, "preview": preview}
@router.post("/ai/confirm", response_model=list[QuestionOut])
def confirm_ai_import(payload: list[dict], db: Session = Depends(get_db)) -> list[QuestionOut]:
if not payload:
raise HTTPException(status_code=400, detail="没有可导入数据")
items = [Question(**item) for item in payload]
db.add_all(items)
db.add(
ImportHistory(
filename=items[0].source_file if items else "",
method="ai",
question_count=len(items),
status="success",
)
)
db.commit()
for item in items:
db.refresh(item)
return [QuestionOut.model_validate(item) for item in items]
@router.post("/excel", response_model=list[QuestionOut])
def import_excel(file: UploadFile = File(...), db: Session = Depends(get_db)) -> list[QuestionOut]:
path = save_upload(file)
if Path(path).suffix.lower() not in [".xlsx", ".xlsm", ".xltx", ".xltm"]:
raise HTTPException(status_code=400, detail="仅支持 Excel 文件")
rows = parse_excel_file(Path(path))
items = [Question(**row) for row in rows]
db.add_all(items)
db.add(
ImportHistory(
filename=file.filename or "",
method="excel",
question_count=len(items),
status="success",
)
)
db.commit()
for item in items:
db.refresh(item)
return [QuestionOut.model_validate(item) for item in items]
@router.get("/template")
def download_template() -> dict:
content = create_template_bytes()
return {"filename": "question_template.xlsx", "content_base64": content.hex()}
@router.get("/history", response_model=list[ImportHistoryOut])
def import_history(db: Session = Depends(get_db)) -> list[ImportHistoryOut]:
rows = db.query(ImportHistory).order_by(ImportHistory.created_at.desc()).limit(100).all()
return [ImportHistoryOut.model_validate(r) for r in rows]

View File

@@ -0,0 +1,57 @@
import random
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from backend.auth import get_current_user
from backend.database import get_db
from backend.models import Question
from backend.schemas import PracticeCheckRequest, PracticeStartRequest
router = APIRouter(dependencies=[Depends(get_current_user)])
@router.post("/start")
def start_practice(payload: PracticeStartRequest, db: Session = Depends(get_db)) -> dict:
query = db.query(Question)
if payload.chapter:
query = query.filter(Question.chapter == payload.chapter)
if payload.secondary_knowledge:
query = query.filter(Question.secondary_knowledge == payload.secondary_knowledge)
if payload.question_type:
query = query.filter(Question.question_type == payload.question_type)
if payload.difficulty:
query = query.filter(Question.difficulty == payload.difficulty)
items = query.all()
if payload.random_mode:
random.shuffle(items)
items = items[: payload.limit]
return {
"total": len(items),
"items": [
{
"id": i.id,
"stem": i.stem,
"option_a": i.option_a,
"option_b": i.option_b,
"option_c": i.option_c,
"option_d": i.option_d,
"question_type": i.question_type,
}
for i in items
],
}
@router.post("/check")
def check_answer(payload: PracticeCheckRequest, db: Session = Depends(get_db)) -> dict:
item = db.get(Question, payload.question_id)
if not item:
return {"correct": False, "message": "题目不存在"}
user_answer = payload.user_answer.strip().upper()
right_answer = item.answer.strip().upper()
return {
"correct": user_answer == right_answer,
"right_answer": right_answer,
"explanation": item.explanation,
}

View File

@@ -0,0 +1,136 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from backend.auth import get_current_user
from backend.database import get_db
from backend.models import Question
from backend.schemas import (
BatchDeleteRequest,
BatchUpdateRequest,
QuestionCreate,
QuestionOut,
QuestionUpdate,
)
router = APIRouter(dependencies=[Depends(get_current_user)])
@router.get("")
def list_questions(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=200),
keyword: str = "",
chapter: str = "",
secondary_knowledge: str = "",
question_type: str = "",
difficulty: str = "",
db: Session = Depends(get_db),
) -> dict:
query = db.query(Question)
if keyword:
like = f"%{keyword}%"
query = query.filter(
or_(
Question.stem.like(like),
Question.option_a.like(like),
Question.option_b.like(like),
Question.option_c.like(like),
Question.option_d.like(like),
Question.explanation.like(like),
)
)
if chapter:
query = query.filter(Question.chapter == chapter)
if secondary_knowledge:
query = query.filter(Question.secondary_knowledge == secondary_knowledge)
if question_type:
query = query.filter(Question.question_type == question_type)
if difficulty:
query = query.filter(Question.difficulty == difficulty)
total = query.with_entities(func.count(Question.id)).scalar() or 0
items = (
query.order_by(Question.updated_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return {"total": total, "items": [QuestionOut.model_validate(i).model_dump() for i in items]}
@router.get("/id/{question_id}", response_model=QuestionOut)
def get_question(question_id: int, db: Session = Depends(get_db)) -> QuestionOut:
item = db.get(Question, question_id)
if not item:
raise HTTPException(status_code=404, detail="题目不存在")
return QuestionOut.model_validate(item)
@router.post("", response_model=QuestionOut)
def create_question(payload: QuestionCreate, db: Session = Depends(get_db)) -> QuestionOut:
item = Question(**payload.model_dump())
db.add(item)
db.commit()
db.refresh(item)
return QuestionOut.model_validate(item)
@router.delete("/batch")
def batch_delete(payload: BatchDeleteRequest, db: Session = Depends(get_db)) -> dict:
if not payload.ids:
return {"deleted": 0}
deleted = db.query(Question).filter(Question.id.in_(payload.ids)).delete(synchronize_session=False)
db.commit()
return {"deleted": deleted}
@router.put("/batch/update")
def batch_update(payload: BatchUpdateRequest, db: Session = Depends(get_db)) -> dict:
if not payload.ids:
return {"updated": 0}
updates = payload.model_dump(exclude_none=True, exclude={"ids"})
if not updates:
return {"updated": 0}
updated = db.query(Question).filter(Question.id.in_(payload.ids)).update(
updates, synchronize_session=False
)
db.commit()
return {"updated": updated}
@router.get("/meta/options")
def get_filter_options(db: Session = Depends(get_db)) -> dict:
def distinct_values(column) -> list[str]:
rows = db.query(column).filter(column != "").distinct().all()
return [row[0] for row in rows]
return {
"chapters": distinct_values(Question.chapter),
"secondary_knowledge_list": distinct_values(Question.secondary_knowledge),
"question_types": distinct_values(Question.question_type),
"difficulties": distinct_values(Question.difficulty),
}
@router.put("/{question_id}", response_model=QuestionOut)
def update_question(question_id: int, payload: QuestionUpdate, db: Session = Depends(get_db)) -> QuestionOut:
item = db.get(Question, question_id)
if not item:
raise HTTPException(status_code=404, detail="题目不存在")
update_data = payload.model_dump(exclude_none=True)
for key, value in update_data.items():
setattr(item, key, value)
db.commit()
db.refresh(item)
return QuestionOut.model_validate(item)
@router.delete("/{question_id}")
def delete_question(question_id: int, db: Session = Depends(get_db)) -> dict:
item = db.get(Question, question_id)
if not item:
raise HTTPException(status_code=404, detail="题目不存在")
db.delete(item)
db.commit()
return {"ok": True}

37
backend/routers/stats.py Normal file
View File

@@ -0,0 +1,37 @@
from fastapi import APIRouter, Depends
from sqlalchemy import func
from sqlalchemy.orm import Session
from backend.auth import get_current_user
from backend.database import get_db
from backend.models import ImportHistory, Question
router = APIRouter(dependencies=[Depends(get_current_user)])
@router.get("")
def get_stats(db: Session = Depends(get_db)) -> dict:
total = db.query(func.count(Question.id)).scalar() or 0
by_type = db.query(Question.question_type, func.count(Question.id)).group_by(Question.question_type).all()
by_difficulty = db.query(Question.difficulty, func.count(Question.id)).group_by(Question.difficulty).all()
by_chapter = db.query(Question.chapter, func.count(Question.id)).group_by(Question.chapter).all()
latest_imports = (
db.query(ImportHistory).order_by(ImportHistory.created_at.desc()).limit(10).all()
)
return {
"total": total,
"by_type": [{"name": n or "未分类", "value": v} for n, v in by_type],
"by_difficulty": [{"name": n or "未分类", "value": v} for n, v in by_difficulty],
"by_chapter": [{"name": n or "未分类", "value": v} for n, v in by_chapter],
"latest_imports": [
{
"id": i.id,
"filename": i.filename,
"method": i.method,
"question_count": i.question_count,
"status": i.status,
"created_at": i.created_at.isoformat(),
}
for i in latest_imports
],
}

117
backend/schemas.py Normal file
View File

@@ -0,0 +1,117 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class LoginRequest(BaseModel):
username: str
password: str
class QuestionBase(BaseModel):
chapter: str = ""
primary_knowledge: str = ""
secondary_knowledge: str = ""
question_type: str = ""
difficulty: str = ""
stem: str = Field(..., min_length=1)
option_a: str = ""
option_b: str = ""
option_c: str = ""
option_d: str = ""
answer: str = ""
explanation: str = ""
notes: str = ""
source_file: str = ""
class QuestionCreate(QuestionBase):
pass
class QuestionUpdate(BaseModel):
chapter: Optional[str] = None
primary_knowledge: Optional[str] = None
secondary_knowledge: Optional[str] = None
question_type: Optional[str] = None
difficulty: Optional[str] = None
stem: Optional[str] = None
option_a: Optional[str] = None
option_b: Optional[str] = None
option_c: Optional[str] = None
option_d: Optional[str] = None
answer: Optional[str] = None
explanation: Optional[str] = None
notes: Optional[str] = None
source_file: Optional[str] = None
class QuestionOut(QuestionBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class BatchDeleteRequest(BaseModel):
ids: list[int]
class BatchUpdateRequest(BaseModel):
ids: list[int]
chapter: Optional[str] = None
primary_knowledge: Optional[str] = None
secondary_knowledge: Optional[str] = None
question_type: Optional[str] = None
difficulty: Optional[str] = None
class CategoryBase(BaseModel):
name: str
parent_id: Optional[int] = None
level: int = 1
class CategoryCreate(CategoryBase):
pass
class CategoryOut(CategoryBase):
id: int
class Config:
from_attributes = True
class ImportHistoryOut(BaseModel):
id: int
filename: str
method: str
question_count: int
status: str
created_at: datetime
class Config:
from_attributes = True
class PracticeStartRequest(BaseModel):
chapter: Optional[str] = None
secondary_knowledge: Optional[str] = None
question_type: Optional[str] = None
difficulty: Optional[str] = None
random_mode: bool = False
limit: int = 20
class PracticeCheckRequest(BaseModel):
question_id: int
user_answer: str

View File

@@ -0,0 +1 @@
# Services package marker.

View File

@@ -0,0 +1,70 @@
from io import BytesIO
from pathlib import Path
from openpyxl import Workbook, load_workbook
QUESTION_COLUMNS = [
("chapter", "章节"),
("primary_knowledge", "一级知识点"),
("secondary_knowledge", "二级知识点"),
("question_type", "题目类型"),
("difficulty", "难度"),
("stem", "题干"),
("option_a", "选项A"),
("option_b", "选项B"),
("option_c", "选项C"),
("option_d", "选项D"),
("answer", "正确答案"),
("explanation", "解析"),
("notes", "备注"),
("source_file", "来源文件"),
]
def create_template_bytes() -> bytes:
wb = Workbook()
ws = wb.active
ws.title = "questions"
ws.append([col[1] for col in QUESTION_COLUMNS])
ws.append(["函数", "", "函数性质", "单选", "", "示例题干", "A", "B", "C", "D", "A", "示例解析", "", "template.xlsx"])
buf = BytesIO()
wb.save(buf)
return buf.getvalue()
def parse_excel_file(path: Path) -> list[dict]:
wb = load_workbook(path, read_only=True)
ws = wb.active
rows = list(ws.iter_rows(values_only=True))
if not rows:
return []
headers = [str(v).strip() if v is not None else "" for v in rows[0]]
key_map = {label: key for key, label in QUESTION_COLUMNS}
result: list[dict] = []
for row in rows[1:]:
if not row or all(v is None or str(v).strip() == "" for v in row):
continue
item = {key: "" for key, _ in QUESTION_COLUMNS}
for idx, val in enumerate(row):
if idx >= len(headers):
continue
label = headers[idx]
key = key_map.get(label)
if not key:
continue
item[key] = "" if val is None else str(val)
if item["stem"]:
result.append(item)
return result
def export_excel_bytes(items: list[dict]) -> bytes:
wb = Workbook()
ws = wb.active
ws.title = "questions"
ws.append([label for _, label in QUESTION_COLUMNS])
for item in items:
ws.append([item.get(key, "") for key, _ in QUESTION_COLUMNS])
buf = BytesIO()
wb.save(buf)
return buf.getvalue()

View File

@@ -0,0 +1,20 @@
import shutil
from pathlib import Path
from fastapi import UploadFile
from backend.config import settings
def ensure_upload_dir() -> Path:
target = Path(settings.upload_dir)
target.mkdir(parents=True, exist_ok=True)
return target
def save_upload(upload_file: UploadFile) -> Path:
target_dir = ensure_upload_dir()
target_path = target_dir / upload_file.filename
with target_path.open("wb") as buffer:
shutil.copyfileobj(upload_file.file, buffer)
return target_path

167
backend/services/parser.py Normal file
View File

@@ -0,0 +1,167 @@
import json
import re
import subprocess
from pathlib import Path
import requests
from backend.config import settings
class DMXAPIService:
def __init__(self) -> None:
self.api_key = settings.api_key
self.model_name = settings.model_name
self.api_url = settings.dmxapi_url
def parse_file(self, file_path: str) -> list[dict]:
path = Path(file_path)
if path.suffix.lower() != ".pdf":
pdf_path = self._convert_to_pdf(path)
else:
pdf_path = path
try:
file_url = self._upload_to_temp_host(pdf_path)
questions = self._parse_with_file_url(file_url, path.name)
finally:
if pdf_path != path and pdf_path.exists():
pdf_path.unlink(missing_ok=True)
return questions
def _upload_to_temp_host(self, path: Path) -> str:
try:
with path.open("rb") as f:
response = requests.post("https://file.io", files={"file": f}, timeout=60)
if response.status_code == 200 and response.json().get("success"):
return response.json()["link"]
except Exception:
pass
with path.open("rb") as f:
response = requests.post("https://tmpfiles.org/api/v1/upload", files={"file": f}, timeout=60)
if response.status_code != 200:
raise ValueError(f"上传失败: {response.text}")
data = response.json()
if data.get("status") != "success":
raise ValueError(f"上传失败: {data}")
return data["data"]["url"].replace("tmpfiles.org/", "tmpfiles.org/dl/")
def _convert_to_pdf(self, path: Path) -> Path:
pdf_path = path.with_suffix(".pdf")
pdf_path.unlink(missing_ok=True)
cmd = [
"pandoc",
str(path),
"-o",
str(pdf_path),
"--pdf-engine=xelatex",
"-V",
"CJKmainfont=PingFang SC",
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=90)
if result.returncode != 0:
fallback = [
"pandoc",
str(path),
"-o",
str(pdf_path),
"--pdf-engine=weasyprint",
]
result = subprocess.run(fallback, capture_output=True, text=True, timeout=90)
if result.returncode != 0:
raise ValueError(f"文件转 PDF 失败: {result.stderr}")
return pdf_path
def _parse_with_file_url(self, file_url: str, original_filename: str) -> list[dict]:
if not self.api_key:
raise ValueError("未配置 API_KEY无法调用 DMXAPI")
payload = {
"model": self.model_name,
"input": [
{
"role": "user",
"content": [
{"type": "input_file", "file_url": file_url},
{"type": "input_text", "text": self._build_instruction(original_filename)},
],
}
],
}
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
response = requests.post(
self.api_url, headers=headers, data=json.dumps(payload), timeout=180
)
if response.status_code != 200:
raise ValueError(f"DMXAPI 请求失败: {response.status_code} {response.text}")
return self._extract_questions(response.json())
def _build_instruction(self, filename: str) -> str:
return f"""请从文件\"{filename}\"中提取所有题目信息并以JSON数组格式返回。
提取字段题干、选项A、选项B、选项C、选项D、正确答案、解析、备注。
数学公式统一转为 LaTeX内联公式使用 $...$,独立公式使用 $$...$$。
答案统一大写字母,清理“答案:”等前缀。
仅返回 JSON 数组,不要返回额外说明。"""
def _extract_questions(self, response: dict) -> list[dict]:
if response.get("status") != "completed":
raise ValueError(f"响应状态异常: {response.get('status')}")
text = None
for item in response.get("output", []):
if item.get("type") == "message":
for content in item.get("content", []):
if content.get("type") == "output_text":
text = content.get("text")
break
if not text:
raise ValueError("未在响应中找到文本内容")
questions = self._parse_json(text)
for q in questions:
for field in ["选项A", "选项B", "选项C", "选项D", "解析", "备注"]:
q.setdefault(field, "")
return questions
def _parse_json(self, text: str) -> list[dict]:
start_idx = text.find("[")
end_idx = text.rfind("]")
if start_idx < 0 or end_idx < 0:
raise ValueError(f"未找到 JSON 数组: {text[:200]}")
json_str = text[start_idx : end_idx + 1]
data = json.loads(json_str)
if not isinstance(data, list):
raise ValueError("解析结果不是数组")
for index, item in enumerate(data):
if "题干" not in item or "正确答案" not in item:
raise ValueError(f"{index + 1} 题缺少题干或正确答案")
item["正确答案"] = re.sub(r"[^A-D0-9]", "", str(item.get("正确答案", "")).upper())
return data
def extract_metadata(filename: str) -> dict:
basename = Path(filename).stem
separators = ["+", " ", "-", "_"]
parts = [basename]
for sep in separators:
if sep in basename:
parts = basename.split(sep)
break
secondary = parts[0].strip() if len(parts) > 0 else ""
raw_type = parts[1].strip() if len(parts) > 1 else ""
difficulty = parts[2].strip() if len(parts) > 2 else ""
mapped_type = settings.type_map.get(raw_type, raw_type)
chapter = secondary.split("")[0] if "" in secondary else secondary
chapter = chapter or "未分类"
return {
"chapter": chapter,
"secondary_knowledge": secondary,
"question_type": mapped_type,
"difficulty": difficulty,
"source_file": filename,
}

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>题库管理系统</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5213
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
frontend/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "problem-bank-web",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/charts": "^2.2.7",
"antd": "^5.27.0",
"axios": "^1.11.0",
"katex": "^0.16.11",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.1"
},
"devDependencies": {
"@types/react": "^18.3.24",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.7.0",
"typescript": "^5.9.2",
"vite": "^7.1.3"
}
}

67
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,67 @@
import { Layout, Menu, message } from "antd";
import { useMemo, useState } from "react";
import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import LoginModal from "./components/LoginModal";
import Categories from "./pages/Categories";
import Dashboard from "./pages/Dashboard";
import ExportPage from "./pages/Export";
import ImportPage from "./pages/Import";
import PracticePage from "./pages/Practice";
import QuestionEdit from "./pages/QuestionEdit";
import Questions from "./pages/Questions";
const { Header, Content, Sider } = Layout;
const menuItems = [
{ key: "/dashboard", label: "仪表盘" },
{ key: "/questions", label: "题目管理" },
{ key: "/import", label: "导入中心" },
{ key: "/export", label: "导出" },
{ key: "/categories", label: "分类管理" },
{ key: "/practice", label: "练习模式" }
];
export default function App() {
const navigate = useNavigate();
const location = useLocation();
const [loggedIn, setLoggedIn] = useState(Boolean(localStorage.getItem("pb_token")));
const selectedKey = useMemo(() => {
const hit = menuItems.find((m) => location.pathname.startsWith(m.key));
return hit ? [hit.key] : ["/dashboard"];
}, [location.pathname]);
const logout = () => {
localStorage.removeItem("pb_token");
setLoggedIn(false);
message.info("已退出登录");
};
return (
<Layout style={{ minHeight: "100vh" }}>
<LoginModal open={!loggedIn} onSuccess={() => setLoggedIn(true)} />
<Sider theme="light">
<div className="logo"></div>
<Menu mode="inline" selectedKeys={selectedKey} items={menuItems} onClick={(e) => navigate(e.key)} />
</Sider>
<Layout>
<Header className="header">
<span></span>
<a onClick={logout}>退</a>
</Header>
<Content className="content">
<Routes>
<Route path="/" element={<Navigate to="/dashboard" />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/questions" element={<Questions />} />
<Route path="/questions/:id" element={<QuestionEdit />} />
<Route path="/import" element={<ImportPage />} />
<Route path="/export" element={<ExportPage />} />
<Route path="/categories" element={<Categories />} />
<Route path="/practice" element={<PracticePage />} />
</Routes>
</Content>
</Layout>
</Layout>
);
}

15
frontend/src/api/index.ts Normal file
View File

@@ -0,0 +1,15 @@
import axios from "axios";
const api = axios.create({
baseURL: "http://127.0.0.1:8000/api"
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem("pb_token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export default api;

View File

@@ -0,0 +1,20 @@
import katex from "katex";
interface Props {
text: string;
}
function renderLatexText(text: string): string {
if (!text) return "";
return text.replace(/\$(.+?)\$/g, (_, expr) => {
try {
return katex.renderToString(expr, { throwOnError: false });
} catch {
return `$${expr}$`;
}
});
}
export default function LatexRenderer({ text }: Props) {
return <span dangerouslySetInnerHTML={{ __html: renderLatexText(text) }} />;
}

View File

@@ -0,0 +1,38 @@
import { Button, Form, Input, Modal, message } from "antd";
import api from "../api";
interface Props {
open: boolean;
onSuccess: () => void;
}
export default function LoginModal({ open, onSuccess }: Props) {
const [form] = Form.useForm();
const onFinish = async (values: { username: string; password: string }) => {
try {
const { data } = await api.post("/auth/login", values);
localStorage.setItem("pb_token", data.access_token);
message.success("登录成功");
onSuccess();
} catch {
message.error("登录失败,请检查用户名或密码");
}
};
return (
<Modal open={open} title="管理员登录" footer={null} closable={false} maskClosable={false}>
<Form form={form} layout="vertical" onFinish={onFinish}>
<Form.Item label="用户名" name="username" rules={[{ required: true }]}>
<Input placeholder="admin" />
</Form.Item>
<Form.Item label="密码" name="password" rules={[{ required: true }]}>
<Input.Password />
</Form.Item>
<Button type="primary" htmlType="submit" block>
</Button>
</Form>
</Modal>
);
}

View File

@@ -0,0 +1,24 @@
import { Card, Tag } from "antd";
import LatexRenderer from "./LatexRenderer";
import { Question } from "../types";
interface Props {
question: Question;
}
export default function QuestionCard({ question }: Props) {
return (
<Card
title={`${question.id}. ${question.question_type || "未分类题型"}`}
extra={<Tag>{question.difficulty || "未知难度"}</Tag>}
size="small"
>
<div><LatexRenderer text={question.stem} /></div>
{question.option_a && <div>A. <LatexRenderer text={question.option_a} /></div>}
{question.option_b && <div>B. <LatexRenderer text={question.option_b} /></div>}
{question.option_c && <div>C. <LatexRenderer text={question.option_c} /></div>}
{question.option_d && <div>D. <LatexRenderer text={question.option_d} /></div>}
<div>{question.answer}</div>
</Card>
);
}

15
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,15 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "antd/dist/reset.css";
import "katex/dist/katex.min.css";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);

View File

@@ -0,0 +1,57 @@
import { Button, Card, Form, Input, InputNumber, Modal, Space, Tree, message } from "antd";
import { useEffect, useMemo, useState } from "react";
import api from "../api";
import { CategoryNode } from "../types";
function mapTree(nodes: CategoryNode[]): any[] {
return nodes.map((n) => ({
key: n.id,
title: `${n.name} (${n.count ?? 0})`,
children: n.children ? mapTree(n.children) : []
}));
}
export default function Categories() {
const [items, setItems] = useState<CategoryNode[]>([]);
const [open, setOpen] = useState(false);
const [form] = Form.useForm();
const load = () => api.get("/categories").then((res) => setItems(res.data.items || []));
useEffect(() => {
load();
}, []);
const treeData = useMemo(() => mapTree(items), [items]);
const onCreate = async () => {
await api.post("/categories", form.getFieldsValue());
message.success("分类创建成功");
setOpen(false);
form.resetFields();
load();
};
return (
<Card
title="分类管理"
extra={<Button type="primary" onClick={() => setOpen(true)}></Button>}
>
<Tree treeData={treeData} defaultExpandAll />
<Modal open={open} title="新增分类" onCancel={() => setOpen(false)} onOk={onCreate}>
<Form form={form} layout="vertical">
<Form.Item label="名称" name="name" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Space style={{ width: "100%" }}>
<Form.Item label="父节点ID" name="parent_id">
<InputNumber style={{ width: 120 }} />
</Form.Item>
<Form.Item label="层级" name="level" initialValue={1}>
<InputNumber min={1} max={3} style={{ width: 120 }} />
</Form.Item>
</Space>
</Form>
</Modal>
</Card>
);
}

View File

@@ -0,0 +1,67 @@
import { useEffect, useState } from "react";
import { Card, Col, List, Row, Statistic } from "antd";
import { Column } from "@ant-design/charts";
import api from "../api";
interface StatsData {
total: number;
by_type: { name: string; value: number }[];
by_difficulty: { name: string; value: number }[];
by_chapter: { name: string; value: number }[];
latest_imports: { id: number; filename: string; method: string; question_count: number }[];
}
export default function Dashboard() {
const [stats, setStats] = useState<StatsData>({
total: 0,
by_type: [],
by_difficulty: [],
by_chapter: [],
latest_imports: []
});
useEffect(() => {
api.get("/stats").then((res) => setStats(res.data));
}, []);
return (
<div>
<Row gutter={16}>
<Col span={6}>
<Card><Statistic title="题目总数" value={stats.total} /></Card>
</Col>
<Col span={6}>
<Card><Statistic title="题型数量" value={stats.by_type.length} /></Card>
</Col>
<Col span={6}>
<Card><Statistic title="章节数量" value={stats.by_chapter.length} /></Card>
</Col>
<Col span={6}>
<Card><Statistic title="最近导入次数" value={stats.latest_imports.length} /></Card>
</Col>
</Row>
<Row gutter={16} style={{ marginTop: 16 }}>
<Col span={12}>
<Card title="题型分布">
<Column data={stats.by_type} xField="name" yField="value" />
</Card>
</Col>
<Col span={12}>
<Card title="难度分布">
<Column data={stats.by_difficulty} xField="name" yField="value" />
</Card>
</Col>
</Row>
<Card title="最近导入记录" style={{ marginTop: 16 }}>
<List
dataSource={stats.latest_imports}
renderItem={(item) => (
<List.Item>
{item.filename} | {item.method} | {item.question_count}
</List.Item>
)}
/>
</Card>
</div>
);
}

View File

@@ -0,0 +1,80 @@
import { Button, Card, Form, Radio, Select, Space, message } from "antd";
import api from "../api";
function downloadText(filename: string, content: string, mime: string) {
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function hexToArrayBuffer(hex: string): ArrayBuffer {
const length = hex.length / 2;
const arr = new Uint8Array(length);
for (let i = 0; i < length; i += 1) {
arr[i] = parseInt(hex.substr(i * 2, 2), 16);
}
return arr.buffer;
}
export default function ExportPage() {
const [form] = Form.useForm();
const onExport = async () => {
const values = form.getFieldsValue();
const { data } = await api.get("/export", { params: values });
if (data.format === "json") {
downloadText("questions.json", data.content, "application/json");
} else if (data.format === "csv") {
downloadText("questions.csv", data.content, "text/csv;charset=utf-8");
} else {
const blob = new Blob([hexToArrayBuffer(data.content_base64)], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "questions.xlsx";
a.click();
URL.revokeObjectURL(url);
}
message.success("导出完成");
};
const downloadTemplate = async () => {
const { data } = await api.get("/import/template");
const blob = new Blob([hexToArrayBuffer(data.content_base64)], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = data.filename || "template.xlsx";
a.click();
URL.revokeObjectURL(url);
};
return (
<Card title="导出题库">
<Form form={form} layout="vertical">
<Form.Item name="format" label="导出格式" initialValue="json">
<Radio.Group options={[
{ label: "JSON", value: "json" },
{ label: "CSV", value: "csv" },
{ label: "Excel", value: "xlsx" }
]} />
</Form.Item>
<Form.Item name="chapter" label="章节筛选">
<Select allowClear placeholder="不选则导出全部" />
</Form.Item>
</Form>
<Space>
<Button type="primary" onClick={onExport}></Button>
<Button onClick={downloadTemplate}></Button>
</Space>
</Card>
);
}

View File

@@ -0,0 +1,92 @@
import { Button, Card, Table, Tabs, Upload, message } from "antd";
import type { UploadProps } from "antd";
import { useState } from "react";
import api from "../api";
export default function ImportPage() {
const [preview, setPreview] = useState<Record<string, string>[]>([]);
const [loading, setLoading] = useState(false);
const aiProps: UploadProps = {
name: "file",
customRequest: async ({ file, onSuccess, onError }) => {
try {
setLoading(true);
const formData = new FormData();
formData.append("file", file as File);
const { data } = await api.post("/import/ai/parse", formData);
setPreview(data.preview || []);
message.success("AI 解析完成");
onSuccess?.({});
} catch (err) {
onError?.(err as Error);
} finally {
setLoading(false);
}
}
};
const excelProps: UploadProps = {
name: "file",
customRequest: async ({ file, onSuccess, onError }) => {
try {
const formData = new FormData();
formData.append("file", file as File);
const { data } = await api.post("/import/excel", formData);
message.success(`Excel 导入成功,共 ${data.length}`);
onSuccess?.({});
} catch (err) {
onError?.(err as Error);
}
}
};
const confirmSave = async () => {
await api.post("/import/ai/confirm", preview);
message.success(`已保存 ${preview.length} 道题`);
setPreview([]);
};
return (
<Tabs
items={[
{
key: "ai",
label: "AI 智能导入",
children: (
<Card>
<Upload {...aiProps} showUploadList={false}>
<Button type="primary" loading={loading}> PDF/Word </Button>
</Upload>
<Table
style={{ marginTop: 16 }}
rowKey={(_, index) => String(index)}
dataSource={preview}
columns={[
{ title: "题干", dataIndex: "stem" },
{ title: "答案", dataIndex: "answer", width: 120 },
{ title: "题型", dataIndex: "question_type", width: 120 }
]}
pagination={{ pageSize: 10 }}
/>
<Button type="primary" onClick={confirmSave} disabled={!preview.length}>
</Button>
</Card>
)
},
{
key: "excel",
label: "Excel 导入",
children: (
<Card>
<Upload {...excelProps} showUploadList={false}>
<Button type="primary"> Excel </Button>
</Upload>
</Card>
)
}
]}
/>
);
}

View File

@@ -0,0 +1,71 @@
import { Button, Card, Form, Input, Radio, Space, message } from "antd";
import { useState } from "react";
import api from "../api";
import LatexRenderer from "../components/LatexRenderer";
interface PracticeItem {
id: number;
stem: string;
option_a: string;
option_b: string;
option_c: string;
option_d: string;
}
export default function PracticePage() {
const [form] = Form.useForm();
const [items, setItems] = useState<PracticeItem[]>([]);
const [index, setIndex] = useState(0);
const [feedback, setFeedback] = useState("");
const start = async () => {
const { data } = await api.post("/practice/start", form.getFieldsValue());
setItems(data.items || []);
setIndex(0);
setFeedback("");
};
const check = async (answer: string) => {
if (!items[index]) return;
const { data } = await api.post("/practice/check", {
question_id: items[index].id,
user_answer: answer
});
setFeedback(data.correct ? `正确。解析:${data.explanation || ""}` : `错误,答案 ${data.right_answer}。解析:${data.explanation || ""}`);
if (data.correct) message.success("回答正确");
};
const current = items[index];
return (
<Space direction="vertical" size={16} style={{ width: "100%" }}>
<Card title="练习配置">
<Form form={form} layout="inline">
<Form.Item name="chapter" label="章节"><Input /></Form.Item>
<Form.Item name="question_type" label="题型"><Input /></Form.Item>
<Form.Item name="difficulty" label="难度"><Input /></Form.Item>
<Button type="primary" onClick={start}></Button>
</Form>
</Card>
<Card title={current ? `${index + 1}` : "暂无题目"}>
{current ? (
<>
<p><LatexRenderer text={current.stem} /></p>
<Radio.Group onChange={(e) => check(e.target.value)}>
{current.option_a && <Radio value="A">A. {current.option_a}</Radio>}
{current.option_b && <Radio value="B">B. {current.option_b}</Radio>}
{current.option_c && <Radio value="C">C. {current.option_c}</Radio>}
{current.option_d && <Radio value="D">D. {current.option_d}</Radio>}
</Radio.Group>
<p style={{ marginTop: 12 }}>{feedback}</p>
<Button disabled={index >= items.length - 1} onClick={() => { setIndex((v) => v + 1); setFeedback(""); }}>
</Button>
</>
) : (
<p></p>
)}
</Card>
</Space>
);
}

View File

@@ -0,0 +1,51 @@
import { Button, Card, Form, Input, Select, message } from "antd";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import api from "../api";
export default function QuestionEdit() {
const [form] = Form.useForm();
const navigate = useNavigate();
const { id } = useParams();
useEffect(() => {
if (!id || id === "new") return;
api.get(`/questions/id/${id}`).then((res) => form.setFieldsValue(res.data));
}, [id, form]);
const onSubmit = async (values: Record<string, string>) => {
if (!id || id === "new") {
await api.post("/questions", values);
message.success("新增成功");
} else {
await api.put(`/questions/${id}`, values);
message.success("保存成功");
}
navigate("/questions");
};
return (
<Card title={id === "new" ? "新增题目" : "编辑题目"}>
<Form form={form} layout="vertical" onFinish={onSubmit}>
<Form.Item name="chapter" label="章节"><Input /></Form.Item>
<Form.Item name="primary_knowledge" label="一级知识点"><Input /></Form.Item>
<Form.Item name="secondary_knowledge" label="二级知识点"><Input /></Form.Item>
<Form.Item name="question_type" label="题型">
<Select options={["单选", "多选", "不定项", "填空", "解答"].map((v) => ({ value: v }))} />
</Form.Item>
<Form.Item name="difficulty" label="难度">
<Select options={["易", "中", "难"].map((v) => ({ value: v }))} />
</Form.Item>
<Form.Item name="stem" label="题干" rules={[{ required: true }]}><Input.TextArea rows={4} /></Form.Item>
<Form.Item name="option_a" label="选项A"><Input /></Form.Item>
<Form.Item name="option_b" label="选项B"><Input /></Form.Item>
<Form.Item name="option_c" label="选项C"><Input /></Form.Item>
<Form.Item name="option_d" label="选项D"><Input /></Form.Item>
<Form.Item name="answer" label="答案"><Input /></Form.Item>
<Form.Item name="explanation" label="解析"><Input.TextArea rows={3} /></Form.Item>
<Form.Item name="notes" label="备注"><Input.TextArea rows={2} /></Form.Item>
<Button type="primary" htmlType="submit"></Button>
</Form>
</Card>
);
}

View File

@@ -0,0 +1,119 @@
import { useEffect, useState } from "react";
import {
Button,
Drawer,
Form,
Input,
Popconfirm,
Select,
Space,
Table,
Tag,
message
} from "antd";
import { useNavigate } from "react-router-dom";
import api from "../api";
import LatexRenderer from "../components/LatexRenderer";
import { Question } from "../types";
export default function Questions() {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [items, setItems] = useState<Question[]>([]);
const [total, setTotal] = useState(0);
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [detail, setDetail] = useState<Question | null>(null);
const [meta, setMeta] = useState({
chapters: [] as string[],
secondary_knowledge_list: [] as string[],
question_types: [] as string[],
difficulties: [] as string[]
});
const [form] = Form.useForm();
const fetchData = async () => {
setLoading(true);
const values = form.getFieldsValue();
const { data } = await api.get("/questions", { params: { page: 1, page_size: 50, ...values } });
setItems(data.items);
setTotal(data.total);
setLoading(false);
};
useEffect(() => {
fetchData();
api.get("/questions/meta/options").then((res) => setMeta(res.data));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const batchDelete = async () => {
await api.delete("/questions/batch", { data: { ids: selectedRowKeys } });
message.success("批量删除成功");
setSelectedRowKeys([]);
fetchData();
};
return (
<div>
<Space style={{ marginBottom: 12 }}>
<Button type="primary" onClick={() => navigate("/questions/new")}></Button>
<Button danger disabled={!selectedRowKeys.length} onClick={batchDelete}></Button>
</Space>
<Form form={form} layout="inline" onFinish={fetchData} style={{ marginBottom: 12 }}>
<Form.Item name="keyword">
<Input placeholder="关键词" allowClear />
</Form.Item>
<Form.Item name="chapter">
<Select placeholder="章节" allowClear style={{ width: 150 }} options={meta.chapters.map((v) => ({ value: v }))} />
</Form.Item>
<Form.Item name="question_type">
<Select placeholder="题型" allowClear style={{ width: 130 }} options={meta.question_types.map((v) => ({ value: v }))} />
</Form.Item>
<Form.Item name="difficulty">
<Select placeholder="难度" allowClear style={{ width: 100 }} options={meta.difficulties.map((v) => ({ value: v }))} />
</Form.Item>
<Button htmlType="submit"></Button>
</Form>
<Table
rowKey="id"
loading={loading}
dataSource={items}
rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys }}
columns={[
{ title: "ID", dataIndex: "id", width: 80 },
{ title: "章节", dataIndex: "chapter", width: 120 },
{ title: "题型", dataIndex: "question_type", width: 100, render: (v) => <Tag>{v}</Tag> },
{ title: "难度", dataIndex: "difficulty", width: 80 },
{ title: "题干", dataIndex: "stem", render: (v) => <LatexRenderer text={v} /> },
{
title: "操作",
width: 180,
render: (_, row) => (
<Space>
<Button size="small" onClick={() => setDetail(row)}></Button>
<Button size="small" onClick={() => navigate(`/questions/${row.id}`)}></Button>
<Popconfirm title="确认删除?" onConfirm={async () => { await api.delete(`/questions/${row.id}`); fetchData(); }}>
<Button size="small" danger></Button>
</Popconfirm>
</Space>
)
}
]}
pagination={{ total, pageSize: 50 }}
/>
<Drawer title="题目详情" open={!!detail} onClose={() => setDetail(null)} width={700}>
{detail && (
<div>
<p><b>:</b> <LatexRenderer text={detail.stem} /></p>
<p>A: <LatexRenderer text={detail.option_a} /></p>
<p>B: <LatexRenderer text={detail.option_b} /></p>
<p>C: <LatexRenderer text={detail.option_c} /></p>
<p>D: <LatexRenderer text={detail.option_d} /></p>
<p><b>:</b> {detail.answer}</p>
<p><b>:</b> <LatexRenderer text={detail.explanation} /></p>
</div>
)}
</Drawer>
</div>
);
}

29
frontend/src/styles.css Normal file
View File

@@ -0,0 +1,29 @@
body {
margin: 0;
background: #f5f7fb;
}
.logo {
height: 56px;
line-height: 56px;
text-align: center;
font-weight: 600;
border-bottom: 1px solid #f0f0f0;
}
.header {
background: #fff;
border-bottom: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.header a {
color: #1677ff;
cursor: pointer;
}
.content {
padding: 16px;
}

28
frontend/src/types.ts Normal file
View File

@@ -0,0 +1,28 @@
export interface Question {
id: number;
chapter: string;
primary_knowledge: string;
secondary_knowledge: string;
question_type: string;
difficulty: string;
stem: string;
option_a: string;
option_b: string;
option_c: string;
option_d: string;
answer: string;
explanation: string;
notes: string;
source_file: string;
created_at: string;
updated_at: string;
}
export interface CategoryNode {
id: number;
name: string;
level: number;
parent_id?: number;
count?: number;
children?: CategoryNode[];
}

View File

@@ -0,0 +1,3 @@
export function normalizeLatexInput(input: string): string {
return input.trim();
}

17
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}

9
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173
}
});