Add collapsible draggable study panel with saved layout

This commit is contained in:
2026-10-12 14:18:19 +08:00
parent 281ab20018
commit 1f6706efbb
6 changed files with 197 additions and 18 deletions
+19 -5
View File
@@ -1,6 +1,6 @@
# 学员中心 · 2倍速学习助手使用说明 # 学员中心 · 2倍速学习助手使用说明
适用版本:**1.4.1**。浏览器设置核对日期:**2026-10-06**。 适用版本:**1.4.2**。浏览器设置核对日期:**2026-10-06**。
[仓库首页](README.md) · [脚本文件](icampus-study-helper.user.js) · [更新记录](CHANGELOG.md) [仓库首页](README.md) · [脚本文件](icampus-study-helper.user.js) · [更新记录](CHANGELOG.md)
@@ -37,9 +37,9 @@ Chrome不能在无痕窗口内安装扩展,应先普通窗口安装再允许
2. 点击文件页面的“下载”,或打开“原始文件 / Raw”后保存。文件应名为 `icampus-study-helper.user.js`,不能是仓库HTML网页或误存的 `.txt`。下载整个仓库压缩包的用户先解压。 2. 点击文件页面的“下载”,或打开“原始文件 / Raw”后保存。文件应名为 `icampus-study-helper.user.js`,不能是仓库HTML网页或误存的 `.txt`。下载整个仓库压缩包的用户先解压。
3. 点击篡改猴图标 →“管理面板 / Dashboard”→“实用工具 / Utilities”。 3. 点击篡改猴图标 →“管理面板 / Dashboard”→“实用工具 / Utilities”。
4. 找到“从文件导入 / Import from file”文件选择按钮,选中该 `.user.js`。 4. 找到“从文件导入 / Import from file”文件选择按钮,选中该 `.user.js`。
5. 在安装/导入页面核对名称“学员中心 · 2倍速学习助手”和版本 **1.4.1**,按提示确认安装或更新。仅选择文件不等于安装完成。 5. 在安装/导入页面核对名称“学员中心 · 2倍速学习助手”和版本 **1.4.2**,按提示确认安装或更新。仅选择文件不等于安装完成。
6. 回到“已安装脚本”,确认开关开启。 6. 回到“已安装脚本”,确认开关开启。
7. 刷新课程页,确认左下角版本为 **1.4.1**。 7. 刷新课程页,确认左下角版本为 **1.4.2**。
篡改猴导入入口见 [官方导入导出说明](https://www.tampermonkey.net/faq.php?locale=zh&q=Q106)。界面文字可能因版本不同而变化;若该入口不接受 `.user.js`,直接用下一节复制方式,无需将脚本改成JSON。 篡改猴导入入口见 [官方导入导出说明](https://www.tampermonkey.net/faq.php?locale=zh&q=Q106)。界面文字可能因版本不同而变化;若该入口不接受 `.user.js`,直接用下一节复制方式,无需将脚本改成JSON。
@@ -77,10 +77,20 @@ Chrome不能在无痕窗口内安装扩展,应先普通窗口安装再允许
| 播放受限时尝试静音 | 有声自动播放被拒绝时尝试一次静音;成功后面板显示“静音” | | 播放受限时尝试静音 | 有声自动播放被拒绝时尝试一次静音;成功后面板显示“静音” |
| 已启动 · 等待恢复 | 仍启用,查看提示;普通等待会定期检查或随页面变化接续 | | 已启动 · 等待恢复 | 仍启用,查看提示;普通等待会定期检查或随页面变化接续 |
| 人工取消 | 关闭自动运行并暂停助手控制的视频 | | 人工取消 | 关闭自动运行并暂停助手控制的视频 |
| 收起/展开 | 右上角收起面板成为状态条,点击状态条展开;收纳不停止任务 |
| 拖动标题/收纳条 | 按住并拖动来移动面板;其他按钮仍按正常点击操作 |
| 运行记录 → 更新记录 | 显示当前页的近期运行记录 | | 运行记录 → 更新记录 | 显示当前页的近期运行记录 |
更换运行范围或“只做本门测验”前,先人工取消。启动状态保存在当前标签页会话中,同一页普通刷新和自动恢复刷新都可接续。新标签页、浏览器重启、结束无痕会话不保证保留。手动暂停视频时,助手等待手动播放,不抢占暂停操作。 更换运行范围或“只做本门测验”前,先人工取消。启动状态保存在当前标签页会话中,同一页普通刷新和自动恢复刷新都可接续。新标签页、浏览器重启、结束无痕会话不保证保留。手动暂停视频时,助手等待手动播放,不抢占暂停操作。
### 面板收纳和拖拽
点击标题右侧 **“收起”**,完整面板变成小型状态条,显示“运行中”“等待恢复”或“未启动”。收纳仅改变显示,不会暂停视频、取消任务或修改运行选项。鼠标停在状态条上可查看当前提示,点击 **“展开”** 恢复完整面板;需要停止时先展开再点“人工取消”。也可用Tab聚焦收起/展开按钮,按Enter操作。
按住展开面板的标题区域,或按住整个收纳条,拖动到合适位置即可。拖动收纳条后放开不会误展开,普通点击才展开。表单、收起和其他操作按钮不作为拖拽区域。
拖动结束、收起/展开时保存位置和状态,刷新后恢复。窗口缩小或展开后空间不足,会自动调整到可见范围;内容较多时面板内部可滚动。偏好保存在课程站点本地存储,普通/无痕环境分别记忆。浏览器禁止存储时仍可本页拖动和收纳,但不能保证刷新保留。
### 全过程静音 ### 全过程静音
勾选 **“全过程静音”** 后,当前课程播放器立即静音,不用等待点击开始;每次启动、加载新视频或更换课程播放器都会应用静音,网页或用户在播放器中取消静音时也会重新静音。播放位置、音量滑块和播放速度不因该开关改变。 勾选 **“全过程静音”** 后,当前课程播放器立即静音,不用等待点击开始;每次启动、加载新视频或更换课程播放器都会应用静音,网页或用户在播放器中取消静音时也会重新静音。播放位置、音量滑块和播放速度不因该开关改变。
@@ -167,7 +177,7 @@ Chrome经常回收课程页时,可在“设置 → 性能 → 始终保持活
| 保存位置 | 内容 | | 保存位置 | 内容 |
| --- | --- | | --- | --- |
| 网站本地存储 | 答案记忆、最近提交快照、后台、全过程静音和静音回退偏好、标签页运行锁 | | 网站本地存储 | 答案记忆、最近提交快照、后台、全过程静音和静音回退偏好、面板位置及收纳状态、标签页运行锁 |
| 当前标签页会话存储 | 启用状态、运行范围和刷新恢复记录 | | 当前标签页会话存储 | 启用状态、运行范围和刷新恢复记录 |
| 当前页内存 | 运行记录、已读取课程列表和时长样本 | | 当前页内存 | 运行记录、已读取课程列表和时长样本 |
@@ -193,12 +203,16 @@ Chrome经常回收课程页时,可在“设置 → 性能 → 始终保持活
| 课程重名 | 手动进入目标课程,选仅当前课程 | | 课程重名 | 手动进入目标课程,选仅当前课程 |
| 登录、前置限制、无保存确认 | 按平台提示处理后刷新,同一标签页可接续 | | 登录、前置限制、无保存确认 | 按平台提示处理后刷新,同一标签页可接续 |
| 视频没有声音 | 如勾选了全过程静音,先关闭该开关;再检查播放器原有静音状态或受限播放的静音回退 | | 视频没有声音 | 如勾选了全过程静音,先关闭该开关;再检查播放器原有静音状态或受限播放的静音回退 |
| 只看到小型助手状态条 | 面板已收纳,点击状态条展开;收纳期间任务仍继续 |
| 想移动面板 | 拖动标题或收纳条;不要拖动表单或操作按钮 |
反馈时展开“运行记录”并点“更新记录”,提供脚本版本、普通/无痕模式、运行范围、面板文字和必要记录,不要附凭据或完整敏感抓包。 反馈时展开“运行记录”并点“更新记录”,提供脚本版本、普通/无痕模式、运行范围、面板文字和必要记录,不要附凭据或完整敏感抓包。
## 12. 验证与恢复边界 ## 12. 验证与恢复边界
91项本地行为测试:16播放/进度、13测验、13媒体恢复、12后台、10持续运行/完成条件、17统计/自动播放回退、10全过程静音。覆盖低于100分但已合格换课、刷新接续、缺失时长、暂停预测、静音成功和仍被拒绝,以及静音开关、播放器更换、声音重置、后台与原始状态恢复等情况。 101项本地行为测试:16播放/进度、13测验、13媒体恢复、12后台、10持续运行/完成条件、17统计/自动播放回退、10全过程静音、10面板操作。覆盖低于100分但已合格换课、刷新接续、缺失时长、暂停预测、静音回退、面板位置保存、状态恢复、拖动误点击抑制和存储失败等情况。
面板另在本地模拟课程页面使用Playwright与预装Chrome验证:1280×900桌面及360×640窗口下的收起、展开、标题/状态条拖动、刷新保留、运行意图保留、屏幕边界和键盘展开均通过,无相关控制台错误。该验证只覆盖本地面板交互,没有更新用户浏览器中的已安装脚本;真实课程页和实际触屏设备尚未验证。
现场此前已观察到网页原生2倍速、平台保存响应及点击播放后恢复。全过程静音经过本地模拟测试,尚未在真实课程上验证。整体统计、估算和静音回退也尚未完成所有真实课程和长时间后台端到端验证,不能保证最终学分或全部课程结构兼容。 现场此前已观察到网页原生2倍速、平台保存响应及点击播放后恢复。全过程静音经过本地模拟测试,尚未在真实课程上验证。整体统计、估算和静音回退也尚未完成所有真实课程和长时间后台端到端验证,不能保证最终学分或全部课程结构兼容。
+8
View File
@@ -2,6 +2,14 @@
[仓库首页](README.md) · [安装与使用](2倍速脚本使用说明.md) [仓库首页](README.md) · [安装与使用](2倍速脚本使用说明.md)
## 1.4.2(2026-10-12)
- 新增面板“收起/展开”,收起后显示运行状态条,自动任务继续。
- 支持拖动面板标题和收纳条移动,避免拖动后误触展开。
- 保存面板位置和收纳状态,刷新后恢复;窗口缩小及展开时限制在可见范围内。
- 新增10项行为测试,总计101项;本地Chrome模拟页面通过桌面和窄窗口的交互验证。
- 更新安装版本和使用说明。
## 1.4.1(2026-10-10) ## 1.4.1(2026-10-10)
- 新增“全过程静音”开关,默认关闭,可随时开启,立即静音当前课程播放器。 - 新增“全过程静音”开关,默认关闭,可随时开启,立即静音当前课程播放器。
+7 -4
View File
@@ -2,7 +2,7 @@
用于 [学员中心课程页](https://icampus.yunyinsh.com/course/) 的 Tampermonkey(篡改猴/油猴)脚本。支持 **2倍速播放、全过程静音、自动续播、测验作答和交卷、按平台合格状态换课、后台播放,以及整体进度和完成时间估算**。 用于 [学员中心课程页](https://icampus.yunyinsh.com/course/) 的 Tampermonkey(篡改猴/油猴)脚本。支持 **2倍速播放、全过程静音、自动续播、测验作答和交卷、按平台合格状态换课、后台播放,以及整体进度和完成时间估算**。
当前版本:**1.4.1**。普通使用只需要桌面 Chrome 和篡改猴,无需安装 Node.js、Git 或运行命令。 当前版本:**1.4.2**。普通使用只需要桌面 Chrome 和篡改猴,无需安装 Node.js、Git 或运行命令。
**[获取脚本](icampus-study-helper.user.js) · [完整安装与使用说明](2倍速脚本使用说明.md) · [更新记录](CHANGELOG.md)** **[获取脚本](icampus-study-helper.user.js) · [完整安装与使用说明](2倍速脚本使用说明.md) · [更新记录](CHANGELOG.md)**
@@ -12,7 +12,7 @@
2. 地址栏输入 `chrome://extensions`,找到 Tampermonkey →“详情”,按下表检查设置。 2. 地址栏输入 `chrome://extensions`,找到 Tampermonkey →“详情”,按下表检查设置。
3. 在仓库打开 [icampus-study-helper.user.js](icampus-study-helper.user.js),使用文件页面的“下载”或“原始文件 / Raw”保存完整脚本。 3. 在仓库打开 [icampus-study-helper.user.js](icampus-study-helper.user.js),使用文件页面的“下载”或“原始文件 / Raw”保存完整脚本。
4. 点击篡改猴图标 →“管理面板”→“实用工具”→“从文件导入 / Import from file”,选择下载的 `.user.js`,按提示安装。找不到该入口或不接受文件时,使用下方复制方法。 4. 点击篡改猴图标 →“管理面板”→“实用工具”→“从文件导入 / Import from file”,选择下载的 `.user.js`,按提示安装。找不到该入口或不接受文件时,使用下方复制方法。
5. 登录课程网站并刷新,确认左下角显示 **“2倍速学习助手 · 1.4.1”**。打开课程列表,展开目标分组,选择范围并点击“开始”。停止时点击“人工取消”。 5. 登录课程网站并刷新,确认左下角显示 **“2倍速学习助手 · 1.4.2”**。打开课程列表,展开目标分组,选择范围并点击“开始”。停止时点击“人工取消”。
扩展应先在普通窗口安装,再按需允许在无痕窗口运行;参考 [Chrome 官方扩展帮助](https://support.google.com/chrome_webstore/answer/2664769?hl=en)。 扩展应先在普通窗口安装,再按需允许在无痕窗口运行;参考 [Chrome 官方扩展帮助](https://support.google.com/chrome_webstore/answer/2664769?hl=en)。
@@ -47,7 +47,8 @@
| 课程完成判断 | **学习进度100%+平台判定测验合格**才换课;无需测验的课程只核对学习完成 | | 课程完成判断 | **学习进度100%+平台判定测验合格**才换课;无需测验的课程只核对学习完成 |
| 持续启用 | 开始后保持启用,同一标签页刷新可接续;“人工取消”清除启用状态 | | 持续启用 | 开始后保持启用,同一标签页刷新可接续;“人工取消”清除启用状态 |
| 后台播放 | 默认开启“切标签页继续播放”,保留网站正常播放和保存流程 | | 后台播放 | 默认开启“切标签页继续播放”,保留网站正常播放和保存流程 |
| 全过程静音(新增) | 勾选后立即静音,切换课程/视频和播放器重建后继续静音,刷新保留偏好 | | 面板收纳与拖拽(新增) | 点击“收起”变为状态条,点击“展开”恢复;拖动标题或收纳条移动,刷新记住位置和状态 |
| 全过程静音 | 勾选后立即静音,切换课程/视频和播放器重建后继续静音,刷新保留偏好 |
| 自动播放恢复 | 有声自动播放被拒绝时可尝试一次静音播放;仍失败则等待点击,避免定时空转 | | 自动播放恢复 | 有声自动播放被拒绝时可尝试一次静音播放;仍失败则等待点击,避免定时空转 |
| 整体进度 | 完成百分比、已完成/总课程数、平均学习进度、待合格测验数 | | 整体进度 | 完成百分比、已完成/总课程数、平均学习进度、待合格测验数 |
| 时间预测 | 显示预计剩余时长和完成日期时间;缺失时长用已知课程均值粗估并标明 | | 时间预测 | 显示预计剩余时长和完成日期时间;缺失时长用已知课程均值粗估并标明 |
@@ -72,6 +73,8 @@
需要全程无声音时,勾选新增的 **“全过程静音”**(默认关闭)。该开关独立于自动运行,在开始前、播放中、等待和后台均保持课程播放器静音;人工取消只停止自动运行,不关闭静音偏好。关闭静音开关后恢复播放器开启前的静音状态;原本已静音的播放器仍保持静音。普通与无痕环境分别保存偏好。 需要全程无声音时,勾选新增的 **“全过程静音”**(默认关闭)。该开关独立于自动运行,在开始前、播放中、等待和后台均保持课程播放器静音;人工取消只停止自动运行,不关闭静音偏好。关闭静音开关后恢复播放器开启前的静音状态;原本已静音的播放器仍保持静音。普通与无痕环境分别保存偏好。
面板右上角点击 **“收起”**,会变成显示运行状态的小条,自动任务继续;点击小条 **“展开”** 恢复控制面板。按住面板标题或收纳条拖动即可移动。位置和收纳状态在同一站点存储环境中保存,刷新后恢复;窗口缩小时自动限制在可见范围内。需要人工取消时先展开面板。
“已启动 · 等待恢复”仍是启用状态;普通等待会定期检查或随页面变化接续。不支持的题型、登录失效、自动播放仍受限等情况需人工处理。手动暂停视频后会等待手动恢复播放;更换范围前先“人工取消”。 “已启动 · 等待恢复”仍是启用状态;普通等待会定期检查或随页面变化接续。不支持的题型、登录失效、自动播放仍受限等情况需人工处理。手动暂停视频后会等待手动恢复播放;更换范围前先“人工取消”。
**仓库推送不会自动更新浏览器已安装脚本。** 更新时重新导入最新文件,或在原脚本中替换全部代码;然后分别刷新实际使用的普通/无痕课程标签页并检查面板版本。只启用一份助手,停用旧的“学员中心 · 本地网络诊断”等测试脚本。 **仓库推送不会自动更新浏览器已安装脚本。** 更新时重新导入最新文件,或在原脚本中替换全部代码;然后分别刷新实际使用的普通/无痕课程标签页并检查面板版本。只启用一份助手,停用旧的“学员中心 · 本地网络诊断”等测试脚本。
@@ -86,7 +89,7 @@
## 验证与开发 ## 验证与开发
目前 **91项本地行为测试**,覆盖播放、测验、恢复、后台、完成判断、统计、自动播放恢复和全过程静音。实际卡住页面此前已通过点击播放恢复2倍速,并重新收到平台保存确认。全过程静音新增10项本地测试,尚未在真实课程上验证;统计、预测和静音回退也尚未完成所有课程及长期后台的真实端到端验证。 目前 **101项本地行为测试**,覆盖播放、测验、恢复、后台、完成判断、统计、自动播放恢复、全过程静音及面板操作。面板收纳、拖动、刷新恢复和窗口边界另通过本地模拟页面的Chrome浏览器验证(1280×900、360×640),无相关控制台错误。尚未在真实课程页验证新版面板;全过程静音、统计、预测和静音回退也尚未完成所有课程及长期后台的真实端到端验证。
开发验证需要 Node.js 18+,无需安装第三方依赖: 开发验证需要 Node.js 18+,无需安装第三方依赖:
+78 -7
View File
@@ -1,8 +1,8 @@
// ==UserScript== // ==UserScript==
// @name 学员中心 · 2倍速学习助手 // @name 学员中心 · 2倍速学习助手
// @namespace local.icampus.study // @namespace local.icampus.study
// @version 1.4.1 // @version 1.4.2
// @description 支持全过程静音、切换标签页继续2倍速播放与保存进度,自动测验及错题重试。 // @description 支持面板收纳与拖拽、全过程静音、后台2倍速播放、自动测验及错题重试。
// @match https://icampus.yunyinsh.com/course/* // @match https://icampus.yunyinsh.com/course/*
// @run-at document-start // @run-at document-start
// @grant none // @grant none
@@ -73,6 +73,11 @@
const minutes=Math.ceil(Math.max(0,seconds)/60); const minutes=Math.ceil(Math.max(0,seconds)/60);
return minutes>=60?`${Math.floor(minutes/60)}小时${minutes%60}分`:`${minutes}分钟`; return minutes>=60?`${Math.floor(minutes/60)}小时${minutes%60}分`:`${minutes}分钟`;
} }
function panelPosition(x,y,width,height,viewportWidth,viewportHeight) {
const maxX=Math.max(8,viewportWidth-width-8),maxY=Math.max(8,viewportHeight-height-8);
return {x:Math.max(8,Math.min(maxX,Number.isFinite(x)?x:16)),
y:Math.max(8,Math.min(maxY,Number.isFinite(y)?y:16))};
}
function videoPlan(detail) { function videoPlan(detail) {
if (!detail?.course || !Array.isArray(detail.resources)) return {kind: 'wait'}; if (!detail?.course || !Array.isArray(detail.resources)) return {kind: 'wait'};
const resources = [...detail.resources].sort((a, b) => a.sortNo - b.sortNo); const resources = [...detail.resources].sort((a, b) => a.sortNo - b.sortNo);
@@ -136,7 +141,7 @@
} catch { return null; } } catch { return null; }
} }
const core = {normalize, route, endpoint, bodyJSON, courseDone, examPassed, videoPlan, resolveCards, leaseAvailable, const core = {normalize, route, endpoint, bodyJSON, courseDone, examPassed, videoPlan, resolveCards, leaseAvailable,
questionKey, selectionKey, chooseAnswer, learnCard, quizEndpoint, learningPercent, overallProgress, durationText}; questionKey, selectionKey, chooseAnswer, learnCard, quizEndpoint, learningPercent, overallProgress, durationText, panelPosition};
if (typeof module === 'object' && module.exports && typeof window === 'undefined') { module.exports = core; return; } if (typeof module === 'object' && module.exports && typeof window === 'undefined') { module.exports = core; return; }
if (window.top !== window.self || document.getElementById('icampus-study-helper')) return; if (window.top !== window.self || document.getElementById('icampus-study-helper')) return;
@@ -148,6 +153,7 @@
const INTENT_KEY = 'icampus.study.v1.enabled'; const INTENT_KEY = 'icampus.study.v1.enabled';
const MUTED_KEY = 'icampus.study.v1.mutedFallback'; const MUTED_KEY = 'icampus.study.v1.mutedFallback';
const FULL_MUTED_KEY = 'icampus.study.v1.fullMuted'; const FULL_MUTED_KEY = 'icampus.study.v1.fullMuted';
const PANEL_KEY = 'icampus.study.v1.panel';
let fullMutedEnabled = false; let fullMutedEnabled = false;
try { fullMutedEnabled = localStorage.getItem(FULL_MUTED_KEY) === 'on'; } catch {} try { fullMutedEnabled = localStorage.getItem(FULL_MUTED_KEY) === 'on'; } catch {}
const mutedPlayers = new Map(); const mutedPlayers = new Map();
@@ -180,6 +186,7 @@
let runNumber = 0; let runNumber = 0;
let status = '就绪:刷新后,在课程列表或学习页点击开始。'; let status = '就绪:刷新后,在课程列表或学习页点击开始。';
let panel; let panel;
let fitPanel = () => {};
let mode; let mode;
let active = null; let active = null;
let routeSince = Date.now(); let routeSince = Date.now();
@@ -817,7 +824,10 @@
panel.querySelector('[data-quiz-only]').disabled = armed; panel.querySelector('[data-quiz-only]').disabled = armed;
panel.querySelector('[data-background-state]').textContent = backgroundEnabled panel.querySelector('[data-background-state]').textContent = backgroundEnabled
? `${isHidden()?'后台':'前台'} · 切标签页继续播放已开启` : '后台模式已关闭'; ? `${isHidden()?'后台':'前台'} · 切标签页继续播放已开启` : '后台模式已关闭';
panel.querySelector('[data-mini-state]').textContent=armed?(running?'运行中':'等待恢复'):'未启动';
panel.querySelector('[data-expand]').title=`${status}\n拖动可移动,点击展开面板`;
mode.disabled = armed; mode.disabled = armed;
fitPanel();
} }
function renderOverall() { function renderOverall() {
const current=details.get(route(location.hash).id)?.course; const current=details.get(route(location.hash).id)?.course;
@@ -840,14 +850,75 @@
} }
panel.querySelector('[data-estimate]').textContent=estimate; panel.querySelector('[data-estimate]').textContent=estimate;
} }
function setupPanelMovement(host) {
const section=panel.querySelector('[data-main-panel]');
const header=panel.querySelector('[data-drag-handle]');
const compact=panel.querySelector('[data-expand]');
let collapsed=false,position=null,drag=null,suppressClick=false;
try {
const raw=localStorage.getItem(PANEL_KEY);
const saved=raw && raw.length<2048?JSON.parse(raw):null;
collapsed=saved?.collapsed===true;
if(Number.isFinite(saved?.x) && Number.isFinite(saved?.y)) position={x:saved.x,y:saved.y};
} catch { /* Invalid layout settings use the default location. */ }
function save() {
try { localStorage.setItem(PANEL_KEY,JSON.stringify({...position,collapsed})); } catch {}
}
function rect() {return host.getBoundingClientRect?.() || {left:16,top:16,width:collapsed?240:322,height:collapsed?44:600};}
fitPanel=()=>{
const box=rect();
position=panelPosition(position?.x ?? box.left,position?.y ?? box.top,box.width,box.height,
window.innerWidth || 1024,window.innerHeight || 768);
host.style.left=`${position.x}px`;host.style.top=`${position.y}px`;host.style.bottom='auto';
};
function setCollapsed(value) {
collapsed=value;section.hidden=value;compact.hidden=!value;
compact.ariaExpanded=String(!value);
fitPanel();save();
}
section.hidden=collapsed;compact.hidden=!collapsed;compact.ariaExpanded=String(!collapsed);
fitPanel();
panel.querySelector('[data-collapse]').onclick=()=>setCollapsed(true);
compact.onclick=event=>{
const dragged=suppressClick;suppressClick=false;
if(dragged && event?.detail!==0) return;
setCollapsed(false);
};
for(const handle of [header,compact]) {
handle.onpointerdown=event=>{
if(event.button!==0 || event.isPrimary===false || drag) return;
if(handle===header && event.target.closest?.('button, input, select, a, summary')) return;
suppressClick=false;
const box=rect();
drag={id:event.pointerId,handle,startX:event.clientX,startY:event.clientY,x:box.left,y:box.top,moved:false};
handle.setPointerCapture?.(event.pointerId);
};
handle.onpointermove=event=>{
if(!drag || drag.id!==event.pointerId) return;
const dx=event.clientX-drag.startX,dy=event.clientY-drag.startY;
if(!drag.moved && Math.hypot(dx,dy)<4) return;
drag.moved=true;
position={x:drag.x+dx,y:drag.y+dy};fitPanel();event.preventDefault();
};
function endDrag(event) {
if(!drag || drag.id!==event.pointerId) return;
if(drag.moved) {suppressClick=handle===compact;fitPanel();save();}
drag=null;
if(handle.hasPointerCapture?.(event.pointerId)) handle.releasePointerCapture(event.pointerId);
}
handle.onpointerup=endDrag;handle.onpointercancel=endDrag;handle.onlostpointercapture=endDrag;
}
window.addEventListener('resize',()=>{fitPanel();save();});
}
function mount() { function mount() {
if (!document.body) return; if (!document.body) return;
const host = document.createElement('div'); host.id = 'icampus-study-helper'; const host = document.createElement('div'); host.id = 'icampus-study-helper';
host.style.cssText = 'position:fixed;left:16px;bottom:16px;z-index:2147483646'; host.style.cssText = 'position:fixed;left:16px;bottom:16px;z-index:2147483646';
host.style.maxHeight='calc(100vh - 32px)'; host.style.overflowY='auto'; host.style.maxHeight='calc(100vh - 16px)'; host.style.overflowY='auto';
panel = host.attachShadow({mode: 'open'}); panel = host.attachShadow({mode: 'open'});
panel.innerHTML = `<style>:host{font:14px/1.6 system-ui;color:#eaf0fa}section{width:290px;background:#17243a;border:1px solid #6883a6;border-radius:12px;padding:15px;box-shadow:0 5px 24px #0005}strong{font-size:16px}p{margin:9px 0;white-space:pre-line}small{color:#b7c7df}button,select{font:inherit;border-radius:6px;border:1px solid #7186a5;padding:5px 9px;margin:3px 5px 3px 0}button{cursor:pointer;background:#dbeaff;color:#123}button:disabled{opacity:.5;cursor:default}select{width:100%;background:#fff;color:#123}[data-status]{min-height:45px}details{margin-top:8px}pre{white-space:pre-wrap;max-height:160px;overflow:auto;font-size:12px}</style> panel.innerHTML = `<style>:host{display:block;font:14px/1.6 system-ui;color:#eaf0fa}[hidden]{display:none!important}section{box-sizing:border-box;width:322px;max-width:calc(100vw - 16px);background:#17243a;border:1px solid #6883a6;border-radius:12px;padding:15px;box-shadow:0 5px 24px #0005}strong{font-size:14px}p{margin:9px 0;white-space:pre-line}small{color:#b7c7df}button,select{font:inherit;border-radius:6px;border:1px solid #7186a5;padding:5px 9px;margin:3px 5px 3px 0}button{cursor:pointer;background:#dbeaff;color:#123}button:disabled{opacity:.5;cursor:default}select{width:100%;background:#fff;color:#123}[data-status]{min-height:45px}details{margin-top:8px}pre{white-space:pre-wrap;max-height:160px;overflow:auto;font-size:12px}[data-drag-handle]{display:flex;align-items:center;justify-content:space-between;gap:8px;cursor:grab;touch-action:none;user-select:none}[data-collapse]{flex-shrink:0;margin:0}.compact{display:flex;align-items:center;gap:10px;max-width:calc(100vw - 16px);box-sizing:border-box;margin:0;padding:10px 13px;border-radius:999px;background:#17243a;color:#eaf0fa;cursor:grab;touch-action:none;user-select:none}.compact [data-mini-state]{color:#b7c7df;font-size:12px}.compact:focus-visible,button:focus-visible{outline:2px solid #a3ccff;outline-offset:-3px}</style>
<section><strong>2倍速学习助手 · 1.4.1</strong><p data-status></p><small data-progress></small> <button class="compact" data-expand hidden aria-expanded="false" aria-label="展开学习助手面板"><span>2×学习助手</span><span data-mini-state></span><span>展开</span></button>
<section data-main-panel aria-label="学习助手面板"><header data-drag-handle title="按住标题拖动面板"><strong>2倍速学习助手 · 1.4.2</strong><button data-collapse aria-label="收起学习助手面板">收起</button></header><p data-status></p><small data-progress></small>
<p data-overall></p><progress data-overall-bar aria-label="整体课程完成百分比" max="100" value="0" style="width:100%"></progress> <p data-overall></p><progress data-overall-bar aria-label="整体课程完成百分比" max="100" value="0" style="width:100%"></progress>
<p data-estimate style="font-size:12px;color:#b7c7df"></p> <p data-estimate style="font-size:12px;color:#b7c7df"></p>
<select aria-label="播放范围"><option value="all">按列表顺序继续课程</option><option value="current">仅当前课程</option></select> <select aria-label="播放范围"><option value="all">按列表顺序继续课程</option><option value="current">仅当前课程</option></select>
@@ -894,7 +965,7 @@
panel.querySelector('[data-stop]').onclick = () => stop(); panel.querySelector('[data-stop]').onclick = () => stop();
autoQuiz.onchange=()=>{persistIntent();if(waiting)waiting.nextAt=0;}; autoQuiz.onchange=()=>{persistIntent();if(waiting)waiting.nextAt=0;};
panel.querySelector('[data-copy]').onclick = () => { panel.querySelector('pre').textContent = logs.map(x => `${x.time} ${x.message}`).join('\n'); }; panel.querySelector('[data-copy]').onclick = () => { panel.querySelector('pre').textContent = logs.map(x => `${x.time} ${x.message}`).join('\n'); };
document.body.append(host); render(); document.body.append(host);setupPanelMovement(host);render();
try { try {
const resume=JSON.parse(sessionStorage.getItem(RESUME_KEY) || 'null'); const resume=JSON.parse(sessionStorage.getItem(RESUME_KEY) || 'null');
sessionStorage.removeItem(RESUME_KEY); sessionStorage.removeItem(RESUME_KEY);
+2 -2
View File
@@ -1,10 +1,10 @@
{ {
"name": "e-learning-assistant", "name": "e-learning-assistant",
"version": "1.4.1", "version": "1.4.2",
"private": true, "private": true,
"description": "学员中心2倍速播放和测验辅助脚本", "description": "学员中心2倍速播放和测验辅助脚本",
"scripts": { "scripts": {
"test": "node --check icampus-study-helper.user.js && node study-helper.test.cjs && node quiz-helper.test.cjs && node playback-recovery.test.cjs && node background-playback.test.cjs && node persistent-run.test.cjs && node overall-progress.test.cjs && node full-mute.test.cjs" "test": "node --check icampus-study-helper.user.js && node study-helper.test.cjs && node quiz-helper.test.cjs && node playback-recovery.test.cjs && node background-playback.test.cjs && node persistent-run.test.cjs && node overall-progress.test.cjs && node full-mute.test.cjs && node panel-controls.test.cjs"
}, },
"engines": { "node": ">=18" } "engines": { "node": ">=18" }
} }
+83
View File
@@ -0,0 +1,83 @@
'use strict';
const assert=require('node:assert/strict');
const {panelPosition}=require('./icampus-study-helper.user.js');
const {harness}=require('./study-helper.test.cjs');
const KEY='icampus.study.v1.panel';
let passed=0;
async function test(name,fn){await fn();passed++;console.log(`PASS ${name}`);}
function mount(h){
const nodes=new Map();
const panel={querySelector(s){if(!nodes.has(s))nodes.set(s,{textContent:'',value:'all',checked:true});return nodes.get(s);}};
const host={style:{},attachShadow:()=>panel,getBoundingClientRect(){return {
left:parseFloat(this.style.left)||16,top:parseFloat(this.style.top)||100,
width:nodes.get('[data-main-panel]')?.hidden?240:322,height:nodes.get('[data-main-panel]')?.hidden?44:620};}};
h.context.window.innerWidth=1400;h.context.window.innerHeight=900;
h.context.document.body={append(){}};h.context.document.createElement=()=>host;h.api.mount();
return {nodes,host};
}
function pointer(id,x,y,target={closest:()=>null}){return {pointerId:id,clientX:x,clientY:y,button:0,isPrimary:true,target,preventDefault(){}};}
(async()=>{
await test('collapse and expand preserve active playback, intent and options',()=>{
const h=harness(),{nodes}=mount(h);h.api.begin();h.api.track(h.video);
nodes.get('[data-collapse]').onclick();assert.equal(nodes.get('[data-main-panel]').hidden,true);assert.equal(nodes.get('[data-expand]').hidden,false);
assert.equal(h.api.state.running,true);assert.equal(h.api.state.armed,true);assert.equal(h.video.pauseCount,0);
nodes.get('[data-expand]').onclick({detail:0});assert.equal(nodes.get('[data-main-panel]').hidden,false);assert.equal(h.api.state.running,true);
});
await test('dragging the title changes and stores position',()=>{
const h=harness(),{nodes,host}=mount(h),handle=nodes.get('[data-drag-handle]');
handle.onpointerdown(pointer(1,20,104));handle.onpointermove(pointer(1,220,204));handle.onpointerup(pointer(1,220,204));
assert.equal(host.style.left,'216px');assert.equal(host.style.top,'200px');
assert.deepEqual(JSON.parse(h.storage.get(KEY)),{x:216,y:200,collapsed:false});
});
await test('dragged compact button remains collapsed and a later click expands',()=>{
const h=harness(),{nodes}=mount(h);nodes.get('[data-collapse]').onclick();const compact=nodes.get('[data-expand]');
compact.onpointerdown(pointer(1,20,104));compact.onpointermove(pointer(1,120,204));compact.onpointerup(pointer(1,120,204));
compact.onclick({detail:1});assert.equal(nodes.get('[data-main-panel]').hidden,true);
compact.onpointerdown(pointer(2,120,204));compact.onpointerup(pointer(2,120,204));compact.onclick({detail:1});
assert.equal(nodes.get('[data-main-panel]').hidden,false);
});
await test('refresh restores compact state and saved position',()=>{
const h=harness(),{nodes}=mount(h);const header=nodes.get('[data-drag-handle]');
header.onpointerdown(pointer(1,20,104));header.onpointermove(pointer(1,220,204));header.onpointerup(pointer(1,220,204));nodes.get('[data-collapse]').onclick();
const restarted=harness({storageEntries:[...h.storage]}),ui=mount(restarted);
assert.equal(ui.nodes.get('[data-main-panel]').hidden,true);assert.equal(ui.host.style.left,'216px');assert.equal(ui.host.style.top,'200px');
assert.equal(restarted.api.state.armed,false);
});
await test('resize keeps the header and expanded panel inside the smaller viewport',()=>{
const h=harness(),{nodes,host}=mount(h);const header=nodes.get('[data-drag-handle]');
header.onpointerdown(pointer(1,20,104));header.onpointermove(pointer(1,1000,250));header.onpointerup(pointer(1,1000,250));
h.window.innerWidth=400;h.window.innerHeight=700;h.window.dispatchEvent({type:'resize'});
assert.equal(host.style.left,'70px');assert.equal(host.style.top,'72px');
});
await test('tiny movements and button presses do not start a drag or change settings',()=>{
const h=harness(),{nodes,host}=mount(h),header=nodes.get('[data-drag-handle]');
const before={...host.style};header.onpointerdown(pointer(1,20,104));header.onpointermove(pointer(1,21,105));header.onpointerup(pointer(1,21,105));
assert.equal(host.style.left,before.left);assert.equal(h.storage.has(KEY),false);
header.onpointerdown(pointer(2,20,104,{closest:()=>({})}));header.onpointermove(pointer(2,500,500));header.onpointerup(pointer(2,500,500));
assert.equal(host.style.left,before.left);assert.equal(h.api.state.armed,false);
});
await test('cancelled pointer capture allows the next drag to work',()=>{
const h=harness(),{nodes,host}=mount(h),header=nodes.get('[data-drag-handle]');
header.onpointerdown(pointer(1,20,104));header.onpointercancel(pointer(1,20,104));
header.onpointerdown(pointer(2,20,104));header.onpointermove(pointer(2,120,104));header.onpointerup(pointer(2,120,104));
assert.equal(host.style.left,'116px');
});
await test('invalid and off-screen preferences safely fall back or clamp',()=>{
const invalid=harness({storageEntries:[[KEY,'broken json']]});assert.equal(mount(invalid).nodes.get('[data-main-panel]').hidden,false);
const outside=harness({storageEntries:[[KEY,JSON.stringify({x:100000,y:-100,collapsed:false})]]});const ui=mount(outside);
assert.equal(ui.host.style.left,'1070px');assert.equal(ui.host.style.top,'8px');
assert.deepEqual(panelPosition(NaN,Infinity,322,620,1400,900),{x:16,y:16});
});
await test('compact status reflects waiting and cancellation',()=>{
const h=harness(),{nodes}=mount(h);nodes.get('[data-start]').onclick();nodes.get('[data-collapse]').onclick();
h.api.park('等待网络');assert.equal(nodes.get('[data-mini-state]').textContent,'等待恢复');
assert.match(nodes.get('[data-expand]').title,/等待网络/);h.api.stop();assert.equal(nodes.get('[data-mini-state]').textContent,'未启动');
});
await test('blocked local storage does not prevent collapsing or dragging',()=>{
const h=harness(),{nodes,host}=mount(h);h.context.localStorage.setItem=()=>{throw new Error('blocked');};
nodes.get('[data-collapse]').onclick();const compact=nodes.get('[data-expand]');
compact.onpointerdown(pointer(1,20,104));compact.onpointermove(pointer(1,120,204));compact.onpointerup(pointer(1,120,204));
assert.equal(nodes.get('[data-main-panel]').hidden,true);assert.equal(host.style.left,'116px');
});
console.log(`${passed} panel control tests passed.`);
})().catch(e=>{console.error(e);process.exitCode=1;});