feat: 添加账号支持选择学习平台

账号新增 platform 字段,登录/刷新课程/发现课程按平台分发;同时把 JS 脚本收进 script/(目录规范调整)。
This commit is contained in:
study-tool
2026-10-30 01:13:47 +08:00
parent e8634a6317
commit 1a3347f7f9
29 changed files with 449 additions and 297 deletions
+3 -3
View File
@@ -30,10 +30,10 @@
## 代码结构 / 平台插件 ## 代码结构 / 平台插件
- 业务代码都在 `src/study_tool/` 包里;根目录只放启动壳 `app.py`、`pyproject.toml`、`static/`、`data/`、`des*.js` 这类运行时资源。 - 业务代码都在 `src/study_tool/` 包里;根目录只放启动壳 `app.py`、`pyproject.toml`、`static/`、`data/`、`script/` 这类运行时资源。散落的 JS 脚本统一收进 `script/`,不参与运行的参考脚本放 `script/reference/`。
- 平台代码只能放在 `src/study_tool/platforms/` 下;一个平台只注册一次(`Platform` + `registry.register`),并在 `Platform.entries` 里声明登录 / 学习入口。同一平台的不同页面代际可以拆成实现模块(如莞易学的 `jspx.py` / `study.py`),由平台模块按 `course["generation"]` 分发。 - 平台代码只能放在 `src/study_tool/platforms/` 下;一个平台只注册一次(`Platform` + `registry.register`),并在 `Platform.entries` 里声明登录 / 学习入口。同一平台的不同页面代际可以拆成实现模块(如莞易学的 `jspx.py` / `study.py`),由平台模块按 `course["generation"]` 分发。
- 新增平台的标准步骤:写 `platforms/<名字>.py`(实现 `study(key, s, d, **opts)`、声明 `entries`)→ 在 `platforms/__init__.py` 的 `load_builtin()` 里 import → 结束。不要改 `engine.py`、`routes/`、前端。 - 新增平台的标准步骤:写 `platforms/<名字>.py`(实现 `study(key, s, d, **opts)`、声明 `entries`,需要登录 / 课程列表 / 发现时再实现对应可选入口)→ 在 `platforms/__init__.py` 的 `load_builtin()` 里 import → 结束。不要改 `engine.py`、`routes/`、前端。
- 引擎、路由、前端都不允许直接 import 某个具体平台模块;需要平台能力时用 `registry.get(course["platform"])`;识别平台用 `registry.detect(url, html)`。 - 引擎、路由、前端都不允许直接 import 某个具体平台模块;需要平台能力时用 `registry.get(course["platform"])`;账号级操作(登录 / 刷新课程 / 发现课程 / 会话)按 `accounts.platform` 用 `registry.get()` 取 `Platform` 上的 `session` / `dump_cookies` / `login` / `list_courses` / `discover` / `renew_session` / `profile`;「添加账号」的平台下拉来自 `GET /api/platforms`。
- 前端源码在 `web/`(React + TypeScript + Vite + Tailwind CSS),构建产物输出到 `static/` 并提交进仓库;改完前端必须执行 `cd web && pnpm build`,不要直接改 `static/` 里的产物。 - 前端源码在 `web/`(React + TypeScript + Vite + Tailwind CSS),构建产物输出到 `static/` 并提交进仓库;改完前端必须执行 `cd web && pnpm build`,不要直接改 `static/` 里的产物。
- 新代码的 import 一律显式(如 `from study_tool.config import JSPX`),不要再新增 `from xxx import *`;历史遗留的 star import 只保留在拆分前就存在的模块里。 - 新代码的 import 一律显式(如 `from study_tool.config import JSPX`),不要再新增 `from xxx import *`;历史遗留的 star import 只保留在拆分前就存在的模块里。
- `docs/architecture.md` 维护分层与插件契约,改动结构时同步更新。 - `docs/architecture.md` 维护分层与插件契约,改动结构时同步更新。
+4
View File
@@ -7,10 +7,13 @@
- 前端从单文件 `static/index.html`(原生 JS)改为 `web/` 工程:React 19 + TypeScript + Vite + Tailwind CSS v4;Vite 直接输出到 `static/`,FastAPI 托管 `/` 与 `/assets`,运行 Python 不再需要 Node - 前端从单文件 `static/index.html`(原生 JS)改为 `web/` 工程:React 19 + TypeScript + Vite + Tailwind CSS v4;Vite 直接输出到 `static/`,FastAPI 托管 `/` 与 `/assets`,运行 Python 不再需要 Node
- 新旧学习平台合并为「莞易学」:`jspx.dgjy.net`(登录入口)与 `study.dgjy.net`(学习入口)统一注册为 `Platform(id="guanyixue", label="莞易学")`,课程页代际(`legacy` / `spa`)由 `platforms/detect.py` 判断,平台内部按代际分发到 `jspx.py` / `study.py` - 新旧学习平台合并为「莞易学」:`jspx.dgjy.net`(登录入口)与 `study.dgjy.net`(学习入口)统一注册为 `Platform(id="guanyixue", label="莞易学")`,课程页代际(`legacy` / `spa`)由 `platforms/detect.py` 判断,平台内部按代际分发到 `jspx.py` / `study.py`
- `Platform` 新增 `entries`(登录 / 学习入口 URL 片段)与 `registry.detect()`;课程发现按入口识别平台,新增平台不再需要改引擎。新增 `GET /api/platforms` 供前端展示平台名 - `Platform` 新增 `entries`(登录 / 学习入口 URL 片段)与 `registry.detect()`;课程发现按入口识别平台,新增平台不再需要改引擎。新增 `GET /api/platforms` 供前端展示平台名
- 添加账号新增「学习平台」下拉(数据来自 `GET /api/platforms`);`accounts` 表新增 `platform` 字段(老库启动时自动补列),账号选定平台后,登录 / 刷新课程 / 发现课程分别走该平台的 `login` / `list_courses` / `discover`,学习与测验按 `course["platform"]` + `generation` 分发
- `Platform` 增加账号级可选入口:`session` / `dump_cookies` / `login` / `list_courses` / `discover` / `renew_session` / `profile`;引擎和路由按 `registry.get(account["platform"])` 取实现,接入第二个平台只需写一个模块
- 测验交卷从 `platform`(jspx / study)改为 `generation`(legacy / spa),接口返回 `platform="guanyixue"` + `generation` - 测验交卷从 `platform`(jspx / study)改为 `generation`(legacy / spa),接口返回 `platform="guanyixue"` + `generation`
- 代码改为标准 `src/` 包布局:业务代码全部在 `src/study_tool/`,根目录 `app.py` 只做启动壳(`python app.py`、`start.bat`、Dockerfile 都不用改);新增 `pyproject.toml`,`pip install -e .` 后可用 `study-tool` 命令 - 代码改为标准 `src/` 包布局:业务代码全部在 `src/study_tool/`,根目录 `app.py` 只做启动壳(`python app.py`、`start.bat`、Dockerfile 都不用改);新增 `pyproject.toml`,`pip install -e .` 后可用 `study-tool` 命令
- 平台拆成插件包 `src/study_tool/platforms/`:`base.py` 定义 `Platform` 契约、`registry.py` 注册表;引擎按 `course["platform"]` 从注册表分发 - 平台拆成插件包 `src/study_tool/platforms/`:`base.py` 定义 `Platform` 契约、`registry.py` 注册表;引擎按 `course["platform"]` 从注册表分发
- HTTP 接口收进 `routes/`(accounts / misc / logs),`docs/architecture.md` 说明分层、平台识别与新增平台的步骤 - HTTP 接口收进 `routes/`(accounts / misc / logs),`docs/architecture.md` 说明分层、平台识别与新增平台的步骤
- 散落的 JS 脚本统一收进 `script/`:运行必需的 `des.js` / `desenc.js` 放 `script/`(`config.py` 的 `DESENC` 指向 `script/desenc.js`),不参与运行的 `dgjy-toolkit.user.js`、`study-axios.mjs` 移到 `script/reference/` 作参考
### 新增 ### 新增
@@ -20,6 +23,7 @@
### 修复 ### 修复
- 账号接口的错误分支引用未导入的 `JSONResponse`、删除账号引用未导入的 `RUNS` / `RUNS_LOCK`,登录失败 / 删除账号会 500:补齐导入
- `discover()` 引用未定义的 `detect_platform` / `has_study_marker` 导致课程发现 `NameError`:代际识别集中到 `platforms/detect.py`,两代页面共用同一套判断 - `discover()` 引用未定义的 `detect_platform` / `has_study_marker` 导致课程发现 `NameError`:代际识别集中到 `platforms/detect.py`,两代页面共用同一套判断
- 系统设置里的大模型配置保存后重新打开会回到默认值:`save_settings` 写的是嵌套结构 `{llm, study}`,`load_settings` 却按顶层字段读,现在改成从 `llm` 子对象读取 - 系统设置里的大模型配置保存后重新打开会回到默认值:`save_settings` 写的是嵌套结构 `{llm, study}`,`load_settings` 却按顶层字段读,现在改成从 `llm` 子对象读取
- AI 答题整段同步等待导致浏览器 / 反代超时:`/api/quiz/answer` 改为流式(text/event-stream)分批返回,每批失败自动重试一次 - AI 答题整段同步等待导致浏览器 / 反代超时:`/api/quiz/answer` 改为流式(text/event-stream)分批返回,每批失败自动重试一次
+1 -1
View File
@@ -17,7 +17,7 @@ ENV PYTHONUNBUFFERED=1 \
WORKDIR /app WORKDIR /app
# nodejs: desenc.js 要用(登录表单 rsa 字段走站点原版 DES 算法) # nodejs: script/desenc.js 要用(登录表单 rsa 字段走站点原版 DES 算法)
# curl: 健康检查用 # curl: 健康检查用
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends nodejs ca-certificates curl tzdata \ && apt-get install -y --no-install-recommends nodejs ca-certificates curl tzdata \
+28
View File
@@ -2,6 +2,34 @@
已实现、但还需要你实测确认的事项。确认没问题后把对应条目删掉。 已实现、但还需要你实测确认的事项。确认没问题后把对应条目删掉。
## 添加账号选择平台
**状态**:已实现,待实测确认。
### 做了什么
- 「添加账号」弹窗最上面新增「学习平台」下拉,选项来自 `GET /api/platforms`(当前只有「莞易学」)。
- 账号记录新增 `platform` 字段(SQLite 老库启动时自动 `ALTER TABLE` 补列)。
- 登录(`/api/login`)、刷新课程(`/api/courses`)、发现课程(`/api/discover`、`/api/quiz`)、
开始学习(引擎 `run_one_course`)都按账号所选平台取 `Platform` 上的实现;选「莞易学」时
仍然自动识别旧版 / 新版课程页。
- 以后新增平台:在 `platforms/<名字>.py` 实现 `session` / `login` / `list_courses` / `discover` /
`study` 并注册,下拉里就会多一个选项。
### 怎么测
1. 打开「添加账号」,确认最上面能看到「学习平台 / 莞易学」。
2. 正常添加账号并登录,卡片右上角显示所选平台的 label(当前为「莞易学」)。
3. 用一个已有账号点「刷新课程」「开始学习」,行为应与改动前一致(旧版 / 新版都能跑)。
### 如果没生效
- 下拉是空的:`GET /api/platforms` 应返回 `[{id, label}]`;检查 `platforms/__init__.py` 的
`load_builtin()` 是否导入了平台模块。
- 登录报「平台 xxx 还没有实现登录」:该平台的 `Platform.login` 没注册或 id 对不上。
- 老库启动报 `no such column: platform`:删掉 `data/study.db` 重启(当前账号数据为空),
或确认 `store._ensure_columns()` 已执行。
## 新旧平台合并为「莞易学」 ## 新旧平台合并为「莞易学」
**状态**:已实现,待实测确认。 **状态**:已实现,待实测确认。
+8 -6
View File
@@ -1,4 +1,4 @@
# 莞易学多账号学习控制台 # 学习控制台
莞易学(`jspx.dgjy.net` / `study.dgjy.net`,新旧版课程页自动识别)的多账号挂机控制台。账号、会话、课程缓存、运行日志、系统设置全部存在服务端 SQLite,浏览器只是界面,换电脑、远程打开都在。 莞易学(`jspx.dgjy.net` / `study.dgjy.net`,新旧版课程页自动识别)的多账号挂机控制台。账号、会话、课程缓存、运行日志、系统设置全部存在服务端 SQLite,浏览器只是界面,换电脑、远程打开都在。
@@ -100,7 +100,7 @@ pnpm typecheck
## 用法 ## 用法
1. 右上角「添加账号」,填账号密码(存进服务端 SQLite)。 1. 右上角「添加账号」→ 选「学习平台」(当前只有莞易学)→ 填账号密码(存进服务端 SQLite)。
2. 点「登录」→ 自动读 `/admin/main.action` 的「在学课程」。 2. 点「登录」→ 自动读 `/admin/main.action` 的「在学课程」。
3. 勾选课程 → 「开始学习」→ 卡片底部「课程日志」展开看实时进度。 3. 勾选课程 → 「开始学习」→ 卡片底部「课程日志」展开看实时进度。
4. 「自动交反馈表」默认开;「自动交测验」默认关(答题正确率无法保证)。 4. 「自动交反馈表」默认开;「自动交测验」默认关(答题正确率无法保证)。
@@ -148,7 +148,7 @@ src/study_tool/
app.py 组装:FastAPI、鉴权中间件、页面 / health、启动 main() app.py 组装:FastAPI、鉴权中间件、页面 / health、启动 main()
webapp.py FastAPI 实例(路由模块从这里注册,避免循环导入) webapp.py FastAPI 实例(路由模块从这里注册,避免循环导入)
runtime.py 运行状态 + 统一日志源(控制台 / 内存 / SQLite) runtime.py 运行状态 + 统一日志源(控制台 / 内存 / SQLite)
config.py 常量、环境变量、根目录定位(data / static / desenc.js) config.py 常量、环境变量、根目录定位(data / static / script)
store.py SQLite3:账号 / 设置 / 登录记录 / 运行日志 / 状态 / 缓存 / 快照 store.py SQLite3:账号 / 设置 / 登录记录 / 运行日志 / 状态 / 缓存 / 快照
models.py HTTP 请求模型 models.py HTTP 请求模型
settings_store.py 系统设置读写 settings_store.py 系统设置读写
@@ -169,7 +169,9 @@ src/study_tool/
logs.py /api/log、/api/runs logs.py /api/log、/api/runs
web/ 前端源码(React 19 + TypeScript + Vite + Tailwind CSS v4) web/ 前端源码(React 19 + TypeScript + Vite + Tailwind CSS v4)
static/ 前端构建产物(index.html + assets/ + favicon.svg) static/ 前端构建产物(index.html + assets/ + favicon.svg)
des.js desenc.js DES 加密(登录表单 rsa 字段,站点原版算法) script/des.js 服务端 DES 加密(登录表单 rsa 字段,站点原版算法)
script/desenc.js DES 命令行封装(Node 调用,config.py 里定位)
script/reference/ 不参与运行的参考脚本:油猴 dgjy-toolkit.user.js、study-axios.mjs
start.bat 一键启动(Windows) start.bat 一键启动(Windows)
Dockerfile Docker 镜像(python:3.12-slim + nodejs + chromium) Dockerfile Docker 镜像(python:3.12-slim + nodejs + chromium)
docker-compose.yml Docker 编排:端口、数据卷、AUTH_TOKEN、健康检查 docker-compose.yml Docker 编排:端口、数据卷、AUTH_TOKEN、健康检查
@@ -178,11 +180,11 @@ requirements.txt 依赖
data/study.db SQLite 库(账号/设置/日志/快照) data/study.db SQLite 库(账号/设置/日志/快照)
docs/architecture.md 分层说明 + 平台插件怎么加 docs/architecture.md 分层说明 + 平台插件怎么加
study_video.py 独立命令行工具:按 trainCourseId 直接刷视频课时(会话也存 study.db) study_video.py 独立命令行工具:按 trainCourseId 直接刷视频课时(会话也存 study.db)
dgjy-toolkit.user.js 浏览器油猴脚本(独立使用,与网页端无关)
``` ```
分层与插件契约见 [docs/architecture.md](docs/architecture.md):引擎只按 `course["platform"]` 分层与插件契约见 [docs/architecture.md](docs/architecture.md):引擎只按 `course["platform"]`
从注册表取平台实现;平台用 `entries` 声明登录 / 学习入口,新增平台不用改 engine / 路由 / 前端。 从注册表取平台实现;平台用 `entries` 声明登录 / 学习入口,并可按需实现 `login` / `list_courses` /
`discover` 等账号级入口;「添加账号」选的平台存进账号记录,登录与课程发现按它分发,新增平台不用改 engine / 路由 / 前端。
日志统一从 `runtime.log()` 出:控制台、页面卡片「课程日志」(按账号 id)、右上角「系统日志」(key=system) 日志统一从 `runtime.log()` 出:控制台、页面卡片「课程日志」(按账号 id)、右上角「系统日志」(key=system)
看到的是同一份;新增日志不要再自己 `print()`。 看到的是同一份;新增日志不要再自己 `print()`。
+21 -9
View File
@@ -10,7 +10,7 @@ src/study_tool/
__main__.py python -m study_tool 入口 __main__.py python -m study_tool 入口
app.py 组装:FastAPI、鉴权中间件、页面 / health、启动 main() app.py 组装:FastAPI、鉴权中间件、页面 / health、启动 main()
webapp.py FastAPI 实例(路由模块从这里注册,避免循环导入) webapp.py FastAPI 实例(路由模块从这里注册,避免循环导入)
config.py 常量 / 环境变量 / 根目录定位(data、static、desenc.js) config.py 常量 / 环境变量 / 根目录定位(data、static、script)
runtime.py 运行状态 RUNS + 统一日志 log() runtime.py 运行状态 RUNS + 统一日志 log()
store.py SQLite:账号 / 设置 / 日志 / 状态 / 缓存 / 快照 store.py SQLite:账号 / 设置 / 日志 / 状态 / 缓存 / 快照
models.py HTTP 请求模型(Pydantic) models.py HTTP 请求模型(Pydantic)
@@ -32,7 +32,8 @@ src/study_tool/
logs.py /api/log、/api/runs logs.py /api/log、/api/runs
web/ 前端源码(React 19 + TypeScript + Vite + Tailwind CSS v4) web/ 前端源码(React 19 + TypeScript + Vite + Tailwind CSS v4)
static/ 前端构建产物(index.html + assets/ + favicon.svg,提交进仓库) static/ 前端构建产物(index.html + assets/ + favicon.svg,提交进仓库)
des*.js 服务端 DES 加密脚本(Node 调用) script/ 服务端 JS 脚本:des.js、desenc.js(Node 调用)
script/reference/ 不参与运行的参考脚本:dgjy-toolkit.user.js、study-axios.mjs
``` ```
## 分层 ## 分层
@@ -63,11 +64,20 @@ class Platform:
label: str # 日志 / 界面展示名,如「莞易学」 label: str # 日志 / 界面展示名,如「莞易学」
study: Callable # (key, s, d, **opts) -> None study: Callable # (key, s, d, **opts) -> None
entries: tuple[str, ...] # 登录入口 / 学习入口的 URL 片段 entries: tuple[str, ...] # 登录入口 / 学习入口的 URL 片段
# 账号级能力:添加账号时选中的平台决定登录 / 刷新课程 / 发现课程走哪套实现
session: Callable # (cookies) -> requests.Session
dump_cookies: Callable # (session) -> [cookie]
login: Callable # (username, password) -> (session, user)
list_courses: Callable # (session) -> [course]
discover: Callable # (session, plan_id, project_id, name) -> 课程描述
renew_session: Callable # (session) -> bool,无密码续期
profile: Callable # (session) -> 显示名
``` ```
`registry.detect(url, html)` 按 `entries` 命中平台;课程发现时把平台 id 写进 `registry.detect(url, html)` 按 `entries` 命中平台;「添加账号」时选择的平台写进
`course["platform"]`,把页面代际写进 `course["generation"]`(`legacy` / `spa`)。 `accounts.platform`,之后登录 / 刷新课程 / 发现课程都用 `registry.get(account["platform"])`
引擎按平台分发,平台内部再按代际选择学习实现。 取实现。课程发现时把平台 id 写进 `course["platform"]`,把页面代际写进
`course["generation"]`(`legacy` / `spa`);引擎按平台分发,平台内部再按代际选择学习实现。
`opts` 固定包含:`workers / cap / enable_quiz / enable_feedback / mode / parallel / `opts` 固定包含:`workers / cap / enable_quiz / enable_feedback / mode / parallel /
quiz_key / cookies / plan / project / name`。插件可以只取自己需要的。 quiz_key / cookies / plan / project / name`。插件可以只取自己需要的。
@@ -100,16 +110,18 @@ quiz_key / cookies / plan / project / name`。插件可以只取自己需要的
PLATFORM = registry.register(Platform( PLATFORM = registry.register(Platform(
id="<平台 id>", label="展示名", study=_study, id="<平台 id>", label="展示名", study=_study,
entries=("<登录入口域名>", "<学习入口域名>"), entries=("<登录入口域名>", "<学习入口域名>"),
session=<new_session>, dump_cookies=<dump_cookies>, login=<login>,
list_courses=<list_courses>, discover=<discover>,
)) ))
``` ```
2. 在 `platforms/__init__.py` 的 `load_builtin()` 里加一行 `from study_tool.platforms import <名字>`。 2. 在 `platforms/__init__.py` 的 `load_builtin()` 里加一行 `from study_tool.platforms import <名字>`。
3. 平台自己的登录 / 发现流程把 `course["platform"]` 写成该 id 后,引擎自动分发; 3. 平台注册后会自动出现在「添加账号」的平台下拉框;账号选了该平台,登录 / 刷新课程 /
`registry.detect()` 用登录 / 学习入口识别平台,不需要改前端。 发现课程 / 学习都走它的实现,不需要改路由或前端。
现阶段登录与课程发现仍由莞易学模块承担(SSO 只有一套);接入第二个平台时, 现阶段登录与课程发现由莞易学模块承担(SSO 只有一套);`accounts.platform` 已经是平台分发的
在账号登录 / 发现入口按 `Platform.entries` 命中结果选择对应实现即可。 依据,接入第二个平台时在 `platforms/<平台>.py` 里实现上面的入口即可。
## 前端 ## 前端
+3 -3
View File
@@ -1,7 +1,7 @@
# 后续待办 # 后续待办
- 接入第二个学习平台时,把该平台的登录、课程发现实现放进 `platforms/<平台>.py`, - 接入第二个学习平台时,在 `platforms/<平台>.py` 里实现 `session` / `login` / `list_courses` /
登录 / 发现入口用 `Platform.entries` + `registry.detect()` 选择;目前登录与发现仍由莞易学模块承担。 `discover` / `study` 并注册;账号在「添加账号」里选平台后即可用,登录与课程发现不需要再改路由。
- 前端补一条自动化回归(Playwright):空态、账号卡片、测验弹窗、设置保存。 - 前端补一条自动化回归(Playwright):空态、账号卡片、测验弹窗、设置保存、平台下拉。
- 构建检查进 CI:`cd web && pnpm install --frozen-lockfile && pnpm typecheck && pnpm build`, - 构建检查进 CI:`cd web && pnpm install --frozen-lockfile && pnpm typecheck && pnpm build`,
并校验 `static/` 与源码同步。 并校验 `static/` 与源码同步。
View File
+1 -1
View File
@@ -1,5 +1,5 @@
// Compute the site's strEnc(data, k1, k2, k3) using the original des.js. // Compute the site's strEnc(data, k1, k2, k3) using the original des.js.
// usage: node desenc.js <data> [k1] [k2] [k3] // usage: node script/desenc.js <data> [k1] [k2] [k3]
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const vm = require('vm'); const vm = require('vm');
@@ -7,10 +7,10 @@
* *
* 用法: * 用法:
* MIDDLE_TOKEN='<document.cookie 里 Middle-Token 的值>' \ * MIDDLE_TOKEN='<document.cookie 里 Middle-Token 的值>' \
* node js/study-axios.mjs status 123456 * node script/reference/study-axios.mjs status 123456
* *
* MIDDLE_TOKEN=... node js/study-axios.mjs watch 123456 * MIDDLE_TOKEN=... node script/reference/study-axios.mjs watch 123456
* MIDDLE_TOKEN=... node js/study-axios.mjs watch 123456 --beat 2 --limit 900 * MIDDLE_TOKEN=... node script/reference/study-axios.mjs watch 123456 --beat 2 --limit 900
* *
* 依赖:axios(npm i axios),Node >= 18。 * 依赖:axios(npm i axios),Node >= 18。
*/ */
@@ -206,7 +206,7 @@ try {
if (cmd === 'status') await status(id); if (cmd === 'status') await status(id);
else if (cmd === 'watch') await watch(id, opts); else if (cmd === 'watch') await watch(id, opts);
else { else {
console.log('用法:node js/study-axios.mjs <status|watch> <trainCourseId> [--beat 2] [--limit 900]'); console.log('用法:node script/reference/study-axios.mjs <status|watch> <trainCourseId> [--beat 2] [--limit 900]');
process.exit(2); process.exit(2);
} }
} catch (e) { } catch (e) {
+2 -2
View File
@@ -6,7 +6,7 @@ import os
import re import re
import shutil import shutil
# 包内路径 -> 项目根目录:data / static / des*.js 这些运行时资源都留在仓库根 # 包内路径 -> 项目根目录:data / static / script 这些运行时资源都留在仓库根
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(HERE)) ROOT = os.path.dirname(os.path.dirname(HERE))
DATA = os.path.join(ROOT, "data") DATA = os.path.join(ROOT, "data")
@@ -26,7 +26,7 @@ def _find_node():
NODE = _find_node() NODE = _find_node()
DESENC = os.path.join(ROOT, "desenc.js") DESENC = os.path.join(ROOT, "script", "desenc.js")
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36 Edg/154.0.0.0") "(KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36 Edg/154.0.0.0")
+16 -10
View File
@@ -8,6 +8,7 @@ import time
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from study_tool import store
from study_tool.config import * from study_tool.config import *
from study_tool.platforms import registry from study_tool.platforms import registry
from study_tool.platforms.jspx import * from study_tool.platforms.jspx import *
@@ -158,28 +159,33 @@ def study_worker(key, username, cookies, courses, workers, cap, quiz_key,
def run_one_course(key, username, cookies, plan, project, workers, cap, quiz_key, def run_one_course(key, username, cookies, plan, project, workers, cap, quiz_key,
enable_quiz=False, enable_feedback=True, name="", password="", enable_quiz=False, enable_feedback=True, name="", password="",
mode="fast", parallel=2): mode="fast", parallel=2):
s = new_session(cookies) # 账号添加时选的平台决定登录 / 发现走哪套实现;新增平台不用改这里
account = store.account_get(key) or {}
adapter = registry.get(account.get("platform"))
if adapter is None or adapter.discover is None:
raise RuntimeError("平台 %s 没有可用的课程发现实现" % (account.get("platform") or "?"))
s = (adapter.session or new_session)(cookies)
try: try:
d = discover(s, plan, project, name) d = adapter.discover(s, plan, project, name)
except SessionExpired: except SessionExpired:
# 先试无密码续期(CASTGC 通常还有效),不行再用浏览器里存的账号密码重登 # 先试无密码续期(CASTGC 通常还有效),不行再用浏览器里存的账号密码重登
log(key, "会话失效,尝试用 SSO 票据续期…") log(key, "会话失效,尝试无密码续期…")
renewed = ensure_jspx_session(s) renewed = adapter.renew_session(s) if adapter.renew_session else False
d = None d = None
if renewed: if renewed:
try: try:
d = discover(s, plan, project, name) d = adapter.discover(s, plan, project, name)
except SessionExpired: except SessionExpired:
d = None d = None
if d is None: if d is None:
if not password: if not password or adapter.login is None:
raise raise
log(key, "SSO 票据已过期,用账号密码重新登录…") log(key, "续期失败,用账号密码重新登录…")
s, _ = login(username, password) s, _ = adapter.login(username, password)
d = discover(s, plan, project, name) d = adapter.discover(s, plan, project, name)
# 新平台可能刚在 discover 里换到 study.dgjy.net 的会话(Middle-Token),存回去给后续课程用 # 新平台可能刚在 discover 里换到 study.dgjy.net 的会话(Middle-Token),存回去给后续课程用
set_state(key, course=d["course"], progress=d["progress"], user=d["user"], set_state(key, course=d["course"], progress=d["progress"], user=d["user"],
cookies=dump_cookies(s)) cookies=(adapter.dump_cookies or dump_cookies)(s))
log(key, "课程:%s | 进度 %s | %s" % (d["course"], d["progress"], d["info"])) log(key, "课程:%s | 进度 %s | %s" % (d["course"], d["progress"], d["info"]))
pf = registry.get(d.get("platform")) pf = registry.get(d.get("platform"))
gen = d.get("generation") gen = d.get("generation")
+5
View File
@@ -7,6 +7,7 @@ class LoginIn(BaseModel):
username: str = "" username: str = ""
password: str = "" password: str = ""
account_id: str = "" # 只给账号 id 时,用户名/密码从 SQLite 里取,密码不出库 account_id: str = "" # 只给账号 id 时,用户名/密码从 SQLite 里取,密码不出库
platform: str = "guanyixue" # 没传 account_id 时按它选平台
class AccountsIn(BaseModel): class AccountsIn(BaseModel):
@@ -16,6 +17,7 @@ class AccountsIn(BaseModel):
class AccountIn(BaseModel): class AccountIn(BaseModel):
id: str = "" id: str = ""
username: str = "" username: str = ""
platform: str = "guanyixue" # 添加账号时选的平台
password: str | None = None # 不传 = 保留库里已有的密码 password: str | None = None # 不传 = 保留库里已有的密码
note: str = "" note: str = ""
user: str = "" user: str = ""
@@ -33,6 +35,7 @@ class AccountIn(BaseModel):
class CookiesIn(BaseModel): class CookiesIn(BaseModel):
cookies: list cookies: list
platform: str = "guanyixue"
class DiscoverIn(BaseModel): class DiscoverIn(BaseModel):
@@ -40,6 +43,7 @@ class DiscoverIn(BaseModel):
plan_id: str plan_id: str
project_id: str project_id: str
name: str = "" name: str = ""
platform: str = "guanyixue"
class StudyIn(BaseModel): class StudyIn(BaseModel):
@@ -72,6 +76,7 @@ class QuizIn(BaseModel):
plan_id: str = "" plan_id: str = ""
project_id: str = "" project_id: str = ""
name: str = "" name: str = ""
platform: str = "guanyixue"
# retake=True:交卷后点「再考一次」,让平台生成新答卷(消耗一次机会) # retake=True:交卷后点「再考一次」,让平台生成新答卷(消耗一次机会)
retake: bool = False retake: bool = False
period_id: str = "" # 指定要重考/查看的课时(不传就按原逻辑挑第一个) period_id: str = "" # 指定要重考/查看的课时(不传就按原逻辑挑第一个)
+8
View File
@@ -26,6 +26,14 @@ class Platform:
label: str # 日志 / 界面里的展示名 label: str # 日志 / 界面里的展示名
study: Optional[Callable] = None # 学一门课;None = 这个平台还没实现 study: Optional[Callable] = None # 学一门课;None = 这个平台还没实现
entries: tuple = () # 登录 / 学习入口 URL 片段,用于识别平台 entries: tuple = () # 登录 / 学习入口 URL 片段,用于识别平台
# 账号级能力:添加账号时选了哪个平台,登录 / 发现 / 刷新课程就走哪个平台的实现
session: Optional[Callable] = None # (cookies) -> requests.Session
dump_cookies: Optional[Callable] = None # (session) -> [cookie]
login: Optional[Callable] = None # (username, password) -> (session, user)
list_courses: Optional[Callable] = None # (session) -> [course]
discover: Optional[Callable] = None # (session, plan_id, project_id, name) -> 课程描述
renew_session: Optional[Callable] = None # (session) -> bool,无密码续期
profile: Optional[Callable] = None # (session) -> 显示名
def matches(self, url="", html=""): def matches(self, url="", html=""):
"""登录入口或学习入口命中任意片段,就认为页面属于本平台。""" """登录入口或学习入口命中任意片段,就认为页面属于本平台。"""
+14 -4
View File
@@ -1,8 +1,9 @@
"""莞易学平台:jspx.dgjy.net(登录入口)与 study.dgjy.net(学习入口)是同一个平台。 """莞易学平台:jspx.dgjy.net(登录入口)与 study.dgjy.net(学习入口)是同一个平台。
课程页有两代前端,`discover` 会把代际写进 `course["generation"]`(legacy / spa), 账号在「添加账号」里选择平台(`accounts.platform`),登录 / 刷新课程 / 发现课程都会
这里按代际分发到对应的学习实现。新增其他平台时,照本文件写一个模块: 走这里的 `login` / `list_courses` / `discover`;学习时 `discover` 会把页面代际写进
在 `Platform.entries` 声明登录 / 学习入口,注册表就能按入口识别平台。 `course["generation"]`(legacy / spa),由 `_study` 分发到对应实现。
新增其他平台时,照本文件写一个模块并在 `Platform.entries` 声明登录 / 学习入口。
""" """
from study_tool.platforms import jspx, registry, study from study_tool.platforms import jspx, registry, study
from study_tool.platforms.base import Platform from study_tool.platforms.base import Platform
@@ -13,10 +14,19 @@ LABEL = "莞易学"
ENTRIES = ("jspx.dgjy.net", "study.dgjy.net", "dgjy.net") ENTRIES = ("jspx.dgjy.net", "study.dgjy.net", "dgjy.net")
def _profile(s):
return jspx.whoami(jspx.fetch_admin(s))
def _study(key, s, d, **opts): def _study(key, s, d, **opts):
if d.get("generation") == "spa": if d.get("generation") == "spa":
return study._study(key, s, d, **opts) return study._study(key, s, d, **opts)
return jspx._study(key, s, d, **opts) return jspx._study(key, s, d, **opts)
PLATFORM = registry.register(Platform(id=ID, label=LABEL, study=_study, entries=ENTRIES)) PLATFORM = registry.register(Platform(
id=ID, label=LABEL, study=_study, entries=ENTRIES,
session=jspx.new_session, dump_cookies=jspx.dump_cookies,
login=jspx.login, list_courses=jspx.scrape_courses, discover=jspx.discover,
renew_session=jspx.ensure_jspx_session, profile=_profile,
))
+6 -2
View File
@@ -10,6 +10,7 @@ from study_tool.config import *
from study_tool.engine import * from study_tool.engine import *
from study_tool.llm import * from study_tool.llm import *
from study_tool.models import * from study_tool.models import *
from study_tool.platforms import registry
from study_tool.runtime import log from study_tool.runtime import log
# ------------------------------------------------------------------ quiz # ------------------------------------------------------------------ quiz
@@ -246,9 +247,12 @@ def api_quiz(b: QuizIn):
"""取一次测验的题目(含选项),供页面表格展示。""" """取一次测验的题目(含选项),供页面表格展示。"""
if not b.cookies: if not b.cookies:
return {"ok": False, "code": "no_cookie", "error": "尚未登录"} return {"ok": False, "code": "no_cookie", "error": "尚未登录"}
s = new_session(b.cookies) pf = registry.get(b.platform)
if pf is None or pf.discover is None:
return {"ok": False, "error": "平台「%s」还没有实现课程发现" % b.platform}
s = (pf.session or new_session)(b.cookies)
try: try:
d = discover(s, b.plan_id, b.project_id, b.name) d = pf.discover(s, b.plan_id, b.project_id, b.name)
except SessionExpired as e: except SessionExpired as e:
return {"ok": False, "code": "session_expired", "error": str(e)[:300]} return {"ok": False, "code": "session_expired", "error": str(e)[:300]}
except Exception as e: except Exception as e:
+13 -4
View File
@@ -1,13 +1,16 @@
"""账号 / 登录 / 登录记录接口。""" """账号 / 登录 / 登录记录接口。"""
import time import time
from fastapi.responses import JSONResponse
from study_tool.webapp import app from study_tool.webapp import app
from study_tool import store from study_tool import store
from study_tool.config import * from study_tool.config import *
from study_tool.models import * from study_tool.models import *
from study_tool.platforms import registry
from study_tool.platforms.jspx import * from study_tool.platforms.jspx import *
from study_tool.runtime import log from study_tool.runtime import RUNS, RUNS_LOCK, log
def _log_login(username, ok, code, error, elapsed, stage=""): def _log_login(username, ok, code, error, elapsed, stage=""):
"""每次登录尝试:统一日志 + login_log 表(登录记录页用)。""" """每次登录尝试:统一日志 + login_log 表(登录记录页用)。"""
@@ -37,8 +40,14 @@ def api_login(b: LoginIn):
_log_login(username or b.account_id, False, "no_credential", err, time.time() - t0, _log_login(username or b.account_id, False, "no_credential", err, time.time() - t0,
"LoginIn") "LoginIn")
return JSONResponse({"ok": False, "code": "no_credential", "error": err}, status_code=200) return JSONResponse({"ok": False, "code": "no_credential", "error": err}, status_code=200)
pf = registry.get((acc or {}).get("platform") or b.platform)
if pf is None or pf.login is None:
err = "平台「%s」还没有实现登录,请换一个平台或先在 platforms/ 里补上实现" % (
(acc or {}).get("platform") or b.platform)
_log_login(username, False, "no_platform", err, time.time() - t0, "LoginIn")
return JSONResponse({"ok": False, "code": "no_platform", "error": err}, status_code=200)
try: try:
s, user = login(username, password) s, user = pf.login(username, password)
except Exception as e: except Exception as e:
err = str(e)[:300] err = str(e)[:300]
_log_login(username, False, "login_failed", err, time.time() - t0, type(e).__name__) _log_login(username, False, "login_failed", err, time.time() - t0, type(e).__name__)
@@ -49,10 +58,10 @@ def api_login(b: LoginIn):
return JSONResponse({"ok": False, "code": "login_failed", "error": err}, status_code=200) return JSONResponse({"ok": False, "code": "login_failed", "error": err}, status_code=200)
warn = "" warn = ""
try: try:
courses = scrape_courses(s) courses = pf.list_courses(s) if pf.list_courses else []
except Exception as e: except Exception as e:
courses, warn = [], str(e)[:200] courses, warn = [], str(e)[:200]
cookies = dump_cookies(s) cookies = (pf.dump_cookies or dump_cookies)(s)
_log_login(username, True, "ok", warn, time.time() - t0, "user=%s" % user) _log_login(username, True, "ok", warn, time.time() - t0, "user=%s" % user)
if acc: if acc:
store.account_save_login(acc["id"], user, cookies, courses) store.account_save_login(acc["id"], user, cookies, courses)
+17 -8
View File
@@ -65,13 +65,18 @@ def api_courses(b: CookiesIn):
if not b.cookies: if not b.cookies:
return JSONResponse({"ok": False, "code": "no_cookie", return JSONResponse({"ok": False, "code": "no_cookie",
"error": "本机没有该账号的会话 cookie,请先点「登录」"}, status_code=200) "error": "本机没有该账号的会话 cookie,请先点「登录」"}, status_code=200)
s = new_session(b.cookies) pf = registry.get(b.platform)
if pf is None or pf.list_courses is None:
return JSONResponse({"ok": False, "code": "no_platform",
"error": "平台「%s」还没有实现刷新课程,请先在 platforms/ 里补上"
% b.platform}, status_code=200)
s = (pf.session or new_session)(b.cookies)
if not s.cookies: if not s.cookies:
return JSONResponse({"ok": False, "code": "no_cookie", return JSONResponse({"ok": False, "code": "no_cookie",
"error": "保存的 cookie 全部被过滤(domain 不含 dgjy),请重新登录"}, "error": "保存的 cookie 全部被过滤(domain 不含 dgjy),请重新登录"},
status_code=200) status_code=200)
try: try:
courses = scrape_courses(s) courses = pf.list_courses(s)
except SessionExpired as e: except SessionExpired as e:
return JSONResponse({"ok": False, "code": "session_expired", "error": str(e)[:300]}, status_code=200) return JSONResponse({"ok": False, "code": "session_expired", "error": str(e)[:300]}, status_code=200)
except Exception as e: except Exception as e:
@@ -81,21 +86,25 @@ def api_courses(b: CookiesIn):
"error": "登录态有效,但该账号名下三个板块(在学/助学/督学)" "error": "登录态有效,但该账号名下三个板块(在学/助学/督学)"
"的进行中、未开始、已结束里都没有课程"}, status_code=200) "的进行中、未开始、已结束里都没有课程"}, status_code=200)
try: try:
user = whoami(fetch_admin(s)) user = pf.profile(s) if pf.profile else ""
except Exception: except Exception:
user = "" user = ""
return {"ok": True, "courses": courses, "user": user, return {"ok": True, "courses": courses, "user": user,
"in_study": len([c for c in courses if c["in_study"]]), "in_study": len([c for c in courses if c["in_study"]]),
# 补 jspx 会话时 cookie 会换新(JSESSIONID/route),回传让前端写回 IndexedDB # 补 jspx 会话时 cookie 会换新(JSESSIONID/route),回传让前端写回
"cookies": dump_cookies(s)} "cookies": (pf.dump_cookies or dump_cookies)(s)}
@app.post("/api/discover") @app.post("/api/discover")
def api_discover(b: DiscoverIn): def api_discover(b: DiscoverIn):
s = new_session(b.cookies) pf = registry.get(b.platform)
if pf is None or pf.discover is None:
return JSONResponse({"ok": False, "code": "no_platform",
"error": "平台「%s」还没有实现课程发现" % b.platform}, status_code=200)
s = (pf.session or new_session)(b.cookies)
try: try:
d = discover(s, b.plan_id, b.project_id, b.name) d = pf.discover(s, b.plan_id, b.project_id, b.name)
return {"ok": True, **d, "cookies": dump_cookies(s)} return {"ok": True, **d, "cookies": (pf.dump_cookies or dump_cookies)(s)}
except Exception as e: except Exception as e:
return JSONResponse({"ok": False, "error": str(e)[:300]}, status_code=200) return JSONResponse({"ok": False, "error": str(e)[:300]}, status_code=200)
+15 -3
View File
@@ -59,6 +59,7 @@ CREATE TABLE IF NOT EXISTS kv (
CREATE TABLE IF NOT EXISTS accounts ( CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT, username TEXT,
platform TEXT DEFAULT 'guanyixue',
password TEXT, password TEXT,
note TEXT, note TEXT,
user TEXT, user TEXT,
@@ -145,6 +146,7 @@ def conn():
with _lock: with _lock:
if not _ready: if not _ready:
c.executescript(SCHEMA) c.executescript(SCHEMA)
_ensure_columns(c)
c.commit() c.commit()
_migrate(c) _migrate(c)
_ready = True _ready = True
@@ -189,6 +191,13 @@ def db_info():
# ------------------------------------------------------------------ 迁移 # ------------------------------------------------------------------ 迁移
def _ensure_columns(c):
"""SQLite 没有 ADD COLUMN IF NOT EXISTS;老库缺字段时补上。"""
cols = {r["name"] for r in c.execute("PRAGMA table_info(accounts)")}
if "platform" not in cols:
c.execute("ALTER TABLE accounts ADD COLUMN platform TEXT DEFAULT 'guanyixue'")
def _migrate(c): def _migrate(c):
"""把旧的文件式存储导入 SQLite;成功后把原文件改名成 *.imported。""" """把旧的文件式存储导入 SQLite;成功后把原文件改名成 *.imported。"""
# 1) data/settings.json -> settings # 1) data/settings.json -> settings
@@ -284,6 +293,7 @@ def _acc_row(r):
return { return {
"id": r["id"], "id": r["id"],
"username": r["username"] or "", "username": r["username"] or "",
"platform": r["platform"] or "guanyixue",
"note": r["note"] or "", "note": r["note"] or "",
"user": r["user"] or "", "user": r["user"] or "",
"cookies": loads(r["cookies"], []) or [], "cookies": loads(r["cookies"], []) or [],
@@ -336,14 +346,16 @@ def account_upsert(rec):
pwd = rec.get("password") pwd = rec.get("password")
if pwd is not None: if pwd is not None:
pwd = str(pwd) pwd = str(pwd)
platform = (rec.get("platform") or "").strip() or None
selected = loads(dumps(rec.get("selected") or []), []) or [] selected = loads(dumps(rec.get("selected") or []), []) or []
write( write(
"""INSERT INTO accounts """INSERT INTO accounts
(id, username, password, note, user, cookies, courses, selected, status, (id, username, platform, password, note, user, cookies, courses, selected, status,
last_error, last_error_at, opt_quiz, opt_feedback, login_at, created, updated) last_error, last_error_at, opt_quiz, opt_feedback, login_at, created, updated)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
username = excluded.username, username = excluded.username,
platform = COALESCE(excluded.platform, accounts.platform),
password = COALESCE(excluded.password, accounts.password), password = COALESCE(excluded.password, accounts.password),
note = excluded.note, note = excluded.note,
user = excluded.user, user = excluded.user,
@@ -357,7 +369,7 @@ def account_upsert(rec):
opt_feedback = excluded.opt_feedback, opt_feedback = excluded.opt_feedback,
login_at = excluded.login_at, login_at = excluded.login_at,
updated = excluded.updated""", updated = excluded.updated""",
(aid, rec.get("username") or "", pwd, rec.get("note") or "", rec.get("user") or "", (aid, rec.get("username") or "", platform, pwd, rec.get("note") or "", rec.get("user") or "",
dumps(rec.get("cookies") or []), dumps(rec.get("courses") or []), dumps(selected), dumps(rec.get("cookies") or []), dumps(rec.get("courses") or []), dumps(selected),
rec.get("status") or "idle", rec.get("lastError") or "", rec.get("status") or "idle", rec.get("lastError") or "",
float(rec.get("lastErrorAt") or 0), float(rec.get("lastErrorAt") or 0),
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="theme-color" content="#f6f8fc" /> <meta name="theme-color" content="#f6f8fc" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>莞易学 · 多账号学习控制台</title> <title>莞易学 · 多账号学习控制台</title>
<script type="module" crossorigin src="/assets/index-CkxeZCl8.js"></script> <script type="module" crossorigin src="/assets/index-Dfv5RNeD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DiHucQck.css"> <link rel="stylesheet" crossorigin href="/assets/index-BubgUKHl.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+17 -4
View File
@@ -75,6 +75,7 @@ function accountPayload(account: Account) {
return { return {
id: account.id, id: account.id,
username: account.username, username: account.username,
platform: account.platform || 'guanyixue',
note: account.note || '', note: account.note || '',
user: account.user || '', user: account.user || '',
cookies: account.cookies || [], cookies: account.cookies || [],
@@ -93,6 +94,9 @@ export default function App() {
const [accounts, setAccounts] = useState<Account[]>([]) const [accounts, setAccounts] = useState<Account[]>([])
const accountsRef = useRef(accounts) const accountsRef = useRef(accounts)
const [platforms, setPlatforms] = useState<Record<string, string>>({ guanyixue: '莞易学' }) const [platforms, setPlatforms] = useState<Record<string, string>>({ guanyixue: '莞易学' })
const [platformList, setPlatformList] = useState<PlatformInfo[]>([
{ id: 'guanyixue', label: '莞易学' },
])
const [offline, setOffline] = useState(false) const [offline, setOffline] = useState(false)
const [toast, setToast] = useState<ToastState | null>(null) const [toast, setToast] = useState<ToastState | null>(null)
const [pending, setPending] = useState<Record<string, string>>({}) const [pending, setPending] = useState<Record<string, string>>({})
@@ -193,9 +197,12 @@ export default function App() {
useEffect(() => { useEffect(() => {
api<{ platforms: PlatformInfo[] }>('/api/platforms') api<{ platforms: PlatformInfo[] }>('/api/platforms')
.then((response) => { .then((response) => {
const list = response.platforms || []
if (!list.length) return
setPlatformList(list)
const map: Record<string, string> = {} const map: Record<string, string> = {}
for (const platform of response.platforms || []) map[platform.id] = platform.label for (const platform of list) map[platform.id] = platform.label
if (Object.keys(map).length) setPlatforms(map) setPlatforms(map)
}) })
.catch(() => {}) .catch(() => {})
}, []) }, [])
@@ -366,7 +373,10 @@ export default function App() {
try { try {
let response = await api<CoursesResponse>('/api/courses', { let response = await api<CoursesResponse>('/api/courses', {
method: 'POST', method: 'POST',
body: JSON.stringify({ cookies: account.cookies }), body: JSON.stringify({
cookies: account.cookies,
platform: account.platform || 'guanyixue',
}),
}) })
if (!response.ok && response.code === 'session_expired') { if (!response.ok && response.code === 'session_expired') {
notify({ message: '会话已失效,正在自动重新登录…' }) notify({ message: '会话已失效,正在自动重新登录…' })
@@ -578,6 +588,7 @@ export default function App() {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
username: data.username, username: data.username,
platform: data.platform,
password: data.password, password: data.password,
note: data.note, note: data.note,
cookies: [], cookies: [],
@@ -621,7 +632,8 @@ export default function App() {
} }
function platformLabel(account: Account) { function platformLabel(account: Account) {
const id = (account.courses || []).find((course) => course.platform)?.platform const id =
account.platform || (account.courses || []).find((course) => course.platform)?.platform
return (id && platforms[id]) || '莞易学' return (id && platforms[id]) || '莞易学'
} }
@@ -749,6 +761,7 @@ export default function App() {
<AddAccountDialog <AddAccountDialog
open={dialog?.type === 'add'} open={dialog?.type === 'add'}
platforms={platformList}
onClose={() => setDialog(null)} onClose={() => setDialog(null)}
onSubmit={addAccount} onSubmit={addAccount}
/> />
+30 -2
View File
@@ -1,5 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { ChevronDown, GraduationCap } from 'lucide-react'
import type { PlatformInfo } from '../types'
import { Modal } from './ui/Modal' import { Modal } from './ui/Modal'
import { Button } from './ui/Button' import { Button } from './ui/Button'
import { inputCls } from '../lib/utils' import { inputCls } from '../lib/utils'
@@ -8,20 +10,24 @@ export interface NewAccount {
username: string username: string
password: string password: string
note: string note: string
platform: string
} }
export function AddAccountDialog({ export function AddAccountDialog({
open, open,
platforms,
onClose, onClose,
onSubmit, onSubmit,
}: { }: {
open: boolean open: boolean
platforms: PlatformInfo[]
onClose: () => void onClose: () => void
onSubmit: (account: NewAccount) => Promise<void> onSubmit: (account: NewAccount) => Promise<void>
}) { }) {
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [note, setNote] = useState('') const [note, setNote] = useState('')
const [platform, setPlatform] = useState(platforms[0]?.id || 'guanyixue')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -30,9 +36,10 @@ export function AddAccountDialog({
setUsername('') setUsername('')
setPassword('') setPassword('')
setNote('') setNote('')
setPlatform(platforms[0]?.id || 'guanyixue')
setError('') setError('')
setSaving(false) setSaving(false)
}, [open]) }, [open, platforms])
async function submit() { async function submit() {
if (!username.trim() || !password) { if (!username.trim() || !password) {
@@ -42,7 +49,7 @@ export function AddAccountDialog({
setSaving(true) setSaving(true)
setError('') setError('')
try { try {
await onSubmit({ username: username.trim(), password, note: note.trim() }) await onSubmit({ username: username.trim(), password, note: note.trim(), platform })
onClose() onClose()
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)) setError(err instanceof Error ? err.message : String(err))
@@ -69,6 +76,27 @@ export function AddAccountDialog({
} }
> >
<div className="space-y-4"> <div className="space-y-4">
<div>
<label className="mb-1.5 block text-[12px] font-medium text-slate-600">学习平台</label>
<div className="relative">
<GraduationCap className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-slate-400" />
<select
className={`${inputCls} appearance-none pl-8 pr-8`}
value={platform}
onChange={(event) => setPlatform(event.target.value)}
>
{platforms.map((item) => (
<option key={item.id} value={item.id}>
{item.label}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 size-3.5 -translate-y-1/2 text-slate-400" />
</div>
<p className="mt-1.5 text-[11px] leading-relaxed text-slate-400">
选「莞易学」后,后台会自动识别旧版 / 新版课程页并按对应实现学习;后续新增的平台会出现在这里。
</p>
</div>
<div> <div>
<label className="mb-1.5 block text-[12px] font-medium text-slate-600">登录账号</label> <label className="mb-1.5 block text-[12px] font-medium text-slate-600">登录账号</label>
<input <input
+1
View File
@@ -47,6 +47,7 @@ export function QuizDialog({
try { try {
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
cookies: account.cookies, cookies: account.cookies,
platform: account.platform || 'guanyixue',
plan_id: course.plan_id, plan_id: course.plan_id,
project_id: course.project_id, project_id: course.project_id,
name: course.name || '', name: course.name || '',
+1
View File
@@ -37,6 +37,7 @@ export interface RunState {
export interface Account { export interface Account {
id: string id: string
username: string username: string
platform?: string
note?: string note?: string
user?: string user?: string
cookies: Cookie[] cookies: Cookie[]