# OPTIMIZATION 已实现、但还需要你实测确认的事项。确认没问题后把对应条目删掉。 ## 发版体检修复(`src/study_tool/app.py`、`routes/settings.py`、`notify.py`、`web/src/components/SettingsDialog.tsx`) **状态**:已实现,已用独立端口 + 独立库实例复测(首页 / `/query` / 系统设置 / 插件管理均正常渲染,无 console 报错);「清空密钥」「推送失败重试」需你实测。 ### 做了什么 - 启动日志不再输出 `AUTH_TOKEN` 明文(日志同时进控制台、内存和 SQLite,前端「系统日志」里能看到),改成「(需要访问令牌:见 .env 的 AUTH_TOKEN)」。 - 系统设置:温度留空 / 填 0 = 不改,不再把已保存的温度清成 0;API Key、app_secret 右侧加「清空」按钮(`clear_api_key`、`notify.clear_app_secret`),不用再拿一个假值覆盖。 - 故障推送:发送失败只冷却 1 分钟,原来一次网络抖动会把整段 `cooldown_min` 的冷却占掉。 - 文案与文档:`platforms/`、`pugins/` 统一成 `plugins/`;`docs/pugin.md` 更名为 `docs/plugin.md`。 - 工程:`pyproject.toml` 版本与 `metadata.yaml` 对齐,`bumpversion` 同时更新两处;`.gitignore` 补 `.idea/`、`.ruff_cache/`。 ### 怎么测 1. 重启服务,看控制台和右上角「系统日志」:启动那行应是 `控制台启动 -> http://127.0.0.1:8848(需要访问令牌:见 .env 的 AUTH_TOKEN)`,不应出现令牌本身。 2. 已保存 API Key 时打开「系统设置 → 大模型」:API Key 右侧有「清空」,点了保存后 `has_key` 应变成 false;「故障推送」里的 app_secret 同理。 3. 把推送 `base_url` 改成打不通的地址,触发一次故障推送:日志应出现「故障推送失败」,约 1 分钟后再次触发会重新尝试,而不是等满 10 分钟。 ## 卡片标题显示学员姓名 + 日志面板明亮模式(`plugins/gdgxk/main.py`、`utils/learn.py`、`web/src/components/ui/LogLines.tsx`、`AccountCard.tsx`、`SystemLogDialog.tsx`) **状态**:已实现,已用隔离实例截图核对(标题显示平台姓名、日志面板明亮 + 分级配色);「登录后回填姓名」和「进度上报新文案」需你下次学习时确认。 ### 做了什么 - 广东公需课 `profile()` 改成先解析个人中心头像区的 ``:学员账号平台显示的是打码姓名(如「*栋」),老师账号是完整姓名;解析不到才退回原来的「你好,X 老师」。卡片标题仍是 `user → username` 的优先级,所以登录 / 刷新课程后标题就变成姓名,第二行继续显示登录账号。 - 进度上报日志改成能对得上的说法:`进度上报:平台已记 1200/2580 秒(46%),本次上报到 1201 秒`;课件结束改 `课件完成:xxx(平台已记 2580/2580 秒)`,不再出现「还差一半却说 100%」。 - 新增 `ui/LogLines.tsx`:按 `时间 [级别] 内容` 拆行渲染,info 灰蓝、warn 琥珀、error / fail 红色、success 绿色,时间戳淡灰。 - 账号卡片的「课程日志」和右上角「系统日志」都从黑底改成浅色底(`bg-slate-50` + 细边框),长行自动换行不再横向滚动。 ### 怎么测 1. 刷新页面后点一次「刷新课程」(或「重新登录」):卡片标题应从手机号变成平台姓名(广东公需课学员是「*栋」这种打码名),第二行仍显示登录账号。 2. 下次学习时看日志:应出现 `进度上报:平台已记 x/y 秒(z%),本次上报到 n 秒`,百分比与秒数对得上;课件跑完是 `平台已记 y/y 秒`。 3. 日志面板应是浅色底:info 灰蓝、warn 琥珀色、error 红色;点右上角「系统日志」也是同样的浅色配色,长行自动换行。 ## 账号卡片:修改账号信息弹窗 + 自绘确认弹窗(`web/src/components/AccountEditDialog.tsx`、`ui/ConfirmDialog.tsx`、`ui/Modal.tsx`、`ui/Button.tsx`、`AccountCard.tsx`、`QuizDialog.tsx`、`App.tsx`) **状态**:已实现,已用独立端口 + 复制库实例截图核对「修改账号信息」弹窗与「删除账号」确认框;「保存后自动重新登录」这一条还需你实测。 ### 做了什么 - 账号卡片「更多操作」菜单最上面新增「修改账号信息」;账号带 `lastError` 时,红条下方也直接给一个「修改账号信息」按钮,出错账号不用再翻菜单。 - 新增 `AccountEditDialog`:可改学习平台 / 登录账号 / 密码 / 备注。密码留空表示不修改(后端 `account_upsert` 保留原密码),带显示/隐藏切换。保存后写库并立刻用新凭据重新登录一次:弹窗先关,登录在后台跑,失败走 toast + 卡片红条。 - 新增 `ui/ConfirmDialog.tsx` 的 `useConfirm()`:项目内统一风格的确认弹窗(图标 + 标题 + 说明 + 主次按钮,危险操作用红色实心按钮)。替换了三处 `window.confirm`:删除账号(`App.tsx`)、提交测验缺题(`QuizDialog.tsx`)、再考一次(`QuizDialog.tsx`)。 - `Modal` 增加 `sm` 尺寸;`Button` 增加 `danger-solid` 变体。 ### 怎么测 1. 刷新页面,点账号卡片右下「⋯」→「修改账号信息」:弹窗应回填当前平台 / 账号 / 备注,密码框为空并提示「已保存密码,需要更换时再填」。 2. 只改备注后保存:应提示「账号信息已保存,正在用新凭据重新登录…」,随后登录成功并刷新课程;密码留空不应把库里密码清空。 3. 故意填错密码保存:卡片应变成「登录失败」+ 红条,红条下出现「修改账号信息」按钮,点它能直接再改。 4. 点「⋯」→「删除账号」:弹自绘确认框(红色图标 + 红色「删除账号」按钮),取消不删除、确定才删;测验缺题提交、再考一次也应弹同样的自绘弹窗,不再出现浏览器默认框。 ## 故障推送:呆呆面板推送渠道(`study_tool/notify.py`、`plugins/daidai_push/`、`routes/settings.py`、`engine.py`、`web/src/components/SettingsDialog.tsx`) **状态**:已实现并实测通过(本机已用给定 app_key / app_secret 换到 token 并成功发出测试推送;进度异常 / 多次重试的自动触发还需一次真实学习验证)。 ### 做了什么 - 新增 `study_tool/notify.py`:渠道注册表 + `fault()` 故障上报 + `retry()` 重试计数 + 冷却(同账号同类型 `cooldown_min` 分钟内只推一次,重试只在刚达到阈值时推)。 - 新增插件 `plugins/daidai_push/`(`plugin.json` 里 `"kind": "notify"`):`POST {base_url}/api/open-api/token` 换 token,内存缓存并按 `expires_in` 过期,发送遇 401/403 自动换新 token 重发一次;`POST {base_url}/api/notifications/send` 发通知。 - 设置项 `notify`:`base_url`(默认 `https://corn.hanhan.ltd`)、`app_key`、`app_secret`、`channel_name`(留空走默认渠道)、`enabled`、四个分类开关、`retry_threshold`(默认 3)、`cooldown_min`(默认 10)。`GET/POST /api/settings` 读写,`app_secret` 只回显前后几位;新增 `POST /api/notify/test`。 - 触发点:学完仍未完成 / 进度没推进 / 没有可识别活动、课程处理异常、整体运行异常、登录失败、会话失效且无法续期;公需课掉线重登与「未完成任务点自动重学」、莞易学单课时重报都计入重试次数。 - 前端「系统设置」新增「故障推送」区块(含「测试推送」按钮),插件管理里该插件显示能力「推送」。 ### 怎么测 1. 重启服务,打开「系统设置」:左侧是「大模型 / 学习模式 / 故障推送」三栏,右侧只显示当前分类;切到「故障推送」应能看到已填好的 app_key / app_secret(secret 显示为掩码),点面板右上「测试推送」应收到一条「【测试】学习控制台推送」。 2. 「插件管理」里应有「呆呆面板推送」卡片,能力显示「推送」;停用后点「测试推送」应提示没有可用渠道,重新启用后恢复。 3. 跑一门**学不完**的课(或故意让会话失效):课程结束应收到「学习进度异常」或「登录/会话异常」推送,且同一账号 10 分钟内不会重复推。 4. 公需课连续 3 轮进度不增加、或莞易学单课时连续 3 次上报未生效时,应收到「多次重试」推送(阈值可在设置里改)。 ## 插件管理卡片:能力中文 + 适配网站(`web/src/components/PluginsDialog.tsx`、`plugins/__init__.py`、`platforms/contract.py`) **状态**:已实现,待实测确认(前端已 `cd web && pnpm build`;`/api/plugins` 多了 `sites`,需重启服务才生效)。 ### 做了什么 - 卡片上的能力名由 `login / session / …` 改成中文:登录 / 会话 / 续期 / 账号信息 / 课程列表 / 课程详情 / 学习 / 测验 / 抓包(映射在 `PluginsDialog.tsx`,接口仍返回能力键)。 - `/api/plugins` 每条插件多返回 `sites`:取平台 `entries` 并去掉被更具体域名覆盖的父域(莞易学的 `dgjy.net` 不会单独列)。 - 卡片内的信息改成白底分组面板:`适配网站` 一行用等宽字体标签列出域名,`能力` 一行用蓝色标签(chip)逐个列出,`平台 / id` 放在最下面一行弱化展示。 - 顺手把卡片里过期的 `plugins/`、`plugin.json` 文案改成 `plugins/`、`plugin.json`。 ### 怎么测 1. 重启服务后打开右上角「插件管理」:两张卡片都显示中文能力,且各有一行「适配网站」(公需课 `jsglpt.gds.edu.cn · jsxx.gds.edu.cn`,莞易学 `jspx.dgjy.net · study.dgjy.net`)。 2. 点停用 / 启用 / 重载:清单刷新后网站与能力仍正确。 ## 公需课交互式测验(`plugins/gdgxk/main.py` 的 `quiz` / `quiz_submit`) **状态**:取题已验证(本机账号那门课解析出 30 道题、题型 01、选项 A–D);**交卷未实测**(该课考核早已提交,`done=true`,所以弹窗只会显示题目、按钮禁用)。 ### 做了什么 - 公需课补上 `quiz` / `quiz_submit` 两个槽位,前端卡片的「打开测验」因此出现(能力由方法推导)。 - 取题:考核是**独立活动页** `jsxx.gds.edu.cn//study/course/`(课程主页上没有 `#testForm`),从课程目录里找标题含「考核 / 考试」的活动,打开它再 `exam.parse`;题型映射成弹窗认的 `01/02/03`。已交卷的课也返回题目,方便回看。 - 交卷:`exam.submit_letters` 重取一次考核页拿隐藏字段,把用户选的字母按选项 `value`(`Choice0..4`)换成平台要的值,再走原来的 `finishTest2` 提交并回读得分。 - `/api/quiz/submit` 改为按平台分发;莞易学的交卷逻辑挪进 `quiz.submit_quiz`,行为不变。 - 顺带修好学习流程里的自动考核:`exam.run` 之前也读课程主页(拿不到表单),现在同样改为读考核活动页。 ### 怎么测 1. 找一门**考核还没交**的公需课,点「打开测验」:应列出 30 道题的题干 / 题型 / 选项。 2. 点「AI 答题」:应分批把答案填进选项(走 `/api/quiz/answer`,纯大模型)。 3. 改几道答案后点交卷:应返回得分;再点一次「打开测验」应显示已交卷且题目可见。 4. 若提示「考核页里没有找到题目」,把该考核页 HTML 发我,`exam.parse` 的选择器要跟着改。 ## 刷新课程反复重登 / 空平台报错(`web/src/App.tsx`、`routes/accounts.py`、`plugins/gdgxk/main.py`) **状态**:已实现,待实测确认(前端需先 `cd web && pnpm build`)。 ### 做了什么 - `refreshCourses` 在 `session_expired` 自动重登后的重试补上 `platform`,不再出现「平台「」还没有实现刷新课程」,登录成功的课程也会正常写回卡片。 - 前端只在运行中(`discovering` / `studying`)才把 `/api/runs` 的 `cookies` 回写账号;运行结束后不再用开跑时的旧会话覆盖刚登录的新会话。 - `_clear_done_run` 一并清掉运行状态里的 `cookies`,重新登录后旧会话不会继续挂在 `/api/runs` 里。 - 公需课学习中掉线重登(`_relogin`)成功后,用 `database.account_save_cookies` 把新会话写回账号,并 `set_state(cookies=…)` 同步运行状态。 ### 怎么测 1. 让账号会话失效(隔一段时间或换设备登录一次),点「刷新课程」:应看到「会话已失效,正在自动重新登录…」后直接读到课程,不再弹「平台「」还没有实现刷新课程」。 2. 自动重登后等 10 秒以上,再点一次「刷新课程」:不应再次触发登录(说明旧 cookie 没有被写回去)。 3. 学习中故意让会话掉线,日志出现「重新登录成功,继续学习」后结束运行,再点「刷新课程」:应一次成功、不再重登。 ## 公需课自动重学(`plugins/gdgxk/main.py` 的 `study`) **状态**:已实现,待实测确认。 ### 做了什么 - 每刷完一轮活动,都重新请求进度页 `/study/course/progess` 取任务点状态;全部已完成(或一开始就全部已完成)时也会重取一次,以平台状态为准。 - 进度页里还有未完成任务点,就等 60 秒(`ROUND_WAIT`)后自动重新开始学习,只重刷未完成的活动。 - 连续 3 轮(`MAX_STALL`)完成数没增加,或达到 5 轮(`MAX_ROUNDS`)就停止,日志列出剩余任务点,避免考核等非视频任务点死循环。 - 重试等待期间点「停止」会立即退出。 ### 怎么测 1. 选一个有未完成任务点的账号点「开始学习」,观察日志有没有「第 N 轮结束……稍后自动重新学习」,并且自动开始下一轮。 2. 选一个全部任务点已完成的账号,日志应显示「平台进度:N/N 个活动已完成」后结束。 3. 若某个任务点平台迟迟不记完成,应在 3 轮后停下并列出该任务点;如果 60 秒不够平台刷新状态,调大 `ROUND_WAIT`。 ## 公需课选课兜底(`plugins/gdgxk/utils/register.py`) **状态**:已实现,**未实测**(写的时候没有账号,选课超市的页面结构和选课请求是按截图推断的)。 ### 做了什么 - `list_courses` 发现课程列表里没有目标年度的课时,调用 `register.ensure`:读 `/uc/store/course/` 里所有课程链接,逐个打开详情页判断状态。 - 状态判断:任一页按钮是「进入学习」= 已经选中,直接重读课程列表;选中的课位置对应页写「已有选课」= 已选了别的课,不再选;都没选就选第 `pick` 门。 - 配置在 `plugins/gdgxk/config.json`:`year`(空 = 当前年份)、`pick`(第几门,从 1 开始)。暂未做弹窗让用户挑。 ### 怎么测 1. 用一个当年还没选课的账号点「刷新课程」,看系统日志有没有「公需课选课:…」。 2. 若提示「没有解析到课程链接」或「没有找到选课按钮的请求地址」,把 `/uc/store/course/` 页面 HTML、未选状态的详情页 HTML、点「选课」时的请求(cURL)发我,据此改 `register.py` 里的正则和请求方式(现在假定选课是 GET)。 ## 公开查询页 `/query` **状态**:已实现,待实测确认(前端需先 `cd web && pnpm build`)。 ### 做了什么 - 新增 `routes/query.py`:`GET /api/query?username=` 按用户名返回账号的课程列表(名称 / 章节 / 状态 / 进度)和运行进度,不含密码 / cookie / 账号 id。 - `app.py` 的 token 中间件放行 `/query`、`/api/query`、`/favicon.svg` 和 `/assets/*`;`/query` 与 `/` 共用同一个前端入口。 - 前端 `main.tsx` 按路径渲染:`/query` 显示新页面 `pages/QueryPage.tsx`(上方输入框 + 查询按钮,下方课程列表与进度),其余路径仍是控制台。 ### 怎么测 1. 设置 `AUTH_TOKEN` 后启动,不带 token 打开 `/query`,应能正常显示;打开 `/` 仍要求 token。 2. 输入已添加账号的用户名点「查询」,应显示课程列表和进度;输入不存在的用户名显示「没有找到该账号」。 3. 注意:该接口任何人知道用户名就能查到课程与进度,公网部署请确认可接受。 ## 题库匹配(核心 `bank.py` + 公需课题库数据) **状态**:已实现,待实测确认。匹配部分已完成;公需课考核已接到它上面(见下一节)。 ### 做了什么 - 新增 `src/study_tool/bank.py`(与平台无关):题干归一化后按「精确 → 互相包含(两边至少 8 字)→ 相似度 ≥ 0.9」查题库;没命中的题按 5 题一批问设置里的大模型;没配大模型就留空并打 warn 日志。入口 `bank.answer(key, questions, bank)`。 - 新增 `plugins/gdgxk/utils/bank.py` 与 `plugins/gdgxk/bank/2026.json`:题库数据由脚本的 121 条 `videoBank` 转成 `[{"q": 题干, "a": "B"}]`。明年换题库只需往 `bank/` 加 JSON,同目录所有 `*.json` 会合并。 - `answer.py`(考核按序答案)没动:考核改走题干匹配,按序答案留着备用。 ### 怎么测 1. 在项目根目录用 Python 执行:`from study_tool.plugins.gdgxk.utils import bank; bank.answer("t", [{"no":1,"type":"判断","title":"生成式人工智能(GenAI)目前还没有在教育领域得到广泛应用。","options":[{"key":"A","text":"正确"},{"key":"B","text":"错误"}]}])`,应返回 `{"1": {"answer": ["B"], "source": "bank"}}`。 2. 换一道题库里没有的题:没配大模型时日志有「还没配置大模型」警告;配了大模型会返回 `source: "llm"`。 ## 公需课课程考核(`plugins/gdgxk/utils/exam.py`) **状态**:已实现,待实测确认(本轮只验证了解析与选项映射,交卷响应体还没在真实环境跑过)。 ### 做了什么 - 考核不是 JSON 接口:题目内嵌在课程学习页 HTML(`#topicList .m-topic-item`),交卷是 `POST .../ncts/test/delivery//finishTest2`,body 为 `_method=put` + 表单序列化(Spring method override)。 - 题型与字段:单选 `radio`、多选 `checkbox`(最多 5 个选项)、是非 `radio`,字段名 `qti_response_Q{n}:{n+1}:1_RESPONSE`,值 `Choice0..4`;隐藏字段 `qti_item_*` 原样回传。 - `exam.parse` 解析题面 / 选项字母 / 提交值;`exam.answer` 把题库或 AI 给的字母换成 `ChoiceN`;`exam.submit` 按页面 `ajaxSubmit` 的做法交卷。 - 题库没覆盖全部题目时不交卷,只打 warn 并列出缺答案的题号(避免瞎蒙把分数拉低);交完卷解析「已得 N 分」,低于 60 分打 warn 提示人工重考。 - `main.py` 的 `study()` 在视频轮次跑完、进度仍有缺口时调用一次考核,交完再按进度页复核。`opts["exam_auto"]=False` 可关掉自动交卷。 - 页面上已显示「您已完成测验」时直接跳过,不会重复交卷。 ### 怎么测 1. 找一门视频已刷完、只差考核的课跑一次,日志应出现「考核已交卷,得分 N」。 2. 故意让题库缺题(临时改 `bank/2026.json`):应出现「考核共 N 题,M 题没答案,本次不交卷」,且不提交。 3. 重跑已完成的课:日志应是「考核已完成」,不再请求 `finishTest2`。 ## 目录整理:config / database / llm 包,平台代码收进插件 **状态**:已实现,待实测确认。 ### 做了什么 - 二次清理:莞易学专属常量(站点地址、登录授权入口、课程板块、`NODE` / `DESENC`、`DEBUG_DIR` 等)迁到 `plugins/guanyixue/config.py`;测验请求模型 / 答题提示词 / 题型名迁到 `plugins/guanyixue/quiz.py`;`config.LOGDIR`、`SessionExpired` 再导出、`AccountsIn` 无人使用,已删除;核心模块的 star import 改为显式。 - `config.py` → `config/` 包:`config/__init__.py` 放常量 / 环境变量 / 根目录定位,`config/settings.py` 放系统设置默认值与读写。 - `settings_store.py` 拆开:`/api/settings` 移到 `routes/settings.py`;设置读写留在 `config/settings.py`(插件不再反向 import 路由)。 - `llm.py` → `llm/` 包:`llm_chat()` 在 `llm/__init__.py`,`/api/llm/test` 移到 `routes/llm.py`;后续大模型相关代码都放这个目录。 - `store.py` → `database/` 包,改用 SQLAlchemy 2.0 ORM:`database/models.py` 定义 settings / kv / accounts / login_log / run_log / run_state / snapshots 七张表,`database/__init__.py` 提供 engine、线程级 scoped Session 和原来的读写函数(函数名与返回结构不变)。 - `capture.py` → `plugins/guanyixue/capture.py`;抓包接口按账号所属平台走 `registry.get(...)` 的 `capture` / `captures` / `capture_reset`。 - `quiz.py` → `plugins/guanyixue/quiz.py`(莞易学专用);插件 `main.py` import 它来注册 `/api/quiz/*`。 - `app.py` 调 `routes.load_routes()` 自动扫描并导入 `routes/*`(新增路由文件不用改 `app.py`,之前漏写 import 会导致全部 `/api/*` 404);新增依赖 `sqlalchemy>=2.0`(`pyproject.toml`,需 `uv sync`)。 - `docs/architecture.md`、`README.md`、`docs/plugin.md` 同步更新目录与模块引用。 ### 怎么测 1. `uv sync` 后启动,`/api/health` 正常,`data/study.db` 能正常建表 / 读写。 2. 账号列表 / 登录 / 登录记录、课程日志、运行状态(`/api/runs`)、快照、设置保存、LLM 测试接口,行为与改造前一致。 3. 抓包开始 / 停止 / 查看记录正常;插件停用 / 启用 / 重载正常。 4. 莞易学插件启用时,`/api/quiz`、`/api/quiz/submit`、`/api/quiz/answer` 仍可用。 5. 莞易学账号登录(旧版 jspx 与新版 study 两条链路都走一遍)、刷新课程、学习正常;新版课程学习开头仍有「新版课程 ID」日志。 6. 首次启动不再创建 `data/logs` 目录(只要 `data/debug` 与数据库)。 7. 启动后不会再出现 `src/data/`:`database` 的 `DATA_DIR` 复用 `config.DATA`(仓库根 `data/`)。之前 `database/` 比旧 `store.py` 深一层,路径多上了一级才落到 `src/data`;原 `src/data/study.db` 里的账号 / 登录记录 / 日志已合并进根目录 `data/study.db`,页面应能看到两个 gdgxk 账号。 ### 已知取舍 - `/api/quiz/submit`、`/api/quiz/answer` 仍由莞易学插件自己注册;加载器负责在停用 / 重载时摘掉,所以不再重复注册。要做成通用路由需等第二个平台有测验。 ## 插件可删除:路由随插件进出、前端按能力显示、残件归位 **状态**:已实现,待实测确认(我只做了静态检查、前端 typecheck / build,以及在临时副本里实际启动验证)。 ### 做了什么 - `plugins/__init__.py`:加载器记录每个插件 import 期间注册到 `app` 的路由,停用 / 重载 / 加载失败时一并摘除;重载不再重复注册 `/api/quiz*`。 - `platforms/contract.py` / `base.py`:能力新增 `quiz`(`quiz(self, s, d, b)`)与 `capture`;`/api/platforms` 的 `capabilities` 会带上它们。莞易学 `quiz.py` 的取题拆成 `fetch_quiz`,`/api/quiz` 改走 `pf.quiz`。 - 去掉写死的 `guanyixue` 默认值:`accounts.platform` 默认空串、请求模型默认空串、`_capture_platform` 不再兜底;前端平台列表初值为空,「打开测验」「抓包」按 `capabilities` 显示,页脚和添加账号提示不再写「莞易学」。 - 残件归位:`script/des.js`、`desenc.js`、`study_video.py` → `plugins/guanyixue/script/`;`script/capture-jsglpt.py`、`test-gdgxk.py` → `plugins/gdgxk/script/`;`DESENC` 路径随插件目录走;`test-gdgxk.py` 改成走新的插件 API。 - 已验证(临时副本):正常启动;停用 → 启用 → 重载两次后 `/api/quiz*` 恰好各一条;删掉莞易学目录 / 删光所有插件目录后服务都能启动,`/api/platforms` 分别只剩 gdgxk / 为空。 ### 怎么测 1. `cd web && pnpm build` 后启动,界面能打开;平台下拉里有「莞易学」「广东公需课」。 2. 莞易学账号卡片「更多」菜单有「打开测验」「抓包」;广东公需课账号的菜单里没有这两项。 3. 「插件」弹窗里停用莞易学:`/api/quiz` 返回 404;再启用恢复;反复点「重载」不报错。 4. 把 `plugins/guanyixue` 整个目录挪走再启动:服务正常,添加账号只能选公需课;之前的莞易学账号登录时提示平台未实现,不影响其它账号。 5. 在仓库根目录执行 `python src/study_tool/plugins/guanyixue/script/study_video.py verify ` 能跑(脚本自己把 `src` 加进 `sys.path`)。 6. Docker 构建后 nodejs 仍能被登录调用(`DESENC` 现在指向插件目录)。 ## 本机启动读取根目录 .env **状态**:已实现,待实测确认。 ### 做了什么 - 修问题:以前只有 docker compose 会读 `.env`,本机 `python app.py` / `uv run app.py` / `start.bat` 下 `AUTH_TOKEN` / `PORT` / `RUN_LOG_KEEP` / `SNAPSHOT_KEEP` 全部不生效。 - `study_tool/__init__.py` 用 `python-dotenv` 加载仓库根 `.env`;放在包初始化处,早于 store / config 读环境变量(`database/__init__.py` 是 import 时就读 `DB_PATH` / `RUN_LOG_KEEP`)。已存在的环境变量优先,Docker 由 compose 注入的不受影响。 - 新依赖 `python-dotenv>=1.0`(`pyproject.toml` / `requirements.txt`,需 `uv sync`)。 - `app.py` 监听地址改成 `HOST` → `BIND_ADDR` → `127.0.0.1`,`.env` 里的 `BIND_ADDR` 本机也生效。 - README 环境变量表补 `.env` 说明与 `BIND_ADDR` / `TZ` 两行;`.env.example` 说明改为「本机与 Docker 都读」。 ### 怎么测 1. 根目录有 `.env`(`AUTH_TOKEN` 非空)时直接 `python app.py`,启动日志里的地址应带 `?token=`。 2. 不带 token 开 `http://127.0.0.1:8848/` 应显示「需要访问令牌」页;带 `?token=<值>` 正常进控制台。 3. `.env` 改 `PORT=8849` 重启 → 实际监听 8849;命令行 `set PORT=8850` 再启动应压过 `.env`。 4. `.env` 里 `BIND_ADDR=127.0.0.1` → 只绑本机;改 `0.0.0.0` → 同网段可访问。 ## 莞易学工具按阶段拆到 utils **状态**:已实现,待实测确认。 ### 做了什么 - `plugins/guanyixue/` 下的 `jspx.py` / `study.py` 按阶段拆到 `utils/`:`http.py`(会话 / cookie / token)、`login.py`(登录 / SSO / 续期)、`course.py`(旧版课程抓取与代际分发)、 `feedback.py`(反馈表 / 测验)、`learn.py`(旧版学习流程)、`spa.py`(新版 SPA)。 - `main.py` 只留 `Platform` 子类,按 `course["generation"]` 分发到 `learn` / `spa`。 - 新模块全部改成显式 import,不再用 `from study_tool.config import *` / `jspx import *`; `quiz.py` 的引用同步改成 `utils.*`。 ### 怎么测 1. `GET /api/plugins` 里 `guanyixue` 的 loaded 为 true。 2. 用莞易学账号走一遍登录、刷新课程、发现课程(旧版 / 新版各一门更好)。 3. 启动一次学习并停止,确认日志里仍有进度与完成状态输出。 ## 莞易学迁到插件目录 **状态**:已实现,待实测确认。 ### 做了什么 - `platforms/guanyixue.py` → `plugins/guanyixue/main.py`;`jspx.py` / `study.py` / `detect.py` 一并搬进 `plugins/guanyixue/`,插件自包含,热重载能覆盖全部实现模块。 - 导入路径改成 `study_tool.plugins.guanyixue.*`;`quiz.py` 里对 `jspx` / `study` 的引用同步更新。 - `platforms/__init__.py` 只剩运行时,`load_builtin()` 只调 `load_plugins()`。 - 新增 `plugins/guanyixue/plugin.json`(id / title / name)。 ### 怎么测 1. `GET /api/plugins` 列出 `gdgxk` 与 `guanyixue`,两者 enabled / loaded 都是 true。 2. 莞易学:刷新课程 → 发现课程 → 开始学习(旧版 / 新版各一次);测验弹窗照常。 3. 插件弹窗里对 guanyixue 点「重载」后再跑一次学习,行为不变。 ## 插件热加载 + 启用/停用清单 **状态**:已实现,待实测确认。 ### 做了什么 - `plugins/__init__.py` 重写:`load_plugins()` / `discover()` / `set_enabled()` / `reload()`。 - 热加载:把 `study_tool.plugins.<名字>`(含子模块)从 `sys.modules` 摘掉后重新 import, import 时 `registry.register()` 覆盖;重载前先 `registry.unregister()` 旧 id,改 id 不留残影。 - 启用清单存在设置项 `plugins`(`{"disabled": [...]}`):没列进去的默认启用, 新丢进目录的插件自动生效;停用的插件被摘出注册表,`/api/platforms` 与账号平台下拉同步消失。 - `platforms/registry.py` 新增 `unregister(pid)`。 - 新增接口:`GET /api/plugins`、`POST /api/plugins/{name}/enable|disable`、 `POST /api/plugins/reload`、`POST /api/plugins/{name}/reload`;失败返回 `{"ok": false, "error": ...}`。 - 前端顶栏新增「插件管理」弹窗(`web/src/components/PluginsDialog.tsx`):列出插件与状态, 支持启停 / 单个重载 / 全部重载;启停后会重新拉 `/api/platforms`,「添加账号」的平台下拉同步变化。 - 引擎 / 路由 / 平台实现均未改动。 ### 怎么测 1. `GET /api/plugins` 列出 `gdgxk`(enabled / loaded 为 true,capabilities 七项)。 2. `POST /api/plugins/gdgxk/disable` → `GET /api/platforms` 里 gdgxk 消失;再 enable 回来。 3. 改 `plugins/gdgxk/main.py` 里一句日志文案 → `POST /api/plugins/gdgxk/reload` → 不重启服务再跑一次学习,日志应变成新文案。 4. 重启服务:停用状态应被记住(设置项 `plugins`)。 5. 前端顶栏「插件管理」:停用 gdgxk → 「添加账号」的平台下拉立刻少一项;重新启用后恢复。 ## 平台契约改为子类实现(注册方式调整) **状态**:已实现,待实测确认。 ### 做了什么 - `Platform` 从 dataclass「注册时逐项传函数」改成基类:`id` / `label` / `entries` 是类属性, 能力是子类方法;没实现的阶段保持基类的 `None`,`capabilities()` 与 `/api/platforms` 不变。 - `plugins/gdgxk/main.py`(原 `platforms/gdgxk.py`):`session`(原 `new_session`)/ `login` / `list_courses` / `discover` / `study` 等收进 `class Gdgxk(Platform)`;解析 / 上报类 helper 仍是模块级函数。 - `platforms/guanyixue.py`:改成 `class Guanyixue(Platform)`,方法转调 `jspx` / `study`。 - `plugins/` 改成插件目录:`plugins/<名字>/plugin.json`(只放 `id` / `title` / `name`)+ `main.py` (`Platform` 子类);`plugins/__init__.py::load_plugins()` 扫目录 import,`load_builtin()` 调它。 插件里不再声明任何能力绑定。 - 引擎 / 路由 / `quiz.py` / 前端 / `platforms/contract.py` 均未改动。 - 新增 [docs/plugin.md](plugin.md):插件骨架、槽位契约、新增与迁移流程、常见坑。 ### 怎么测 1. `GET /api/platforms` 仍返回 `gdgxk` 与 `guanyixue`,`capabilities` 与改动前一致 (广东公需课:`login` / `session` / `renew` / `profile` / `courses` / `discover` / `study`)。 2. 莞易学:刷新课程 → 发现课程 → 开始学习照常(旧版 / 新版都跑一次)。 3. 广东公需课:登录 → 刷新课程 → 开始学习照常。 ## 平台流程下沉(契约第二步) **状态**:已实现,待实测确认。 ### 做了什么 - `SessionExpired` 移到 `platforms/errors.py`,`config.py` 继续导出;平台实现不再反向 import 引擎。 - 旧版学习流程(反馈表 / 测验 / 刷课件)从 `engine.py` 搬到 `platforms/jspx.py`;新版 SPA 流程(含真播视频)搬到 `platforms/study.py`。 - `engine.py` 只剩账号级编排(课程循环 / 会话续期 / 按平台分发,115 行),不再 import 任何具体平台模块。 - `routes/*`、`quiz.py` 去掉对 `platforms.jspx` / `platforms.study` 的 star import;平台没实现 `session` / `dump_cookies` 时明确报错,不再退回莞易学的默认实现(原来会静默按 dgjy 过滤 cookie)。 ### 怎么测 1. 莞易学:刷新课程 → 发现课程 → 开始学习(快速、常规各跑一次),反馈表 / 测验照常提交。 2. 广东公需课:开始学习,确认进度照常上报。 3. 错误分支:平台缺少 `session` 时,`/api/courses`、`/api/discover` 应返回「还没有实现 session」。 ## 添加账号选择平台 **状态**:已实现,待实测确认。 ### 做了什么 - 「添加账号」弹窗最上面新增「学习平台」下拉,选项来自 `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()` 已执行。 ## 新旧平台合并为「莞易学」 **状态**:已实现,待实测确认。 ### 做了什么 - `jspx.dgjy.net`(登录入口)和 `study.dgjy.net`(学习入口)在注册表里合并成一个平台: `id=guanyixue`、`label=莞易学`。卡片右上角只显示「莞易学」。 - 课程发现时按 URL 和页面标记判断代际:旧版 `legacy`(`tier-tree-one` / `tier3`)、 新版 `spa`(Vue SPA / `/training/*`),写进 `course["generation"]`。 - 学习实现按代际分发:`legacy` 走 `engine._study_builtin`,`spa` 走 `engine.run_study_course`。 - 课程日志里的「平台:」一行会显示 `莞易学 · 旧版页面` 或 `莞易学 · 新版页面`。 - `GET /api/platforms` 返回 `[{id:"guanyixue",label:"莞易学"}]`,前端用 label 展示。 ### 怎么测 1. 分别用一个旧版课程页账号和一个新版课程页账号(或同一账号的两门课)各跑一次自动学习。 2. 看卡片「课程日志」:应出现 `平台:莞易学 · 旧版页面` / `平台:莞易学 · 新版页面`,不再出现 `jspx` / `study` 平台名。 3. 卡片头部徽标应为「莞易学」;打开测验,交卷后后台日志正常,说明 `generation` 分支没走错。 ### 如果没生效 - 日志报「无法识别课程页结构」:把页面快照发出来,可能站点又改版了, 需要补 `platforms/detect.py` 的代际标记。 - 日志报 `平台 xxx 没有可用的学习实现`:`course["platform"]` 不是 `guanyixue`, 检查 `platforms/jspx.py` 的 `_discover_once()` 是否被其他分支覆盖了。 ## 前端 React 重构 **状态**:已实现,待实测确认。 ### 做了什么 - 删除单文件 `static/index.html`,前端源码迁到 `web/`:React 19 + TypeScript + Vite + Tailwind CSS v4,构建产物输出到 `static/`(`index.html` + `assets/*` + `favicon.svg`)。 - 页面功能保持不变:账号轮询(5s)、卡片内日志(1.5s)、课程勾选、登录 / 刷新 / 开始 / 停止 / 抓包 / 删除、自动交测验 / 反馈表开关、测验弹窗(手动作答 / AI 流式答题 / 再考一次)、 系统设置、系统日志、离线提示、Toast。 - FastAPI 新增 `/assets` 静态挂载和 `/favicon.svg`,`/` 仍返回 `static/index.html` 且禁止缓存。 ### 怎么测 1. 直接 `python app.py`,打开页面:账号卡片、状态、课程列表、进度条、日志折叠都正常。 2. 走一遍登录 → 刷新课程 → 勾选 → 开始学习 → 停止,观察按钮 loading 和日志面板。 3. 打开测验(含单选 / 多选 / 判断 / 填空),提交后看标准答案和「再考一次」。 4. 设置里改大模型 / 学习模式并保存,刷新后值仍在;测试连接正常。 5. 右上角系统日志可查看、清空;断网后出现「后端未连接」提示。 ### 如果没生效 - 页面空白 / 404 `/assets/...`:确认 `static/` 里有构建产物,且 `python app.py` 是在仓库根启动。 - 改了 `web/` 但页面没变:执行 `cd web && pnpm build`,构建产物才会进 `static/`。 - 端口不是 8848 时 `pnpm dev` 的 `/api` 代理会打错:改 `web/vite.config.ts` 的 `server.proxy`。 ## 首次进入课程自动加入(syncSelf) **状态**:已实现,待实测确认。 ### 做了什么 首次进入没加入过的课程时,平台课时树会回 `code=500`「你还没有加入学习…」,前端把这句话 渲染成挡路的弹层,不点就进不去。真正把账号同步进课程的是页面自己发的这一发: ``` GET /mod/school/wanpeng/syncSelf?courseCode= ``` (抓包实录:`isJoin=0 needJoin=1` → syncSelf 约 2s → 再拉 `getById` 就是 `isJoin=1`。) `POST /course/front/student/join` 对 `isFreeJoin=0` 的慕课课会被拒「该课程无法直接加入,请联系管理员」, 所以现在 `ensure_study_join` 的顺序是:已加入 → `student/join` → `syncSelf` + 轮询 `getById`(约 11s) → 刷新 jspx 课程页复查。`course_code` 取自 `trainCourse/getById` 的 `courseCode`。 ### 怎么测 1. 用一个没加入过该课程的账号,或把某课时的 `studyLog` 清掉(制造「首次进入」)。 2. 跑一次自动学习,看日志里加入那一段。 预期出现其中一条: - `加入培训:同步课程页后已加入(student/join 被拒:…;syncSelf=请求成功)` - `加入培训:刷新课程页后已加入(…)` 确认点是:之后的 `period/study/log` 真记账(课时 `completeTime` 有值),且全程不需要人工点弹层。 ### 如果没生效 - 日志是 `加入培训被拒:…;课程没有 courseCode,无法走同步接口` → 该账号/该课程的 `getById` 没返回 `courseCode`,需要再抓一次包定位它的来源 (抓包时重点看 `syncSelf?courseCode=` 参数是从哪个响应/本地状态来的)。 - 日志显示 syncSelf 成功但 `getById` 一直 `isJoin=0` → 同步在服务端没落库,先看 `syncSelf` 响应的 `result.code`,再考虑补 `/mod/gjt/roleList` 之外的接口。 ## AI 答题(流式 + 重试 + 日志) **状态**:已实现,待实测确认。 ### 做了什么 - `/api/quiz/answer` 从「等全部批次算完再返回」改成流式(`text/event-stream`):每批 5 题算完就推一行,连接一直有数据,反代不会空闲断连。 - 每批失败按错误类型决定是否重试一次:超时 / 5xx / 429 / 空内容 / 非 JSON 会重试(第二次关掉 `response_format`、口头要求 JSON),鉴权类错误直接放弃这一批。 - 一批里只答出部分题时,第二轮只补问没答出来的题。 - 解析加了兜底:标准 JSON、markdown 包裹、截断 JSON、`1.A / 第3题:B / 3. 错误` 这类自由文本都能识别;多选连写 `AC` 会拆成 `A#C`。 - 日志:开始(题数/批数/模型/超时)、每批耗时与题数、失败原因、模型原文摘要、结束统计;同时打到控制台、SQLite(`/api/log/history?key=<账号>`)和该账号卡片的「课程日志」。 ### 怎么测 1. 打开一个测验,点「AI 答题」,看状态栏是否逐批更新「Ns · 已填 x/y 题」。 2. 结束后展开该账号卡片的「课程日志」,应能看到 `AI答题 开始/第 1/2 批…/结束` 若干行;控制台同样能看到。 3. 故意把 base_url 改错或断开网络再点一次,日志里应出现 `失败 x.xs:LLM 404/超时…`,界面提示「详见该账号课程日志」。 ### 如果没生效 - 状态栏一直停在 0/0、日志里没有任何 `AI答题` 行 → 说明请求没到后端(看浏览器 Network 里 `/api/quiz/answer` 的状态码)。 - 日志里每批都 `失败 … LLM 401` → api_key 不对;`LLM 404` → base_url 少了 `/v1`。 - 每批都是 `本批只答出 0 题` 且模型原文很长 → 模型没按要求给题号,可把批次调小(`quiz.py` 里 `QUIZ_BATCH`)或换个指令遵循更好的模型。 ## 模块拆分 + 统一日志源 + 平台插件 **状态**:已实现,待实测确认。 ### 做了什么 - 代码改成 `src/` 包布局:`src/study_tool/` 下按层分 `config`(常量)、`runtime`(状态 + 日志)、`store`(SQLite)、`engine`(学习引擎)、`capture`、`models`、`settings_store`、`llm`、`quiz`、`platforms/`(平台插件)、`routes/`(HTTP 接口)。根目录 `app.py` 只做启动壳,`python app.py` / `start.bat` / Dockerfile 的启动方式不变;新增 `pyproject.toml`(`pip install -e .` 后可用 `study-tool` 命令)。 - 平台插件化:`platforms/base.py` 定义 `Platform(id, label, study)`,`platforms/registry.py` 提供 `register/get/all`,`platforms/jspx.py` 与 `platforms/study.py` 各自注册;`engine.run_one_course` 发现课程后改成 `registry.get(course["platform"]).study(...)`,不再写死 `if platform == "study"`。新增平台 = 加一个模块 + 一行 import,`docs/architecture.md` 有完整步骤。 - 日志统一走 `runtime.log(key, msg, level)`:控制台、内存(`/api/log`)、SQLite(`/api/log/history`)同一份;设置保存、服务启动都会写。 - 新增右上角「系统日志」弹窗,看 `key=system` 的全局日志;卡片「课程日志」看该账号的日志。 - GBK 控制台编不了的字符(⚠ 等)自动降级,不再让 worker 线程抛异常。 ### 怎么测 1. 启动服务,控制台应出现 `[system] SQLite: …` 和 `[system] 控制台启动 -> …`。 2. 页面右上角「系统日志」→ 看到上面两行;保存一次设置 → 多一行「设置已更新:模型 …」。 3. 点账号卡片的「课程日志」→ 学习日志和以前一致;点「清空」后不再出现旧行。 4. 跑一门课(旧平台 / 新平台各一次),日志里应出现该课程的流程,说明插件分发走到了对应平台。 5. `python -c "import sys; sys.path.insert(0,'src'); from study_tool.platforms import load_builtin, registry; load_builtin(); print(registry.all())"` → 应看到 jspx / study 两个平台。 4. 跑一次 AI 答题 → 该账号「课程日志」里是 `AI答题 …`,控制台同步同一份内容。 ### 如果没生效 - 控制台没有 `[system]` 行 → 跑的是旧进程(端口被占用时新代码会提示后直接退出),先结束旧 PID 再启动。 - 「系统日志」弹窗空白 → `GET /api/log?key=system` 有 lines 就是前端缓存问题,刷新即可;没有 lines 说明还没产生 system 日志,保存一次设置就能看到。 ## 广东公需课平台接入(jsglpt → jsxx) **状态**:登录(账号密码 + 图形验证码 OCR)、课程列表、课程发现、播放注册、进度心跳全部实测跑通;只剩「未学完课程」的进度增长待确认。 ### 已确认的接口 - 登录:`POST https://jsglpt.gds.edu.cn/loginIn.do`,`loginUser.userName` / `loginUser.password` 都是 **base64**(账号是手机号),`loginUser.loginType=normal`;`captcha` 是**强制**的 4 位图形验证码,`GET /login/readCaptcha.action` 返回 `"data:image/png;base64,..."`,用 `ddddocr` 离线识别(识别错就换一张重试,最多 5 次)。登录失败时服务端把原因写回登录页 `#errorText`(如「请输入正确的验证码!」),据此区分是验证码还是密码问题。 - 换票:`POST /teacher/ssoItlmsGxkTicket.action?verificaCode=true&page=` → `ITLMS-||`;把票按 `||` 切成两段,`GET https://jsxx.gds.edu.cn/nts/loginToStoreByAccount?ticket=<前段>&ticketId=<后段>` 才拿到学习平台会话(入口来自 jsglpt 首页的隐藏表单 `SSO_ITLMS_PUBLIC_STUDY_FORM`;直接带 `?ticket=` 访问 `courseRegister` 是无效的)。两个域名的 cookie 要在同一个 session 里。 - 课程列表:`GET /uc/store/courseRegister`,页面里是 `showCourse('<课程id>','<课程名>')`;同一张卡片里还有 `课程学时:N`、`培训年度:YYYY`、`未完成/已完成` 徽标,已解析成 `section` / `info` / `status_label` 显示在卡片上。 - 学习页:`GET /study/course/<课程id>?v=1`(服务端渲染、UTF-8),含 `url1`(CDN 直链 MP4)、`t`(总秒数)、`md5Uid`、`/a_/...` 上报前缀。 - 播放注册:`POST /a_/study/video/user/updateVideoStatus1`(`_method=PUT&id=<课件id>&md5Uid=&playbackSessionId=&playbackTakeover=true`)→ `{time, md5}`。 - 进度心跳:`POST /a_/study/video/user/<课件id>/updateViewTime1`(`_method=PUT&aid=...&isLimit=false&md5&expires&md5Uid&lastViewTime=<秒>&playbackSessionId`)。 - 已看秒数:`GET /a_/study/video/user/getViewTime?id=&md5=&expires=&md5Uid=`,`responseData` 直接就是秒数。 ### 踩到的坑 - 三个上报接口必须带 **Referer(指向课程页)**,否则 500 且响应体为空。 - `isLimit` 只能传 `false` / `true`,传 `"N"` 会 400。 - 课程列表页、学习页都是 UTF-8(别按 GBK 解,会花屏)。 ### 关键结论 进度不靠播放器,`lastViewTime` 直接写服务端。实测把 2640 秒的课报到 2700 秒也被接受,说明可以跳着报、不必真播;默认 `speed=60`、`beat=2`,44 分钟的课约 45 秒报完。课程是否学完看 `getViewTime >= t`。 ### 怎么测 1. 「添加账号」平台选「广东公需课」,填手机号 + 密码 → 能拉到课程列表就算登录成功。 2. 选一门**没学完**的课 → 开始学习 → 看「课程日志」里的进度上报行,再回平台页面核对已学时长。 3. 登录若报「平台要求图形验证码」,说明密码登录要过验证码,先改用浏览器登录后导入 cookie,再补 OCR。 ### 如果没生效 - 400 → 看 Referer 有没有、`isLimit` 是不是 `false`。 - 500 且空响应 → 少了 Referer,或 `md5Uid` 取错。 - 课程列表为空 → jsxx 会话失效,重新登录(或等 `renew_session` 换票)。 - 登录报「请输入正确的验证码!」→ OCR 没认对,重试即可;连续失败说明 `ddddocr` 没装好。 ## 运行状态与日志修复(重启标记 / 历史日志 / 删账号清日志) **状态**:已实现,待实测确认。 ### 做了什么 - **重启标记不再污染新一轮运行**:`app.py` 的 `hydrate_runs()` 把上次没跑完的状态标成「已中断」后立刻落库(库里不再一直挂着 `studying`);`runtime.set_state()` 在状态从非运行态切到 `discovering` / `studying` 时清掉上一轮的 `error` / `progress` / `finished_at`,所以点「开始学习」后不会还挂着「服务重启,上次运行已中断」。 - **重启后历史日志能读出来了**:前端拉日志的兜底条件原是「内存为空 **且** status 为空」,而重启后 status 恰好是 `interrupted`(非空),历史永远不读。现在只看内存是否为空(`web/src/App.tsx`)。 - **删账号连带清日志**:`store.account_delete()` 除 run_state / run_log(账号 id)外,还会按**用户名**清 run_log 与 login_log(登录日志的 key 和 `login_log.username` 记的是手机号,不是账号 id);`DELETE /api/accounts/{id}` 同时把内存里这两个 key 的日志一起丢掉。 ### 怎么测 1. 重启服务后打开页面:卡片应是新状态;点「开始学习」,红条与上一轮的旧进度立即被覆盖。 2. 重启服务(内存日志清空)后展开某账号「课程日志」:应显示 SQLite 里的历史行,而不是「暂无日志」。 3. 删掉一个账号再刷新:该账号的课程日志、登录记录(`/api/loginlog`)都不应再有它的行;`system` 与其它账号的日志必须保留。 ### 如果没生效 - 红条还在 → 看 `run_state` 里那条是否仍带旧 `error`,删掉该行即可复位。 - 历史日志还是空 → 确认 `static/` 已重新构建(`cd web && pnpm build`)并强刷页面。 - 删完还有日志 → 平台的登录用户名与账号 `username` 字段不一致时清不掉,需按账号实际写入的 key 处理。