// ==UserScript== // @name Fk云班课 // @namespace https://bbs.tampermonkey.net.cn/ // @version 1.0.0 // @description 云班课答题辅助 + 浮动控制面板 // @author 江禾 // @license MIT // @match https://www.mosoteach.cn/web/cc-detail/* // @match https://www.mosoteach.cn/web/act/quiz-answer/* // @include https://www.mosoteach.cn/web/cc-detail/* // @include https://www.mosoteach.cn/web/act/quiz-answer/* // @include https://www.mosoteach.cn/web/cc-list // @grant none // ==/UserScript== ; (function () { 'use strict'; // ── 状态 ── const Z = { tab: 'video', x: 20, y: 80, drag: false, dx: 0, dy: 0, questions: [], running: false, paused: false, speed: parseInt(localStorage.getItem('zk_speed')) || 2000, qIdx: 0, timer: null, config: localStorage.getItem('zk_config') || '', }; // ── Shadow DOM ── const host = document.createElement('div'); const root = host.attachShadow({ mode: 'closed' }); host.style.cssText = 'all:initial;display:block;position:fixed;top:0;left:0;width:0;height:0;z-index:99999999'; document.body.appendChild(host); // ── 样式 ── const css = document.createElement('style'); css.textContent = ` .panel{position:fixed;top:80px;right:20px;width:340px;background:#fff;border-radius:16px;overflow:hidden;box-shadow:0 8px 32px rgba(0,0,0,.14);font:14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#1e293b;user-select:none} .hdr{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 0;cursor:grab} .title{font-weight:600;font-size:15px} .sub{font-size:12px;color:#94a3b8;font-weight:500} .tabs{display:flex;gap:6px;margin:10px 16px 0} .tab{flex:1;padding:6px 0;border:none;border-radius:8px;font:inherit;font-size:13px;font-weight:500;color:#94a3b8;cursor:pointer;background:none} .tab.on{background:#eef2ff;color:#3b82f6} .bd{padding:12px 16px 16px;max-height:420px;overflow-y:auto} .bd::-webkit-scrollbar{width:4px} .bd::-webkit-scrollbar-thumb{border-radius:2px;background:#cbd5e1} .legend{display:flex;gap:12px;margin-bottom:10px;font-size:12px;color:#64748b} .legend span{display:flex;align-items:center;gap:3px} .dot{width:8px;height:8px;border-radius:50%;display:inline-block} .grid{display:grid;grid-template-columns:repeat(5,1fr);gap:7px;margin-bottom:12px;max-height:248px;overflow-y:auto} .grid::-webkit-scrollbar{width:4px} .grid::-webkit-scrollbar-thumb{border-radius:2px;background:#cbd5e1} .num{aspect-ratio:1;border:1.5px solid #cbd5e1;border-radius:10px;font:inherit;font-size:13px;font-weight:600;cursor:pointer;background:#f8fafc;color:#475569;display:flex;align-items:center;justify-content:center} .num.cur{background:#eff6ff;color:#3b82f6;border-color:#3b82f6} .num.done{background:#e8f5e9;color:#2e7d32;border-color:#2e7d32} .num.err{background:#fef2f2;color:#ef4444;border-color:#ef4444} .empty{text-align:center;padding:20px 0;color:#94a3b8;font-size:13px} .ctrl{display:flex;gap:7px;margin-bottom:6px} .btn{padding:8px 0;border:none;border-radius:10px;font:inherit;font-size:12px;font-weight:600;cursor:pointer;flex:1;text-align:center;white-space:nowrap} .btn:active{transform:scale(.97)} .btn:disabled{opacity:.4;cursor:default} .btn-pri{background:#3b82f6;color:#fff} .btn-warn{background:#f59e0b;color:#fff} .btn-off{background:#f1f5f9;color:#64748b} .run{display:flex;align-items:center;gap:5px;font-size:11px;color:#22c55e;margin-top:4px} .pulse{width:6px;height:6px;border-radius:50%;background:#22c55e;animation:pl 1.2s infinite} @keyframes pl{0%,100%{opacity:1}50%{opacity:.3}} .spd{display:flex;flex-direction:column;gap:6px;margin-bottom:8px;font-size:11px;color:#64748b} .spd label{font-weight:500} .spd-row{display:flex;align-items:center;gap:4px;flex-wrap:wrap} .spd button{padding:2px 10px;border:1px solid #cbd5e1;border-radius:6px;background:#fff;font:inherit;font-size:11px;color:#475569;cursor:pointer} .spd button.on{background:#3b82f6;border-color:#3b82f6;color:#fff} .spd button.pri{background:#3b82f6;border-color:#3b82f6;color:#fff;padding:3px 14px} .spd input{width:60px;padding:4px;border:1px solid #cbd5e1;border-radius:6px;font:inherit;font-size:13px;text-align:center;outline:none;-moz-appearance:textfield} .spd input::-webkit-inner-spin-button{display:none} .spd input:focus{border-color:#3b82f6} .spd .arrow{padding:2px 6px;font-size:13px} .set{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px} .set label{font-size:13px;color:#475569;font-weight:500} .set input{width:88px;padding:5px 8px;border:1.5px solid #e2e8f0;border-radius:8px;font:inherit;font-size:13px;text-align:center;outline:none} .set input:focus{border-color:#3b82f6} .cfgbtn{padding:4px 12px;border:1.5px solid #e2e8f0;border-radius:8px;background:#fff;font:inherit;font-size:12px;color:#475569;cursor:pointer} .cfgbtn:hover{border-color:#3b82f6;color:#3b82f6} .prev{background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:6px 10px;font-size:11px;color:#64748b;line-height:1.5;max-height:48px;overflow:auto;white-space:pre-wrap;word-break:break-all;margin-top:4px} .prev.em{color:#ef4444;font-style:normal;font-weight:500} .ov{display:none;position:fixed;inset:0;background:rgba(0,0,0,.35);align-items:center;justify-content:center;z-index:99999999} .ov.show{display:flex} .toast{position:fixed;width:340px;box-sizing:border-box;pointer-events:none;font-size:12px;font-weight:500;line-height:1.5;opacity:0;transform:translateY(16px);transition:opacity .35s ease,transform .35s ease} .toast.show{opacity:1;transform:translateY(0)} .toast.err{z-index:99999998;background:#fef2f2;border:1px solid #fecaca;color:#ef4444;padding:8px 12px;border-radius:10px} .toast.ok{z-index:99999998;background:#eff6ff;border:1px solid #bfdbfe;color:#3b82f6;padding:8px 12px;border-radius:10px} .modal{width:580px;max-width:92vw;background:#fff;border-radius:16px;box-shadow:0 16px 48px rgba(0,0,0,.2);padding:20px} .modal h3{font-size:16px;font-weight:600;margin-bottom:4px;color:#1e293b} .hint{font-size:12px;color:#94a3b8;margin-bottom:10px} .modal textarea{width:100%;height:280px;padding:10px;border:1.5px solid #e2e8f0;border-radius:10px;font:12px/1.6 'JetBrains Mono','Fira Code','Consolas',monospace;resize:vertical;outline:none;box-sizing:border-box} .modal textarea:focus{border-color:#3b82f6} .cerr{display:none;background:#fef2f2;color:#ef4444;border:1px solid #fecaca;border-radius:8px;padding:7px 10px;font-size:12px;margin-top:8px} .cerr.show{display:block} .act{display:flex;gap:8px;justify-content:flex-end;margin-top:12px} .act .btn{padding:8px 18px;flex:0 0 auto} `; root.appendChild(css); // ── DOM 快捷创建 ── const $ = (tag, a) => Object.assign(document.createElement(tag), a); const on = (el, ev, fn) => el.addEventListener(ev, fn); // ── 面板 DOM ── const hdr = $('div', { className: 'hdr' }); const subEl = $('span', { className: 'sub' }); hdr.append($('span', { className: 'title', textContent: '📋 Fk云班课' }), subEl); const tabs = $('div', { className: 'tabs' }); const tabVid = $('button', { className: 'tab on', textContent: '视频' }); const tabAns = $('button', { className: 'tab', textContent: '答题' }); const tabSet = $('button', { className: 'tab', textContent: '设置' }); tabs.append(tabVid, tabAns, tabSet); const body = $('div', { className: 'bd' }); const panel = $('div', { className: 'panel' }); panel.append(hdr, tabs, body); root.appendChild(panel); const toast = $('div', { id: 'toast', className: 'toast', textContent: '● 未配置题库' }); root.appendChild(toast); const ov = $('div', { className: 'ov' }); root.appendChild(ov); // ── 面板渲染 ── function render() { body.innerHTML = ''; if (Z.tab === 'video') renderVideo(); else if (Z.tab === 'answer') renderAnswer(); else renderSettings(); } function renderVideo() { const hasVideo = !!document.querySelector('.video-js video'); if (hasVideo) { body.innerHTML = '
JSON 数组格式,每项含 name / url / method / type / contentType / data / handler
'; ov.appendChild(modal); on(ov, 'click', e => { if (e.target === ov) closeCfg(); }); on(modal.querySelector('#cfgCancel'), 'click', closeCfg); on(modal.querySelector('#cfgSave'), 'click', saveCfg); } function closeCfg() { ov.className = 'ov'; } function saveCfg() { const val = ov.querySelector('#cfgTa').value.trim(); if (!val) return; const err = validateCfg(val); if (err) { const e = ov.querySelector('#cfgErr'); e.textContent = err; e.className = 'cerr show'; return; } Z.config = val; localStorage.setItem('zk_config', val); closeCfg(); hideToast(); if (_.prevEl) { _.prevEl.textContent = val.slice(0, 100) + (val.length > 100 ? '……' : ''); _.prevEl.className = 'prev'; } } function validateCfg(text) { let p; try { p = JSON.parse(text); } catch (e) { return '❌ JSON 格式错误:' + e.message; } if (!Array.isArray(p)) return '❌ 必须是数组 []'; const req = ['name', 'url', 'method', 'type', 'contentType', 'data', 'handler']; for (let i = 0; i < p.length; i++) { const it = p[i]; if (!it || typeof it !== 'object') return '❌ 第' + (i + 1) + '项不是对象'; for (const f of req) if (!(f in it)) return '❌ 第' + (i + 1) + '项缺少 "' + f + '"'; if (typeof it.name !== 'string' || !it.name.trim()) return '❌ 第' + (i + 1) + '项 name 无效'; if (typeof it.url !== 'string') return '❌ 第' + (i + 1) + '项 url 必须是字符串'; if (typeof it.handler !== 'string') return '❌ 第' + (i + 1) + '项 handler 必须是字符串'; try { const fn = new Function(it.handler); if (typeof fn() !== 'function') return '❌ 第' + (i + 1) + '项 handler 需返回函数'; } catch (e) { return '❌ 第' + (i + 1) + '项 handler 语法错误'; } } return null; } // ── Toast ── function showToast(text, type) { const el = root.querySelector('#toast'); el.style.right = Z.x + 'px'; el.style.top = (Z.y + panel.offsetHeight + 8) + 'px'; el.textContent = text; el.className = 'toast'; void el.offsetWidth; el.className = 'toast show ' + (type === 'err' ? 'err' : 'ok'); if (type !== 'err') { clearTimeout(el._timer); el._timer = setTimeout(() => hideToast(), 2500); } } function hideToast() { root.querySelector('#toast').className = 'toast'; } // ── 状态检测 ── function detectAnswers() { const items = document.querySelectorAll('.sheet-item'); if (!items.length) return; let changed = false; items.forEach((el, i) => { if (i >= Z.questions.length) return; if (el.classList.contains('primary') && Z.questions[i].status !== 'done') { Z.questions[i].status = 'done'; changed = true; } }); if (changed) updateGrid(); } setInterval(detectAnswers, 800); // ── 视频检测(MutationObserver,无需轮询)── let lastVideo = false; const vidObs = new MutationObserver(() => { const now = !!document.querySelector('.video-js video'); if (now !== lastVideo) { lastVideo = now; if (Z.tab === 'video') render(); } }); vidObs.observe(document.body, { childList: true, subtree: true }); // ── 拖动 ── on(hdr, 'mousedown', e => { Z.drag = true; Z.dx = e.clientX; Z.dy = e.clientY; }); on(document, 'mousemove', e => { if (!Z.drag) return; Z.x = Math.max(8, Z.x - (e.clientX - Z.dx)); Z.y = Math.max(8, Z.y + (e.clientY - Z.dy)); Z.dx = e.clientX; Z.dy = e.clientY; panel.style.right = Z.x + 'px'; panel.style.top = Z.y + 'px'; const t = root.querySelector('#toast'); if (t.classList.contains('show')) { t.style.right = Z.x + 'px'; t.style.top = (Z.y + panel.offsetHeight + 8) + 'px'; } }); on(document, 'mouseup', () => { Z.drag = false; }); // ── 标签切换 ── on(tabVid, 'click', () => { Z.tab = 'video'; tabVid.className = 'tab on'; tabAns.className = 'tab'; tabSet.className = 'tab'; render(); }); on(tabAns, 'click', () => { Z.tab = 'answer'; tabVid.className = 'tab'; tabAns.className = 'tab on'; tabSet.className = 'tab'; render(); }); on(tabSet, 'click', () => { Z.tab = 'settings'; tabVid.className = 'tab'; tabAns.className = 'tab'; tabSet.className = 'tab on'; render(); }); // ── 题目检测 ── function checkPage() { if (location.href.includes('/act/quiz-answer/')) { console.log('[FK] 答题界面'); waitQ(); } else if (Z.questions.length) { Z.questions = []; if (Z.tab === 'answer') render(); console.log('[FK] 已清除题目'); } } function waitQ() { const existing = document.querySelectorAll('.list-item'); if (existing.length) { syncQ(parseQ(existing)); return; } let t, o; const found = () => { const items = document.querySelectorAll('.list-item'); if (!items.length) return; o.disconnect(); clearInterval(t); syncQ(parseQ(items)); }; o = new MutationObserver(found); o.observe(document.body, { childList: true, subtree: true }); t = setInterval(found, 500); setTimeout(() => { o.disconnect(); clearInterval(t); }, 15000); } function parseQ(items) { return Array.from(items).map(item => { const cb = item.querySelector('input[type="checkbox"]'); const rd = item.querySelector('input[type="radio"]'); if (!cb && !rd) return null; return { type: cb ? 'multiple' : 'single', title: (item.querySelector('.topic-subject.word-break') || {}).textContent || '', typeLabel: ((item.querySelector('.v-chip__content') || {}).textContent || '').trim(), options: Array.from(item.querySelectorAll('.topic-answer-align')).map(ta => ({ label: (ta.querySelector('.vote-option-index.number') || {}).textContent || '', text: (ta.querySelector('.content.topic-answer.word-break') || {}).textContent || '', input: ta.parentElement && ta.parentElement.querySelector('input'), })), }; }).filter(Boolean); } function syncQ(qs) { if (!qs || !qs.length) return; Z.questions = qs.map((q, i) => ({ index: i + 1, status: i === 0 ? 'cur' : 'default', _data: q })); if (Z.tab === 'answer') render(); } // ── URL 监听 ── let lastUrl = location.href; on(window, 'popstate', () => { if (location.href !== lastUrl) { lastUrl = location.href; checkPage(); } }); const _push = history.pushState, _rep = history.replaceState; history.pushState = function (...a) { _push.apply(this, a); if (location.href !== lastUrl) { lastUrl = location.href; checkPage(); } }; history.replaceState = function (...a) { _rep.apply(this, a); if (location.href !== lastUrl) { lastUrl = location.href; checkPage(); } }; setInterval(() => { if (location.href !== lastUrl) { lastUrl = location.href; checkPage(); } }, 1000); // ── 启动 ── render(); checkPage(); if (!Z.config) setTimeout(() => showToast('● 未配置题库'), 500); })();