first commit
This commit is contained in:
10
.env.example
Normal file
10
.env.example
Normal 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
56
.gitignore
vendored
Normal 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
45
README.md
Normal 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
550
analysis.py
Normal 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
1
backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Backend package marker.
|
||||||
54
backend/auth.py
Normal file
54
backend/auth.py
Normal 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
32
backend/config.py
Normal 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
23
backend/database.py
Normal 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
30
backend/main.py
Normal 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
54
backend/models.py
Normal 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
12
backend/requirements.txt
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
sqlalchemy
|
||||||
|
pydantic
|
||||||
|
pydantic-settings
|
||||||
|
python-jose[cryptography]
|
||||||
|
PyJWT
|
||||||
|
passlib[bcrypt]
|
||||||
|
python-multipart
|
||||||
|
requests
|
||||||
|
openpyxl
|
||||||
|
python-dotenv
|
||||||
1
backend/routers/__init__.py
Normal file
1
backend/routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Routers package marker.
|
||||||
13
backend/routers/auth.py
Normal file
13
backend/routers/auth.py
Normal 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))
|
||||||
74
backend/routers/categories.py
Normal file
74
backend/routers/categories.py
Normal 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}
|
||||||
68
backend/routers/exports.py
Normal file
68
backend/routers/exports.py
Normal 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()}
|
||||||
97
backend/routers/imports.py
Normal file
97
backend/routers/imports.py
Normal 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]
|
||||||
57
backend/routers/practice.py
Normal file
57
backend/routers/practice.py
Normal 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,
|
||||||
|
}
|
||||||
136
backend/routers/questions.py
Normal file
136
backend/routers/questions.py
Normal 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
37
backend/routers/stats.py
Normal 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
117
backend/schemas.py
Normal 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
|
||||||
1
backend/services/__init__.py
Normal file
1
backend/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Services package marker.
|
||||||
70
backend/services/excel_service.py
Normal file
70
backend/services/excel_service.py
Normal 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()
|
||||||
20
backend/services/file_utils.py
Normal file
20
backend/services/file_utils.py
Normal 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
167
backend/services/parser.py
Normal 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
12
frontend/index.html
Normal 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
5213
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
frontend/package.json
Normal file
27
frontend/package.json
Normal 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
67
frontend/src/App.tsx
Normal 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
15
frontend/src/api/index.ts
Normal 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;
|
||||||
20
frontend/src/components/LatexRenderer.tsx
Normal file
20
frontend/src/components/LatexRenderer.tsx
Normal 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) }} />;
|
||||||
|
}
|
||||||
38
frontend/src/components/LoginModal.tsx
Normal file
38
frontend/src/components/LoginModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
frontend/src/components/QuestionCard.tsx
Normal file
24
frontend/src/components/QuestionCard.tsx
Normal 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
15
frontend/src/main.tsx
Normal 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>
|
||||||
|
);
|
||||||
57
frontend/src/pages/Categories.tsx
Normal file
57
frontend/src/pages/Categories.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
frontend/src/pages/Dashboard.tsx
Normal file
67
frontend/src/pages/Dashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
frontend/src/pages/Export.tsx
Normal file
80
frontend/src/pages/Export.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
frontend/src/pages/Import.tsx
Normal file
92
frontend/src/pages/Import.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
frontend/src/pages/Practice.tsx
Normal file
71
frontend/src/pages/Practice.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
frontend/src/pages/QuestionEdit.tsx
Normal file
51
frontend/src/pages/QuestionEdit.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
frontend/src/pages/Questions.tsx
Normal file
119
frontend/src/pages/Questions.tsx
Normal 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
29
frontend/src/styles.css
Normal 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
28
frontend/src/types.ts
Normal 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[];
|
||||||
|
}
|
||||||
3
frontend/src/utils/latex.ts
Normal file
3
frontend/src/utils/latex.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function normalizeLatexInput(input: string): string {
|
||||||
|
return input.trim();
|
||||||
|
}
|
||||||
17
frontend/tsconfig.json
Normal file
17
frontend/tsconfig.json
Normal 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
9
frontend/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user