| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317 |
- """题目查询路由"""
- from fastapi import APIRouter, Request, HTTPException, Query, Depends
- from app.database import (
- get_connection, get_problem, search_problems, search_fts,
- update_problem_metadata, get_next_unannotated,
- )
- from app.routers.auth import verify_token
- from app.services.renderer import render_docx_to_html
- router = APIRouter(dependencies=[])
- @router.get("/stats")
- def get_stats(request: Request, _=Depends(verify_token)):
- """仪表盘统计"""
- from app.database import count_by_question_type, count_by_import_type, count_by_chapter
- config = request.app.state.config
- conn = get_connection(config.db_path)
- try:
- total_row = conn.execute("SELECT COUNT(*) FROM problems").fetchone()
- total = total_row[0] if total_row else 0
- return {
- "total": total,
- "by_question_type": count_by_question_type(conn),
- "by_import_type": count_by_import_type(conn),
- "by_chapter": count_by_chapter(conn),
- }
- finally:
- conn.close()
- @router.get("/problems")
- def list_problems(
- request: Request,
- chapter: str | None = Query(None),
- knowledge_code: str | None = Query(None),
- question_type: str | None = Query(None),
- import_type: str | None = Query(None),
- annotated: str | None = Query(None),
- page: int = Query(1, ge=1),
- page_size: int = Query(20, ge=1, le=10000),
- _=Depends(verify_token),
- ):
- """题目列表(分页+筛选)"""
- config = request.app.state.config
- conn = get_connection(config.db_path)
- try:
- items, total = search_problems(
- conn, chapter=chapter, knowledge_code=knowledge_code,
- question_type=question_type, import_type=import_type,
- annotated=annotated, page=page, page_size=page_size
- )
- # Add body_preview for each item from FTS
- for item in items:
- row = conn.execute(
- "SELECT body FROM problems_fts WHERE problem_id = ?",
- (item["id"],)
- ).fetchone()
- if row and row["body"]:
- # Get first 80 chars, strip question number
- body = row["body"]
- # Strip question number like "1."
- import re
- body = re.sub(r'^\d+[..]\s*', '', body)
- item["body_preview"] = body[:80]
- else:
- item["body_preview"] = ""
- return {
- "items": items,
- "total": total,
- "page": page,
- "page_size": page_size,
- }
- finally:
- conn.close()
- @router.get("/problems/{problem_id}")
- def get_problem_detail(request: Request, problem_id: str, _=Depends(verify_token)):
- """题目详情(元数据 + 渲染 HTML)"""
- config = request.app.state.config
- conn = get_connection(config.db_path)
- try:
- problem = get_problem(conn, problem_id)
- if not problem:
- raise HTTPException(status_code=404, detail={
- "error": {"code": "not_found", "message": f"题目 {problem_id} 不存在"}
- })
- # 渲染 HTML
- docx_path = config.problems_dir / f"{problem_id}.docx"
- if docx_path.exists():
- problem["render_html"] = render_docx_to_html(docx_path, include_answer=True)
- else:
- problem["render_html"] = "<p>文件不存在</p>"
- return problem
- finally:
- conn.close()
- @router.get("/problems/{problem_id}/render")
- def render_problem(
- request: Request,
- problem_id: str,
- include_answer: bool = Query(True),
- _=Depends(verify_token),
- ):
- """渲染题目为 HTML(JSON 格式,用于 Android WebView)"""
- config = request.app.state.config
- docx_path = config.problems_dir / f"{problem_id}.docx"
- if not docx_path.exists():
- raise HTTPException(status_code=404, detail={
- "error": {"code": "not_found", "message": f"题目文件不存在: {problem_id}"}
- })
- html_fragment = render_docx_to_html(docx_path, include_answer=include_answer)
- full_html = _wrap_full_html(html_fragment, config.token)
- return {"html": full_html, "problem_id": problem_id}
- @router.get("/problems/{problem_id}/view")
- def view_problem(
- request: Request,
- problem_id: str,
- include_answer: bool = Query(True),
- ):
- """渲染题目为完整 HTML 页面(用 IFrame 加载,token 从 query string 获取)"""
- config = request.app.state.config
- token = request.query_params.get("token", "")
- if token != config.token:
- raise HTTPException(status_code=401, detail={
- "error": {"code": "unauthorized", "message": "Token 无效"}
- })
- docx_path = config.problems_dir / f"{problem_id}.docx"
- if not docx_path.exists():
- return _wrap_full_html("<p>题目文件不存在</p>", "")
- html_fragment = render_docx_to_html(docx_path, include_answer=include_answer)
- return HTMLResponse(content=_wrap_full_html(html_fragment, ""))
- def _wrap_full_html(body: str, token: str = "") -> str:
- """将 pandoc HTML 片段包装成完整的 HTML 页面"""
- return f"""<!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <script>
- window.MathJax = {{
- tex: {{
- inlineMath: [['\\\\(', '\\\\)']],
- displayMath: [['\\\\[', '\\\\]']]
- }},
- svg: {{ fontCache: 'global' }}
- }};
- </script>
- <script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js" async></script>
- <style>
- body {{
- font-family: '宋体', 'SimSun', 'Noto Serif CJK SC', 'STSong', serif;
- font-size: 18px;
- line-height: 2.0;
- color: #333;
- padding: 16px;
- max-width: 100%;
- overflow-x: hidden;
- }}
- img {{ max-width: 100%; height: auto; }}
- blockquote {{
- margin: 8px 0;
- padding: 4px 12px;
- border-left: 3px solid #e0e0e0;
- color: #555;
- }}
- p {{ margin: 6px 0; }}
- </style>
- </head>
- <body>
- {body}
- <script>
- if (window.MathJax) {{
- MathJax.startup.promise.then(() => {{
- MathJax.typesetPromise();
- }});
- }}
- </script>
- </body>
- </html>"""
- from fastapi.responses import HTMLResponse
- @router.get("/problems/next-unannotated")
- def next_unannotated(
- request: Request,
- current_id: str | None = Query(None),
- direction: str = Query("next"),
- _=Depends(verify_token),
- ):
- """连续标注导航"""
- config = request.app.state.config
- conn = get_connection(config.db_path)
- try:
- problem = get_next_unannotated(conn, current_id, direction)
- return problem or {}
- finally:
- conn.close()
- @router.put("/problems/{problem_id}/metadata")
- def update_metadata(request: Request, problem_id: str, body: dict, _=Depends(verify_token)):
- """更新题目元数据"""
- config = request.app.state.config
- conn = get_connection(config.db_path)
- try:
- problem = get_problem(conn, problem_id)
- if not problem:
- raise HTTPException(status_code=404, detail={
- "error": {"code": "not_found", "message": f"题目 {problem_id} 不存在"}
- })
- update_problem_metadata(conn, problem_id, body)
- # 同时更新 docx 自定义属性
- docx_path = config.problems_dir / f"{problem_id}.docx"
- if docx_path.exists():
- _update_docx_props(docx_path, body)
- # 自动备份
- try:
- from app.services.backup import auto_backup
- auto_backup(config, changed_files=[str(config.problems_dir / f"{problem_id}.docx")])
- except Exception:
- pass
- return {"ok": True}
- finally:
- conn.close()
- @router.put("/problems/{problem_id}/knowledge")
- def update_knowledge(request: Request, problem_id: str, body: dict, _=Depends(verify_token)):
- """更新知识点编码"""
- config = request.app.state.config
- codes = body.get("knowledge_codes", [])
- conn = get_connection(config.db_path)
- try:
- problem = get_problem(conn, problem_id)
- if not problem:
- raise HTTPException(status_code=404, detail={
- "error": {"code": "not_found", "message": f"题目 {problem_id} 不存在"}
- })
- update_problem_metadata(conn, problem_id, {"knowledge_codes": codes})
- # 更新 docx 自定义属性
- import json
- docx_path = config.problems_dir / f"{problem_id}.docx"
- if docx_path.exists():
- _update_docx_props(docx_path, {"knowledge_codes": json.dumps(codes, ensure_ascii=False)})
- # 自动运行增量索引以更新章节
- from app.services.indexer import incremental_update
- incremental_update(config)
- # 自动备份
- try:
- from app.services.backup import auto_backup
- auto_backup(config, changed_files=[str(config.problems_dir / f"{problem_id}.docx")])
- except Exception:
- pass
- return {"ok": True}
- finally:
- conn.close()
- @router.get("/search")
- def search_problems_api(
- request: Request,
- q: str = Query(..., min_length=1),
- page: int = Query(1, ge=1),
- page_size: int = Query(20, ge=1, le=100),
- _=Depends(verify_token),
- ):
- """全文搜索"""
- from app.services.search import search
- config = request.app.state.config
- return search(config, q, page, page_size)
- def _update_docx_props(docx_path, fields: dict):
- """更新 docx 自定义属性"""
- from docx import Document
- import json
- try:
- doc = Document(str(docx_path))
- for key, value in fields.items():
- if isinstance(value, list):
- value = json.dumps(value, ensure_ascii=False)
- try:
- doc.custom_document_properties[key] = value
- except Exception:
- pass
- doc.save(str(docx_path))
- except Exception:
- pass
|