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
+78 -7
View File
@@ -1,8 +1,8 @@
// ==UserScript==
// @name 学员中心 · 2倍速学习助手
// @namespace local.icampus.study
// @version 1.4.1
// @description 支持全过程静音、切换标签页继续2倍速播放与保存进度,自动测验及错题重试。
// @version 1.4.2
// @description 支持面板收纳与拖拽、全过程静音、后台2倍速播放、自动测验及错题重试。
// @match https://icampus.yunyinsh.com/course/*
// @run-at document-start
// @grant none
@@ -73,6 +73,11 @@
const minutes=Math.ceil(Math.max(0,seconds)/60);
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) {
if (!detail?.course || !Array.isArray(detail.resources)) return {kind: 'wait'};
const resources = [...detail.resources].sort((a, b) => a.sortNo - b.sortNo);
@@ -136,7 +141,7 @@
} catch { return null; }
}
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 (window.top !== window.self || document.getElementById('icampus-study-helper')) return;
@@ -148,6 +153,7 @@
const INTENT_KEY = 'icampus.study.v1.enabled';
const MUTED_KEY = 'icampus.study.v1.mutedFallback';
const FULL_MUTED_KEY = 'icampus.study.v1.fullMuted';
const PANEL_KEY = 'icampus.study.v1.panel';
let fullMutedEnabled = false;
try { fullMutedEnabled = localStorage.getItem(FULL_MUTED_KEY) === 'on'; } catch {}
const mutedPlayers = new Map();
@@ -180,6 +186,7 @@
let runNumber = 0;
let status = '就绪:刷新后,在课程列表或学习页点击开始。';
let panel;
let fitPanel = () => {};
let mode;
let active = null;
let routeSince = Date.now();
@@ -817,7 +824,10 @@
panel.querySelector('[data-quiz-only]').disabled = armed;
panel.querySelector('[data-background-state]').textContent = backgroundEnabled
? `${isHidden()?'后台':'前台'} · 切标签页继续播放已开启` : '后台模式已关闭';
panel.querySelector('[data-mini-state]').textContent=armed?(running?'运行中':'等待恢复'):'未启动';
panel.querySelector('[data-expand]').title=`${status}\n拖动可移动,点击展开面板`;
mode.disabled = armed;
fitPanel();
}
function renderOverall() {
const current=details.get(route(location.hash).id)?.course;
@@ -840,14 +850,75 @@
}
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() {
if (!document.body) return;
const host = document.createElement('div'); host.id = 'icampus-study-helper';
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.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>
<section><strong>2倍速学习助手 · 1.4.1</strong><p data-status></p><small data-progress></small>
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>
<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-estimate style="font-size:12px;color:#b7c7df"></p>
<select aria-label="播放范围"><option value="all">按列表顺序继续课程</option><option value="current">仅当前课程</option></select>
@@ -894,7 +965,7 @@
panel.querySelector('[data-stop]').onclick = () => stop();
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'); };
document.body.append(host); render();
document.body.append(host);setupPanelMovement(host);render();
try {
const resume=JSON.parse(sessionStorage.getItem(RESUME_KEY) || 'null');
sessionStorage.removeItem(RESUME_KEY);