refactor: React 前端重构,新旧平台合并为莞易学

This commit is contained in:
study-tool
2026-10-29 23:50:46 +08:00
parent 35853891c9
commit e8634a6317
62 changed files with 8923 additions and 4623 deletions
+165
View File
@@ -0,0 +1,165 @@
"""课程 / 发现 / 学习 / 停止 / 抓包 / 快照 / 历史日志接口。"""
import time
from fastapi.responses import HTMLResponse, JSONResponse
from study_tool.webapp import app
from study_tool import store
from study_tool.capture import *
from study_tool.config import *
from study_tool.engine import *
from study_tool.models import *
from study_tool.platforms import registry
from study_tool.platforms.jspx import *
from study_tool.platforms.study import *
from study_tool.runtime import RUNS, RUNS_LOCK, log
# ------------------------------------------------------------------ 平台
@app.get("/api/platforms")
def api_platforms():
"""已注册平台;前端用 label 展示,例如「莞易学」。"""
return {"ok": True, "platforms": [{"id": p.id, "label": p.label} for p in registry.all()]}
# ------------------------------------------------------------------ 运行日志 / 快照
@app.get("/api/log/history")
def api_log_history(key: str = "", tail: int = 200):
"""从 SQLite 读历史日志;不带 key 时列出每个账号存了多少行。"""
if not key:
return {"ok": True, "keys": store.run_log_keys()}
return {"ok": True, "key": key, "lines": store.run_log_tail(key, tail)}
@app.delete("/api/log/history")
def api_log_history_clear(key: str = ""):
"""清历史日志;带 key 时连内存里的那一段也清掉,前端不用刷新页面。"""
store.run_log_clear(key)
with RUNS_LOCK:
st = RUNS.get(key)
if st is not None:
st["log"] = []
return {"ok": True}
@app.get("/api/snapshots")
def api_snapshots(limit: int = 50):
"""站点改版时存下的课程页原始 HTML 列表。"""
return {"ok": True, "snapshots": store.snapshot_list(limit)}
@app.get("/api/snapshot/{sid}", response_class=HTMLResponse)
def api_snapshot(sid: int):
row = store.snapshot_get(sid)
if not row:
return HTMLResponse("<h3>快照不存在</h3>", status_code=404)
head = ("<!-- id=%s planId=%s projectId=%s final url: %s created=%s -->\n"
% (row["id"], row["plan_id"], row["project_id"], row["url"],
time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(row["created"] or 0))))
return HTMLResponse(head + (row["html"] or ""))
@app.post("/api/courses")
def api_courses(b: CookiesIn):
if not b.cookies:
return JSONResponse({"ok": False, "code": "no_cookie",
"error": "本机没有该账号的会话 cookie,请先点「登录」"}, status_code=200)
s = new_session(b.cookies)
if not s.cookies:
return JSONResponse({"ok": False, "code": "no_cookie",
"error": "保存的 cookie 全部被过滤(domain 不含 dgjy),请重新登录"},
status_code=200)
try:
courses = scrape_courses(s)
except SessionExpired as e:
return JSONResponse({"ok": False, "code": "session_expired", "error": str(e)[:300]}, status_code=200)
except Exception as e:
return JSONResponse({"ok": False, "code": "fetch_failed", "error": str(e)[:300]}, status_code=200)
if not courses:
return JSONResponse({"ok": False, "code": "no_course",
"error": "登录态有效,但该账号名下三个板块(在学/助学/督学)"
"的进行中、未开始、已结束里都没有课程"}, status_code=200)
try:
user = whoami(fetch_admin(s))
except Exception:
user = ""
return {"ok": True, "courses": courses, "user": user,
"in_study": len([c for c in courses if c["in_study"]]),
# 补 jspx 会话时 cookie 会换新(JSESSIONID/route),回传让前端写回 IndexedDB
"cookies": dump_cookies(s)}
@app.post("/api/discover")
def api_discover(b: DiscoverIn):
s = new_session(b.cookies)
try:
d = discover(s, b.plan_id, b.project_id, b.name)
return {"ok": True, **d, "cookies": dump_cookies(s)}
except Exception as e:
return JSONResponse({"ok": False, "error": str(e)[:300]}, status_code=200)
@app.post("/api/study")
def api_study(b: StudyIn):
st = RUNS.get(b.key) or {}
if st.get("status") in ("discovering", "studying"):
return JSONResponse({"ok": False, "error": "该账号正在学习中"}, status_code=200)
if not b.cookies:
return JSONResponse({"ok": False, "error": "尚未登录"}, status_code=200)
courses = b.courses or []
if not courses and b.plan_id and b.project_id:
courses = [{"name": "", "plan_id": b.plan_id, "project_id": b.project_id}]
if not courses:
return JSONResponse({"ok": False, "error": "没有选中任何课程"}, status_code=200)
set_state(b.key, status="discovering", log=[], done=0, total=0, started=time.time())
# 会话失效时后台要能自动重登:优先用请求里的密码,没有就按 key 从 SQLite 取
pw = b.password or store.account_password(b.key) or ""
st = load_settings()["study"]
threading.Thread(target=study_worker,
args=(b.key, b.username, b.cookies, courses,
max(1, min(b.workers, 95)), b.cap_minutes, b.quiz_key,
b.enable_quiz, b.enable_feedback, pw,
st.get("mode") or "fast", int(st.get("parallel") or 2)),
daemon=True).start()
return {"ok": True, "courses": len(courses),
"enable_quiz": b.enable_quiz, "enable_feedback": b.enable_feedback,
"study": st}
@app.post("/api/stop")
def api_stop(b: StopIn):
set_state(b.key, status="stopped")
log(b.key, "用户请求停止")
return {"ok": True}
@app.post("/api/capture")
def api_capture(b: CaptureIn):
"""开一个可见的浏览器让用户手动走一遍,记录所有 /train/** 请求与响应。"""
if (RUNS.get(b.key) or {}).get("capturing"):
return JSONResponse({"ok": False, "error": "正在抓包"}, status_code=200)
if not b.cookies:
return JSONResponse({"ok": False, "error": "尚未登录"}, status_code=200)
CAPTURES[b.key] = []
set_state(b.key, capturing=True, capture_stop=False, capture_file="")
log(b.key, "正在打开浏览器…")
threading.Thread(target=capture_worker,
args=(b.key, b.cookies, max(1, min(int(b.minutes or 10), 30))),
daemon=True).start()
return {"ok": True}
@app.post("/api/capture/stop")
def api_capture_stop(b: StopIn):
set_state(b.key, capture_stop=True)
log(b.key, "用户请求停止抓包")
return {"ok": True}
@app.get("/api/capture")
def api_capture_get(key: str = ""):
st = RUNS.get(key) or {}
return {"ok": True, "capturing": bool(st.get("capturing")),
"file": st.get("capture_file") or "",
"records": CAPTURES.get(key) or []}