// ==UserScript== // @name 小雅视频连播助手 // @namespace local.xiaoya.video // @version 0.10.2 // @description 视频与自主观看资料连续学习,显示历史学习时长,自动缓存课程列表。 // @author 波波侠 // @license MIT // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2NCIgaGVpZ2h0PSI2NCIgdmlld0JveD0iMCAwIDY0IDY0Ij4KICA8cmVjdCB3aWR0aD0iNjQiIGhlaWdodD0iNjQiIHJ4PSIxNiIgZmlsbD0iIzM0NzhmNiIvPgogIDxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDggNikgc2NhbGUoMikiIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+CiAgICA8cGF0aCBkPSJNMTIgNi41QzkuNiA0LjcgNi44IDQuMyAzLjUgNXYxNGMzLjMtLjcgNi4xLS4zIDguNSAxLjUgMi40LTEuOCA1LjItMi4yIDguNS0xLjVWNWMtMy4zLS43LTYuMS0uMy04LjUgMS41Wm0wIDB2MTQiIHN0cm9rZS13aWR0aD0iMS43Ii8+CiAgICA8cGF0aCBkPSJNNi41IDloMm0tMiAzaDJtNy0zaDJtLTIgM2gyIiBzdHJva2Utd2lkdGg9IjEuNCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+CiAgPC9nPgo8L3N2Zz4K // @homepageURL https://scriptcat.org/zh-CN/script-show-page/8791 // @match https://whut.ai-augmented.com/* // @match https://infra.ai-augmented.com/app/auth/* // @run-at document-end // @grant none // ==/UserScript== (() => { 'use strict'; const VERSION = '0.10.2'; const collator = new Intl.Collator('zh-CN', { numeric: true }); const isVideo = node => Number(node.type) === 9 || /^video\//i.test(node.mimetype || '') || /\.(mp4|webm|m4v|mov|mkv|avi|flv|m3u8)$/i.test(node.name || ''); const isSelfStudy = node => Number(node.task_type ?? node.task?.task_type ?? node.property?.task_type) === 1 || [node.tag, node.task_label, node.task?._taskTypeText].some(label => label === '自主观看'); const isStudyResource = node => Number(node.type) !== 1 && (isVideo(node) || isSelfStudy(node)); const resourceKind = node => isVideo(node) ? '视频' : /\.pptx?$/i.test(node.name || '') ? 'PPT' : '资料'; const DOCUMENT_MS = 120000, QUEUE_TTL = 10 * 60000, STATUS_TTL = 30000, HISTORY_TTL = 15 * 60000; function validMinutes(value) { const number = Number(value); return Number.isInteger(number) && number >= 2 && number <= 1440; } function normalizeSettings(value = {}) { const resourceMinutes = {}; for (const [id, minutes] of Object.entries(value?.resourceMinutes || {})) { if (validMinutes(minutes)) resourceMinutes[id] = Number(minutes); } return { mode: value?.mode === 'single' ? 'single' : 'continuous', documentMinutes: validMinutes(value?.documentMinutes) ? Number(value.documentMinutes) : 2, skipCompleted: value?.skipCompleted === true, resourceMinutes }; } // 接口返回平铺目录。优先使用平台 sort_position,缺失时按名称自然排序。 function compareNodes(a, b) { if (a.sort_position != null && b.sort_position != null && Number.isFinite(Number(a.sort_position)) && Number.isFinite(Number(b.sort_position))) { const order = Number(a.sort_position) - Number(b.sort_position); if (order) return order; } return collator.compare(a.name || '', b.name || ''); } function buildQueue(resources) { const nodes = resources.filter(node => node && node.id != null && Number(node.del) !== 2); const ids = new Set(nodes.map(node => String(node.id))); const children = new Map(); for (const node of nodes) { const parent = String(node.parent_id ?? ''); if (!children.has(parent)) children.set(parent, []); children.get(parent).push(node); } for (const siblings of children.values()) { siblings.sort(compareNodes); } const queue = [], seen = new Set(); function visit(node) { const id = String(node.id); if (seen.has(id)) return; seen.add(id); if (isStudyResource(node)) queue.push(node); for (const child of children.get(id) || []) visit(child); } nodes.filter(node => !ids.has(String(node.parent_id ?? ''))) .sort(compareNodes).forEach(visit); return queue; } function completionState(node, data = {}) { const hasTask = !!(node.task_id || node.task?.task_id); const finished = data.finished === true || data.finished === 1 || data.finished === '1' || data.finished === 'true' ? true : data.finished === false || data.finished === 0 || data.finished === '0' || data.finished === 'false' ? false : null; const record = data.watchRecord || (!hasTask ? data : null); const duration = Number(record?.duration), watched = Number(record?.watched_duration); const percent = Number.isFinite(duration) && duration > 0 && Number.isFinite(watched) && watched >= 0 ? Math.min(100, Math.ceil(watched / (duration * 0.95) * 100)) : null; return { complete: hasTask ? finished : percent == null ? null : percent >= 100, percent, hasTask, error: false, checkedAt: 0 }; } function completionLabel(state) { if (!state) return '待检测'; if (state.error) return '检测失败'; if (state.complete === true) return '已完成'; if (state.hasTask && state.complete === false && state.percent === 100) return '待提交'; if (state.percent > 0) return `已看 ${state.percent}%`; return state.complete === false ? '未完成' : '待检测'; } function resourcePath(course, node) { const parent = node.parent_id == null || node.parent_id === '' ? '_' : String(node.parent_id); return `/app/jx-web/mycourse/${encodeURIComponent(course)}/resource/${encodeURIComponent(parent)}/${encodeURIComponent(String(node.id))}`; } function nextVideo(nodes, currentId, states, skipCompleted) { const index = nodes.findIndex(node => String(node.id) === currentId); return index < 0 ? null : nodes.slice(index + 1).find(node => !skipCompleted || states.get(String(node.id))?.complete !== true) || null; } // 允许在 Node 中检查目录处理逻辑,不执行浏览器代码。 if (typeof module !== 'undefined' && module.exports && typeof window === 'undefined') { module.exports = { buildQueue, isVideo, isSelfStudy, isStudyResource, completionState, completionLabel, resourcePath, nextVideo, normalizeSettings }; return; } if (window.top !== window.self || document.getElementById('xy-video-helper')) return; const loginSurface = location.hostname === 'infra.ai-augmented.com' && location.pathname.startsWith('/app/auth/'); let loginNoticeShown = false; const storageKey = 'xy-video-helper-v1'; let saved = {}; try { saved = JSON.parse(sessionStorage.getItem(storageKey) || '{}'); } catch { /* 使用默认值 */ } let preferences = {}; try { preferences = JSON.parse(localStorage.getItem('xy-learning-preferences-v1') || '{}'); } catch { /* 使用默认值 */ } let activeTab = !loginSurface && ['learning', 'list', 'settings'].includes(saved.activeTab) ? saved.activeTab : 'learning'; let queueSearch = '', queueFilter = 'all'; let activeCourse = saved.course || ''; let running = saved.running === true; let muted = (preferences.muted ?? saved.muted) !== false; let background = (preferences.background ?? saved.background) !== false; let skipCompleted = saved.skipCompleted === true; let collapsed = loginSurface ? false : saved.collapsed !== false; let position = preferences.position || saved.position || null; let userPaused = saved.userPaused === true; let navigation = saved.navigation && Date.now() - saved.navigation.startedAt < 30000 ? saved.navigation : null; let outgoingVideo = null, outgoingSource = ''; let queue = [], queueCourse = '', routeKey = '', generation = 0; let video = null, endedListener = null, readyListener = null, pauseListener = null, playListener = null; let nextTimer = null, loading = false, resumeAfterDialog = false; let lastDialogClick = 0; let backgroundIntent = false, backgroundTimer = null, backgroundAttempts = 0; let lastBackgroundAttempt = 0, playPending = false, waitingForVideoReady = false; const completions = new Map(), pendingChecks = new Map(); let completionEpoch = 0, completionBusy = false, currentCheckAt = 0; let observedCourse = null, courseEpoch = 0, directoryCheckedAt = 0, cacheAt = 0; let documentClock = null, advancing = false; let history = { minutes: null, fetchedAt: 0, error: false }, historyBusy = false; let auth = { state: 'checking', id: null, message: '', checkedAt: 0 }, authBusy = false, authEpoch = 0; let courseInfo = { name: '', state: 'loading', fetchedAt: 0 }, courseInfoBusy = false; let settings = normalizeSettings(), sessionEnded = false, resourceFailure = '', directoryIssue = ''; function tokenEntry() { if (loginSurface) return undefined; return document.cookie.split(';').map(item => item.trim()).find(item => item.split('=')[0].includes('prd-access-token')); } function tokenScope() { const token = tokenEntry(); if (!token) return null; let hash = 2166136261; for (const char of token) hash = Math.imul(hash ^ char.charCodeAt(0), 16777619); return (hash >>> 0).toString(16); } let cacheScope = tokenScope(); const settingsKey = course => auth.id != null && course ? `xy-learning-settings-v1:${encodeURIComponent(String(auth.id))}:${course}` : null; function loadSettings(course) { try { settings = normalizeSettings(JSON.parse(localStorage.getItem(settingsKey(course)) || '{}')); } catch { settings = normalizeSettings(); } skipCompleted = settings.skipCompleted; syncSettingsInputs(); } function saveSettings() { const key = settingsKey(route()?.course); if (key) try { localStorage.setItem(key, JSON.stringify(settings)); } catch { /* 本页设置仍有效 */ } persist(); } function targetDocumentMs(node = currentNode()) { return (settings.resourceMinutes[String(node?.id)] ?? settings.documentMinutes) * 60000; } const cacheKey = course => cacheScope ? `xy-learning-cache-v5:${cacheScope}:${course}` : null; function readCache(course) { try { const key = cacheKey(course); if (!key) return null; const value = JSON.parse(localStorage.getItem(key) || 'null'); return value?.version === 1 && value.course === course && Number.isFinite(value.cachedAt) && Array.isArray(value.queue) && value.queue.every(node => node && node.id != null) && (value.completions == null || Array.isArray(value.completions)) ? value : null; } catch { return null; } } function writeCache() { const key = cacheKey(queueCourse); if (!key || !queueCourse || !cacheAt) return; try { const previous = readCache(queueCourse); localStorage.setItem(key, JSON.stringify({ version: 1, course: queueCourse, cachedAt: cacheAt, queue, completions: [...completions], courseInfo: courseInfo.name ? courseInfo : previous?.courseInfo || null, history: history.minutes != null ? history : previous?.history || null })); } catch { /* 缓存不可用时仍可学习 */ } } const panel = document.createElement('section'); panel.id = 'xy-video-helper'; panel.style.cssText = 'all:initial;position:fixed;width:48px;height:48px;z-index:1100;color-scheme:light;'; const ui = panel.attachShadow({ mode: 'open' }); const icons = { assistant: '', play: '', pause: '', previous: '', next: '', settings: '', loading: '', stop: '', collapse: '', expand: '', refresh: '', }; ui.innerHTML = `
学习助手未开始
当前课程正在确认登录状态…

当前任务— / —
视频待检测
准备好,开始这一节
00:00/00:000%
已学习时长—历史统计 · 次日更新
`; document.body.appendChild(panel); const find = selector => ui.querySelector(selector); const shell = find('.shell'); const launcher = find('[data-launcher]'); const status = find('[data-status]'); const list = find('[data-list]'); const muteInput = find('[data-mute]'); const backgroundInput = find('[data-background]'); const skipInput = find('[data-skip]'); const startButton = find('[data-start]'); const stopButton = find('[data-stop]'); const collapseButton = find('[data-collapse]'); muteInput.checked = muted; backgroundInput.checked = background; skipInput.checked = skipCompleted; function contextMessage() { if (auth.state === 'checking') return '正在确认登录状态…'; if (auth.state === 'signedOut') return auth.message || '请先登录'; if (auth.state === 'error') return '无法确认登录状态,请检查网络并点击刷新重试。'; if (!route()) return '请选择课程'; if (courseInfo.state === 'loading') return '正在读取课程信息…'; if (courseInfo.state === 'forbidden') return '当前账号无权访问此课程。'; if (courseInfo.state === 'error') return '课程信息读取失败,请点击刷新重试。'; if (directoryIssue && !queue.length) return directoryIssue; if (!route()?.node) return loading ? '正在检测本课程的学习资料…' : settings.mode === 'single' ? '请选择学习资料' : '请选择学习资料,或点击“开始连续学习”。'; return ''; } function report(message) { status.textContent = contextMessage() || (navigation ? `跳转中:${navigation.name || '正在打开视频'}` : message); updateNotice(); } function updateNotice() { const message = status.textContent || ''; const attention = /失败|无权|过期|超时|待确认|手动点击|未找到|未确认/.test(message); status.hidden = !message || !attention && (['playing', 'reading', 'paused'].includes(viewMode()) || auth.state === 'signedOut' || !route()); status.dataset.tone = /失败|无权|超时/.test(message) ? 'error' : attention ? 'warning' : 'info'; } function persist() { try { sessionStorage.setItem(storageKey, JSON.stringify({ course: activeCourse, accountId: auth.id, running, userPaused, navigation, muted, background, skipCompleted, collapsed, position, activeTab })); } catch { /* 页面仍可使用 */ } try { localStorage.setItem('xy-learning-preferences-v1', JSON.stringify({ muted, background, position })); } catch { /* 本页偏好仍有效 */ } } function formatTime(seconds) { if (!Number.isFinite(seconds) || seconds < 0) return '00:00'; const whole = Math.floor(seconds), minutes = Math.floor(whole / 60); return `${String(minutes).padStart(2, '0')}:${String(whole % 60).padStart(2, '0')}`; } function viewMode() { if (auth.state === 'checking') return 'authChecking'; if (auth.state === 'signedOut') return 'signedOut'; if (auth.state === 'error') return 'authError'; if (!route()) return 'selectCourse'; if (courseInfo.state === 'loading') return 'courseLoading'; if (['error', 'forbidden'].includes(courseInfo.state)) return 'courseError'; if (navigation) return 'jumping'; const node = currentNode(); if (sessionEnded) return 'ended'; if (!node && settings.mode === 'single' && !loading) return 'selectResource'; if (running && (nextTimer || advancing || video?.ended)) return 'next'; if (loading && !queue.length) return 'loading'; if (node && !isVideo(node)) { if (running) return documentClock?.ready ? 'reading' : 'loading'; return userPaused ? 'paused' : 'stopped'; } if (running && (!video || video.readyState < 2 || playPending)) return 'loading'; if (video && !video.paused && !video.ended) return 'playing'; if (running || userPaused) return 'paused'; return 'stopped'; } function setIcon(element, name) { if (element.dataset.icon === name) return; element.dataset.icon = name; element.innerHTML = icons[name]; } function updateView() { const current = route(); const index = queue.findIndex(node => String(node.id) === current?.node); const node = queue[index]; const timed = !!node && !isVideo(node); const confirmed = auth.state === 'ready' && !!current; find('[data-course-name]').textContent = auth.state === 'checking' ? '正在确认登录状态…' : auth.state === 'signedOut' ? '请先登录' : auth.state === 'error' ? '登录状态未确认' : !current ? '请选择课程' : courseInfo.name || (courseInfo.state === 'loading' ? '正在读取课程…' : '课程信息读取失败'); find('[data-course-name]').title = find('[data-course-name]').textContent; find('[data-course-header]').hidden = !confirmed; find('[data-tabs]').hidden = !confirmed; find('[data-footer]').hidden = !confirmed; find('[data-study-content]').hidden = !confirmed; find('[data-empty-state]').hidden = confirmed; if (!confirmed) { find('[data-empty-title]').textContent = auth.state === 'checking' ? '正在确认登录状态' : auth.state === 'signedOut' ? '请先登录' : auth.state === 'error' ? '连接暂时不可用' : '请选择课程'; find('[data-empty-description]').textContent = auth.state === 'signedOut' ? '登录完成后,助手会自动识别课程。' : auth.state === 'error' ? '检查网络连接后,点击重新检测。' : auth.state === 'checking' ? '请稍候,正在连接学习平台。' : '进入课程后,自动检测视频和自主观看资料。'; find('[data-empty-retry]').hidden = !['error', 'signedOut'].includes(auth.state); if (activeTab !== 'learning') setTab('learning', false); } find('[data-title]').textContent = navigation?.name || node?.name || (confirmed ? '请选择学习资料' : auth.state === 'signedOut' ? '请先登录' : '请选择课程'); find('[data-kind]').textContent = node ? timed ? `${resourceKind(node)} · 停留 ${targetDocumentMs(node) / 60000} 分钟` : '视频' : '待选择'; find('[data-index]').textContent = index >= 0 ? `${String(index + 1).padStart(2, '0')} / ${String(queue.length).padStart(2, '0')}` : '— / —'; const elapsed = timed ? (documentClock?.elapsedMs || 0) / 1000 : video?.currentTime; const duration = timed ? targetDocumentMs(node) / 1000 : video?.duration; find('[data-elapsed]').textContent = formatTime(elapsed); find('[data-duration]').textContent = formatTime(duration); find('[data-progress]').value = Number.isFinite(duration) && duration > 0 ? Math.min(100, Math.max(0, elapsed / duration * 100)) : 0; find('[data-percent]').textContent = `${Math.floor(find('[data-progress]').value)}%`; find('[data-timeline]').hidden = !node; find('[data-list-total]').textContent = queue.length ? String(queue.length) : ''; find('[data-history]').textContent = history.minutes == null ? history.error ? '读取失败' : '—' : `${Number(history.minutes.toFixed(1))} 分钟`; find('[data-history-note]').textContent = historyBusy ? '正在读取历史统计' : history.error ? '历史统计读取失败,可刷新重试' : '历史统计 · 次日更新'; find('.history').dataset.error = String(history.error); find('[data-cache]').textContent = cacheAt ? `更新于 ${new Date(cacheAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}` : loading ? '正在检测课程资料…' : ''; find('[data-cache]').title = '课程列表使用本地缓存;点击刷新可重新检测。学习时长为平台历史统计。'; const state = completions.get(current?.node); find('[data-completion]').textContent = completionLabel(state); find('[data-completion]').classList.toggle('done', state?.complete === true); find('[data-refresh]').disabled = completionBusy || loading || historyBusy; const done = queue.filter(node => completions.get(String(node.id))?.complete === true).length; const unknown = queue.filter(node => completions.get(String(node.id))?.complete == null).length; find('[data-summary]').textContent = queue.length ? `已完成 ${done} / ${queue.length}${unknown ? ` · ${unknown} 项待确认` : ''}${completionBusy ? ' · 检测中' : ''}` : '完成状态以平台记录为准'; shell.dataset.running = String(running); const mode = viewMode(); const states = { jumping: ['跳转中', '跳转中', 'loading'], loading: ['加载中', '加载中', 'loading'], next: ['即将切换', '即将切换', 'loading'], playing: [document.hidden ? '后台播放' : '播放中', '暂停学习', 'pause'], reading: ['阅读中', '暂停学习', 'pause'], paused: ['已暂停', '继续学习', 'play'], stopped: ['已停止', settings.mode === 'single' ? '开始当前项' : '开始连续学习', 'play'], ended: ['本次已结束', '重新开始', 'play'], authChecking: ['确认登录', '确认登录中', 'loading'], signedOut: ['未登录', '请先登录', 'play'], authError: ['连接失败', '请刷新重试', 'play'], selectCourse: ['未选择课程', '请选择课程', 'play'], courseLoading: ['读取课程', '正在读取课程', 'loading'], courseError: ['课程不可用', '请刷新重试', 'play'], selectResource: ['未选择资料', '请选择学习资料', 'play'] }; const [stateText, actionText, icon] = states[mode]; shell.dataset.mode = mode; launcher.dataset.mode = mode; find('[data-state]').textContent = stateText; startButton.disabled = ['jumping', 'loading', 'next', 'authChecking', 'signedOut', 'authError', 'selectCourse', 'courseLoading', 'courseError', 'selectResource'].includes(mode); startButton.dataset.action = ['playing', 'reading'].includes(mode) ? 'pause' : 'play'; startButton.setAttribute('aria-label', actionText); find('[data-action-label]').textContent = actionText; setIcon(find('[data-action-icon]'), icon); for (const currentIcon of list.querySelectorAll('[data-current-icon]')) setIcon(currentIcon, ['playing', 'reading'].includes(mode) ? 'pause' : 'play'); launcher.title = `${stateText};点击${collapsed ? '展开' : '收起'};拖动移动`; if (navigation || contextMessage()) report(''); updateNotice(); stopButton.disabled = !running && !userPaused && !navigation; find('[data-refresh]').disabled = auth.state === 'checking' || authBusy || completionBusy || loading || historyBusy || courseInfoBusy; const configDisabled = !confirmed || courseInfo.state !== 'ready' || ['jumping', 'next'].includes(mode); for (const button of ui.querySelectorAll('[data-learning-mode]')) { button.disabled = configDisabled; button.setAttribute('aria-pressed', String(button.dataset.learningMode === settings.mode)); } find('[data-default-minutes]').disabled = configDisabled; find('[data-resource-minutes]').disabled = configDisabled; find('[data-reset-resource]').disabled = configDisabled; const continuous = settings.mode === 'continuous'; const hasNonVideo = queue.some(item => !isVideo(item)); const hasVideo = queue.some(isVideo); find('[data-continuous-settings]').hidden = !continuous; find('[data-configuration]').hidden = continuous ? !hasNonVideo : !timed; find('[data-configuration]').dataset.single = String(!continuous); find('[data-default-settings]').hidden = !continuous; find('[data-item-settings]').hidden = !timed; find('[data-item-name]').textContent = timed ? `当前任务:${node.name || resourceKind(node)}` : ''; find('[data-item-name]').title = find('[data-item-name]').textContent; find('[data-reset-resource]').textContent = `使用课程默认(${settings.documentMinutes} 分钟)`; find('[data-duration-intro]').textContent = continuous ? '用于本课程的 PPT、PDF 等自主观看任务。视频按自身时长播放。' : '仅设置当前非视频任务,课程默认时间不会被修改。'; muteInput.closest('.option').hidden = continuous ? !hasVideo : !node || timed; skipInput.disabled = configDisabled || settings.mode === 'single'; find('[data-config-note]').textContent = timed ? `本项${settings.resourceMinutes[String(node.id)] != null ? '单独设置' : '跟随默认'}:${targetDocumentMs(node) / 60000} 分钟。调整时保留已停留时间。` : '默认时间应用于本课程的非视频任务,打开任务后可单独调整。'; find('[data-settings-context]').textContent = continuous ? '当前为连续学习,以下设置用于本课程的学习列表。' : !node ? '先在“全部”中选择一项,再显示对应的任务设置。' : `当前为单项学习,仅显示${timed ? '此非视频任务' : '此视频'}适用的选项。`; find('[data-settings-save-note]').textContent = continuous ? '课程学习选项按当前账号保存,播放偏好通用。' : timed ? '本项停留按任务保存,播放偏好通用。' : '播放偏好在各课程间通用。'; find('[data-mode-note]').textContent = !node ? continuous ? '按课程顺序连续学习,可在“设置”中调整规则。' : '先在“全部”中选择一项,结束后停止。' : timed ? `本项停留 ${targetDocumentMs(node) / 60000} 分钟后${continuous ? '自动学习下一项。' : '停止本次学习。'}` : continuous ? '当前视频结束后,自动学习下一项。' : '只学当前视频,播放结束后停止。'; find('[data-locate]').disabled = !node; const canNavigate = confirmed && courseInfo.state === 'ready' && !navigation; const next = index >= 0 ? queue[index + 1] : queue[0]; find('[data-neighbors]').hidden = !confirmed || !queue.length; for (const step of ui.querySelectorAll('[data-step]')) { const previous = step.dataset.step === 'previous'; const target = previous ? index > 0 ? queue[index - 1] : null : next; const label = previous ? '上一项' : index < 0 ? '第一项' : '下一项'; const name = target ? target.name || String(target.id) : previous ? index < 0 ? '请先选择任务' : '已是第一项' : '已是最后一项'; const enabled = canNavigate && !!target; step.disabled = !enabled; step.setAttribute('aria-disabled', String(!enabled)); step.tabIndex = enabled ? 0 : -1; step.querySelector('[data-step-label]').textContent = label; step.querySelector('[data-step-name]').textContent = name; step.title = `${label}:${name}`; step.setAttribute('aria-label', step.title); if (enabled) step.dataset.targetNode = String(target.id); else delete step.dataset.targetNode; } } function setTab(name, save = true) { if (!['learning', 'list', 'settings'].includes(name)) return; activeTab = name; for (const tab of ui.querySelectorAll('[data-tab]')) { const selected = tab.dataset.tab === name; tab.setAttribute('aria-selected', String(selected)); tab.tabIndex = selected ? 0 : -1; } for (const view of ui.querySelectorAll('[data-view]')) view.hidden = view.dataset.view !== name; const confirmed = auth.state === 'ready' && !!route(); find('[data-tabs]').hidden = !confirmed; find('[data-footer]').hidden = !confirmed; if (save) persist(); place(); } function syncSettingsInputs() { find('[data-default-minutes]').value = String(settings.documentMinutes); const node = currentNode(); find('[data-resource-minutes]').value = node && settings.resourceMinutes[String(node.id)] != null ? String(settings.resourceMinutes[String(node.id)]) : ''; skipInput.checked = settings.skipCompleted; } function clampPosition(x, y) { return { x: Math.max(12, Math.min(x, Math.max(12, innerWidth - 60))), y: Math.max(12, Math.min(y, Math.max(12, innerHeight - 60))) }; } function place() { if (!position || !Number.isFinite(position.x) || !Number.isFinite(position.y)) { position = { x: innerWidth - 72, y: innerHeight * 0.12 }; } position = clampPosition(position.x, position.y); Object.assign(panel.style, { left: `${position.x}px`, top: `${position.y}px`, right: 'auto', bottom: 'auto' }); const bounds = shell.getBoundingClientRect(); const width = bounds.width || Math.min(352, Math.max(0, innerWidth - 24)); const height = bounds.height || 480; const preferredX = position.x < innerWidth / 2 ? position.x + 60 : position.x - width - 12; const x = Math.max(12, Math.min(preferredX, innerWidth - width - 12)); const y = Math.max(12, Math.min(position.y, innerHeight - height - 12)); shell.style.left = `${x - position.x}px`; shell.style.top = `${y - position.y}px`; } function setCollapsed() { shell.hidden = collapsed; collapseButton.innerHTML = icons.collapse; collapseButton.setAttribute('aria-expanded', String(!collapsed)); collapseButton.setAttribute('aria-label', collapsed ? '展开面板' : '收起面板'); launcher.setAttribute('aria-expanded', String(!collapsed)); launcher.setAttribute('aria-label', collapsed ? '展开学习助手' : '收起学习助手'); place(); } let drag = null, suppressLauncherClick = false; const dragHandle = find('[data-drag]'); for (const handle of [launcher, dragHandle]) { handle.addEventListener('pointerdown', event => { if (event.button !== 0 || handle !== launcher && event.target.closest('button')) return; drag = { startX: event.clientX, startY: event.clientY, x: position.x, y: position.y, moved: false }; suppressLauncherClick = false; handle.setPointerCapture?.(event.pointerId); }); handle.addEventListener('pointermove', event => { if (!drag) return; const dx = event.clientX - drag.startX, dy = event.clientY - drag.startY; if (Math.hypot(dx, dy) <= 5 && !drag.moved) return; drag.moved = true; position = clampPosition(drag.x + dx, drag.y + dy); place(); }); handle.addEventListener('pointerup', () => { if (!drag) return; suppressLauncherClick = handle === launcher && drag.moved; drag = null; persist(); }); handle.addEventListener('pointercancel', () => { drag = null; suppressLauncherClick = false; }); } window.addEventListener('resize', place); if (typeof ResizeObserver === 'function') { const panelSizeObserver = new ResizeObserver(() => { if (!collapsed) place(); }); panelSizeObserver.observe(shell); } setCollapsed(); function route() { const course = location.pathname.match(/^\/app\/jx-web\/mycourse\/(\d+)(?:\/|$)/); if (!course) return null; const resource = location.pathname.match(/\/resource\/([^/]+)\/(\d+)\/?$/); return { course: course[1], folder: resource?.[1] || null, node: resource?.[2] || null }; } function currentNode() { const current = route(); return current?.course === queueCourse ? queue.find(node => String(node.id) === current.node) : null; } function updateDocumentClock(active) { if (!documentClock) return; const now = Date.now(); if (active && documentClock.lastAt != null) { documentClock.elapsedMs += Math.max(0, now - documentClock.lastAt); } documentClock.lastAt = active ? now : null; } function processDocument() { const current = route(), node = currentNode(); if (!node || isVideo(node)) return; if (!documentClock || documentClock.node !== current.node || documentClock.course !== current.course) { documentClock = { course: current.course, node: current.node, elapsedMs: 0, lastAt: null, ready: false }; } const body = document.querySelector('.group-resource-body .group-resource-content'); const failure = body && [...body.querySelectorAll('.ant-result-error, .resource-error, [data-preview-error="true"]')].find(visible); if (failure) { updateDocumentClock(false); resourceFailure = '资料预览失败,请在平台页面处理后重新开始。'; stop(resourceFailure); return; } documentClock.ready = !!body && !body.querySelector('[aria-busy="true"], .ant-spin-spinning'); const active = running && documentClock.ready && !navigation && !hasOpenDialog() && (background || !document.hidden); updateDocumentClock(active); if (active) { report(`正在阅读${resourceKind(node)},停留 ${targetDocumentMs(node) / 60000} 分钟后${settings.mode === 'single' ? '结束本次学习' : '切换下一项'}。`); if (documentClock.elapsedMs >= targetDocumentMs(node)) scheduleResourceNext(current.node, null, 0); } if (navigation && documentClock.ready) finishNavigation(); updateView(); } function visible(element) { return element && element.isConnected && element.getClientRects().length > 0 && getComputedStyle(element).visibility !== 'hidden'; } function detachVideo() { if (video) { video.removeEventListener('ended', endedListener); for (const event of ['loadedmetadata', 'loadeddata', 'canplay', 'seeked']) video.removeEventListener(event, readyListener); video.removeEventListener('pause', pauseListener); video.removeEventListener('play', playListener); } video = null; playPending = false; waitingForVideoReady = false; backgroundIntent = false; clearTimeout(backgroundTimer); backgroundTimer = null; } function stop(message = '已停止', paused = false, ended = false) { updateDocumentClock(running); if (documentClock) documentClock.lastAt = null; running = false; userPaused = paused; sessionEnded = ended; navigation = null; outgoingVideo = null; generation++; loading = false; clearTimeout(nextTimer); nextTimer = null; advancing = false; clearTimeout(backgroundTimer); backgroundTimer = null; backgroundIntent = false; resumeAfterDialog = false; if (video && !video.paused) video.pause(); persist(); report(message); updateView(); } function beginNavigation(node, course) { updateDocumentClock(running); if (documentClock) documentClock.lastAt = null; sessionEnded = false; resourceFailure = ''; navigation = { course, node: String(node.id), name: node.name || '', fromNode: route()?.node, startedAt: Date.now() }; outgoingVideo = video; outgoingSource = video?.currentSrc || ''; generation++; loading = false; clearTimeout(nextTimer); nextTimer = null; advancing = false; backgroundIntent = false; if (video && !video.paused) video.pause(); detachVideo(); setTab('learning', false); persist(); report(''); updateView(); } function finishNavigation() { if (!navigation) return; navigation = null; outgoingVideo = null; outgoingSource = ''; persist(); report(running ? '正在准备学习…' : '已打开资料,点击主按钮即可开始学习。'); } function openResourceFromClick(event, node, course) { event.preventDefault(); event.stopImmediatePropagation(); if (!node || event.button !== 0) return; if (route()?.course === course && route()?.node === String(node.id)) return; // Task controls are buttons: no native link default or modifier-key new-tab behavior. // Persist playback intent before explicitly navigating this browsing context. const destination = resourcePath(course, node); beginNavigation(node, course); location.assign(destination); } function filteredQueue() { const term = queueSearch.normalize('NFKC').trim().toLocaleLowerCase(); return queue.filter(node => { const done = completions.get(String(node.id))?.complete === true; return (queueFilter === 'all' || (queueFilter === 'completed' ? done : !done)) && (!term || String(node.name || node.id).normalize('NFKC').toLocaleLowerCase().includes(term)); }); } function showQueue() { const previousScroll = list.scrollTop; list.replaceChildren(); const current = route()?.node; const nodes = filteredQueue(); if (!nodes.length) { const empty = document.createElement('li'); empty.className = 'queue-empty'; empty.textContent = queue.length ? queueSearch ? '没有找到匹配的资料' : '当前筛选下没有资料' : auth.state !== 'ready' ? '登录后显示学习资料' : !route() ? '选择课程后自动检测学习资料' : loading ? '正在读取课程…' : directoryIssue || '当前课程暂未检测到视频或自主观看资料'; list.appendChild(empty); } for (const node of nodes) { const number = queue.indexOf(node); const item = document.createElement('li'); const link = document.createElement('button'); link.type = 'button'; link.className = 'queue-link'; link.dataset.node = String(node.id); const currentItem = String(node.id) === current; item.classList.toggle('current', currentItem); if (currentItem) link.setAttribute('aria-current', 'page'); const numberLabel = document.createElement('span'); numberLabel.className = 'num'; if (currentItem) { numberLabel.dataset.currentIcon = ''; setIcon(numberLabel, ['playing', 'reading'].includes(viewMode()) ? 'pause' : 'play'); } else numberLabel.textContent = String(number + 1).padStart(2, '0'); const title = document.createElement('span'); title.className = 'name'; title.textContent = node.name || node.id; title.title = title.textContent; const badge = document.createElement('span'); badge.className = 'completion'; badge.dataset.completionFor = String(node.id); const kind = document.createElement('span'); kind.className = 'kind'; kind.textContent = resourceKind(node); link.append(numberLabel, kind, title, badge); link.onclick = event => openResourceFromClick(event, node, queueCourse); item.appendChild(link); list.appendChild(item); } list.scrollTop = previousScroll; updateCompletionBadges(false); place(); } function updateCompletionBadges(reconcile = true) { if (reconcile) { const expected = filteredQueue().map(node => String(node.id)).join(','); const rendered = [...list.querySelectorAll('[data-node]')].map(link => link.dataset.node).join(','); if (expected !== rendered) { showQueue(); return; } } for (const badge of list.querySelectorAll('[data-completion-for]')) { const state = completions.get(badge.dataset.completionFor); badge.textContent = completionLabel(state); badge.classList.toggle('done', state?.complete === true); badge.title = state?.error ? '状态读取失败,点击“刷新状态”重试' : state?.hasTask ? '任务完成状态以平台返回的 finished 字段为准' : '根据平台已记录的观看时长计算'; } updateView(); } function resetCourseContext(course) { updateDocumentClock(running); documentClock = null; if (video && !video.paused) video.pause(); detachVideo(); generation++; courseEpoch++; completionEpoch++; observedCourse = course; queue = []; queueSearch = ''; queueFilter = 'all'; find('[data-search]').value = ''; for (const filter of ui.querySelectorAll('[data-filter]')) filter.setAttribute('aria-pressed', String(filter.dataset.filter === 'all')); list.scrollTop = 0; queueCourse = course || ''; routeKey = ''; cacheAt = 0; loading = false; completionBusy = false; historyBusy = false; courseInfoBusy = false; history = { minutes: null, fetchedAt: 0, error: false }; courseInfo = { name: '', state: course ? 'loading' : 'none', fetchedAt: 0 }; directoryCheckedAt = 0; currentCheckAt = 0; directoryIssue = ''; resourceFailure = ''; sessionEnded = false; userPaused = false; completions.clear(); if (course && auth.state === 'ready') loadSettings(course); else { settings = normalizeSettings(); skipCompleted = false; syncSettingsInputs(); } showQueue(); } function invalidateAuthentication(message = '登录已过期,请先登录') { authEpoch++; auth = { state: 'signedOut', id: null, message, checkedAt: Date.now() }; authBusy = false; if (!loginNoticeShown) { loginNoticeShown = true; collapsed = false; setCollapsed(); } stop(message); resetCourseContext(null); updateView(); } async function verifyAuthentication(force = false) { if (loginSurface) { invalidateAuthentication('请先登录'); return; } if (authBusy) return; const scope = tokenScope(); if (!scope) { invalidateAuthentication('请先登录'); cacheScope = null; return; } if (!force && auth.state === 'ready' && scope === cacheScope && Date.now() - auth.checkedAt < 5 * 60000) return; const epoch = ++authEpoch; authBusy = true; if (auth.state !== 'ready') auth = { state: 'checking', id: null, message: '', checkedAt: Date.now() }; updateView(); try { const account = await readApi('/api/jx-auth/oauth2/info'); if (epoch !== authEpoch || scope !== tokenScope()) return; if (account?.info?.id == null) throw Object.assign(new Error('请先登录'), { status: 401 }); cacheScope = scope; auth = { state: 'ready', id: String(account.info.id), message: '', checkedAt: Date.now() }; if (saved.accountId && String(saved.accountId) !== auth.id) stop('账号已切换,请选择课程后重新开始。'); } catch (error) { if (epoch !== authEpoch) return; if (error.status === 401) invalidateAuthentication('登录已过期,请先登录'); else { auth = { state: 'error', id: null, message: error.message, checkedAt: Date.now() }; stop('无法确认登录状态,请点击刷新重试。'); resetCourseContext(null); } } finally { if (epoch === authEpoch) authBusy = false; if (auth.state === 'ready') tick(); else updateView(); } } async function loadCourseInfo(course, force = false) { if (courseInfoBusy) return; const epoch = courseEpoch; const cached = readCache(course)?.courseInfo; if (!force && cached?.name) { courseInfo = { ...cached, state: 'ready' }; if (Date.now() - cached.fetchedAt < QUEUE_TTL) { updateView(); return; } } courseInfoBusy = true; try { const data = await readApi(`/api/jx-iresource/group/queryGroup/${encodeURIComponent(course)}`); if (epoch !== courseEpoch) return; const name = data?.name || data?.group?.name || data?.site_name; if (typeof name !== 'string' || !name.trim() || data?.id != null && String(data.id) !== course) throw new Error('课程信息格式异常'); courseInfo = { name: name.trim(), state: 'ready', fetchedAt: Date.now() }; writeCache(); } catch (error) { if (epoch !== courseEpoch) return; if (error.status === 403) courseInfo = { name: '', state: 'forbidden', fetchedAt: 0 }; else if (!courseInfo.name) courseInfo = { name: '', state: 'error', fetchedAt: 0 }; if (running) stop('课程信息读取失败,本次学习已停止。'); } finally { if (epoch === courseEpoch) { courseInfoBusy = false; updateView(); place(); } } } async function readApi(path) { const entry = tokenEntry(); if (!entry) throw new Error('未找到登录状态,请先登录平台'); const token = decodeURIComponent(entry.slice(entry.indexOf('=') + 1)); const response = await fetch(path, { credentials: 'same-origin', headers: { authorization: `Bearer ${token}` } }); if (!response.ok) { if (response.status === 401 && !path.includes('/oauth2/info')) invalidateAuthentication(); throw Object.assign(new Error(`请求失败:HTTP ${response.status}`), { status: response.status }); } const result = await response.json(); if (!result.success) throw new Error(result.message || '平台未返回有效数据'); return result.data; } function checkCompletion(node, force = false) { const id = String(node.id), course = queueCourse, epoch = completionEpoch; const key = `${epoch}/${id}`; if (pendingChecks.has(key)) return pendingChecks.get(key); const previous = completions.get(id); if (!force && previous?.checkedAt && Date.now() - previous.checkedAt < 30000) return Promise.resolve(previous); const pending = (async () => { await Promise.resolve(); try { const task = node.task_id || node.task?.task_id; let state; if (task) { const params = new URLSearchParams({ group_id: course, node_id: id, task_id: String(task) }); const data = await readApi(`/api/jx-iresource/resource/task/studentFinishInfo/v2?${params}`); if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('任务状态格式异常'); state = completionState(node, data); } else if (node.quote_id && isVideo(node)) { const data = await readApi(`/api/jx-iresource/vod/duration/${encodeURIComponent(String(node.quote_id))}`); if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('观看记录格式异常'); state = completionState(node, Object.keys(data).length ? data : { duration: 1, watched_duration: 0 }); } else { state = completionState(node, node.watchRecord || node); } state.checkedAt = Date.now(); if (epoch === completionEpoch && course === queueCourse) completions.set(id, state); return state; } catch { const state = { complete: null, percent: previous?.percent ?? null, hasTask: !!(node.task_id || node.task?.task_id), error: true, checkedAt: Date.now() }; if (epoch === completionEpoch && course === queueCourse) completions.set(id, state); return state; } finally { pendingChecks.delete(key); if (epoch === completionEpoch && course === queueCourse) { updateCompletionBadges(); writeCache(); } } })(); pendingChecks.set(key, pending); return pending; } async function refreshCompletion(force = false) { if (completionBusy || !queue.length) return; completionBusy = true; const epoch = completionEpoch, nodes = [...queue]; let cursor = 0; updateView(); try { await Promise.all(Array.from({ length: Math.min(3, nodes.length) }, async () => { while (cursor < nodes.length && epoch === completionEpoch) await checkCompletion(nodes[cursor++], force); })); } finally { if (epoch === completionEpoch) { completionBusy = false; updateCompletionBadges(); } } } async function loadHistory(course, force = false) { if (historyBusy) return; const epoch = courseEpoch; const cached = readCache(course)?.history; if (!force && cached && Number.isFinite(cached.minutes)) { history = cached; if (Date.now() - cached.fetchedAt < HISTORY_TTL) { updateView(); return; } } historyBusy = true; updateView(); try { const userId = auth.id; if (userId == null) throw new Error('无法读取当前账号'); const params = new URLSearchParams({ group_id: course, user_id: String(userId), client_type: '3', unit: '1' }); const data = await readApi(`/api/jx-stat/ads/duration/user?${params}`); if (data?.total_duration == null || !Number.isFinite(Number(data.total_duration)) || Number(data.total_duration) < 0) { throw new Error('历史统计格式异常'); } if (epoch !== courseEpoch) return; history = { minutes: Number(data.total_duration), fetchedAt: Date.now(), error: false }; writeCache(); } catch { if (epoch === courseEpoch) history = { ...history, error: true }; } finally { if (epoch === courseEpoch) { historyBusy = false; updateView(); } } } function applyQueue(nodes, course, cachedStates = []) { queue = nodes; queueCourse = course; completionEpoch++; completionBusy = false; completions.clear(); const states = new Map(cachedStates); for (const node of queue) { const id = String(node.id), cached = states.get(id); completions.set(id, cached && Date.now() - cached.checkedAt < STATUS_TTL ? cached : completionState(node, node.task_id || node.task?.task_id ? node : node.watchRecord || node)); } syncSettingsInputs(); showQueue(); refreshCompletion(); } async function loadQueue(current, force = false) { if (loading) return; const version = courseEpoch; const cached = readCache(current.course); if (!force && cached) { cacheAt = cached.cachedAt; applyQueue(cached.queue.filter(isStudyResource), current.course, cached.completions || []); if (Date.now() - cached.cachedAt < QUEUE_TTL) { directoryCheckedAt = Date.now(); return; } } loading = true; directoryIssue = ''; directoryCheckedAt = Date.now(); report('正在检测本课程的学习资料…'); updateView(); try { const resources = await readApi(`/api/jx-iresource/resource/queryCourseResources/v2?group_id=${current.course}`); if (!Array.isArray(resources)) throw new Error('目录接口格式不符合预期'); if (version !== courseEpoch || route()?.course !== current.course) return; cacheAt = Date.now(); applyQueue(buildQueue(resources), current.course); writeCache(); report(running ? `已检测 ${queue.length} 项,正在准备学习…` : '在“全部”中选择任务,或点击主按钮从当前项开始学习。'); } catch (error) { if (version === courseEpoch) { directoryIssue = error.status === 403 ? '当前账号无权读取课程资料。' : `列表读取失败:${error.message},点击刷新重试。`; report(directoryIssue); } } finally { if (version === courseEpoch) { loading = false; updateView(); place(); } } } async function playCurrent() { const target = video; if (!running || !target || target.ended || target.error || playPending) return; // A metadata-only preload may need play() to request buffered frames. if (target.readyState < 1) { waitingForVideoReady = true; return; } waitingForVideoReady = false; const version = generation; playPending = true; updateView(); target.muted = muted; try { await target.play(); if (!running || video !== target || version !== generation) { if (!running || video !== target) target.pause(); return; } finishNavigation(); report(document.hidden ? '已在后台继续播放,可切换到其他标签页。' : settings.mode === 'single' ? '当前视频结束后,将结束本次学习。' : '视频结束后,将按课程目录自动播放下一节。'); } catch (error) { if (running && video === target && version === generation) { finishNavigation(); report(error.name === 'NotAllowedError' ? '浏览器限制了自动播放,请手动点击一次视频播放按钮' : `播放失败:${error.message},可手动播放后继续`); } } finally { if (video === target && version === generation) playPending = false; updateView(); } } function hasOpenDialog() { return [...document.querySelectorAll('.ant-modal, [role="dialog"]')].some(visible); } function recoverBackground() { if (!running || !background || !backgroundIntent || !video?.paused || video.ended || video.error || video.seeking || video.readyState < 2 || resumeAfterDialog || hasOpenDialog() || backgroundAttempts >= 3 || Date.now() - lastBackgroundAttempt < 3000) return; backgroundAttempts++; lastBackgroundAttempt = Date.now(); playCurrent(); } function queueBackgroundRecovery() { if (backgroundTimer || !running || !background || !backgroundIntent) return; backgroundTimer = setTimeout(() => { backgroundTimer = null; recoverBackground(); }, 250); } // 在平台的普通 visibilitychange 监听器之前保留播放意图;不修改页面可见性。 document.addEventListener('visibilitychange', () => { if (documentClock && !background) updateDocumentClock(false); clearTimeout(backgroundTimer); backgroundTimer = null; if (!running || !background || !video) return; if (document.hidden) { backgroundIntent = !video.paused && !video.ended; backgroundAttempts = 0; lastBackgroundAttempt = 0; } if (backgroundIntent) queueBackgroundRecovery(); updateView(); }, true); function scheduleNext(currentVideo) { const current = route(); if (!current || video !== currentVideo || !currentVideo.ended) return; scheduleResourceNext(current.node, currentVideo, 5000); } function scheduleResourceNext(nodeId, currentVideo, delay) { const current = route(); if (!running || !current || current.node !== nodeId || nextTimer || advancing) return; const index = queue.findIndex(node => String(node.id) === current.node); if (index < 0) { stop('无法在目录中定位当前资料,已停止'); return; } const next = nextVideo(queue, current.node, completions, skipCompleted); const finished = () => currentVideo ? video === currentVideo && currentVideo.ended : documentClock?.node === nodeId && documentClock.elapsedMs >= targetDocumentMs(queue[index]); report(settings.mode === 'single' ? '当前项已结束,正在确认平台记录。' : next ? delay ? `视频结束,5 秒后切换:${next.name}` : `计划停留已结束,正在切换:${next.name}` : '当前项已结束,正在确认平台记录。'); const version = generation; nextTimer = setTimeout(async () => { nextTimer = null; if (!running || generation !== version || route()?.node !== current.node || !finished()) return; advancing = true; try { await checkCompletion(queue[index], true); if (!running || generation !== version || route()?.node !== current.node || !finished()) return; if (settings.mode === 'single') { const state = completions.get(nodeId); stop(state?.complete === true ? '本次学习已结束,平台记录已完成。' : currentVideo ? '本次播放已结束,平台完成状态待确认。' : '计划停留已结束,平台完成状态待确认。', false, true); return; } const target = nextVideo(queue, current.node, completions, skipCompleted); if (!target) { stop('本次学习已结束,后续没有需要继续学习的资料。', false, true); return; } beginNavigation(target, current.course); location.assign(resourcePath(current.course, target)); } finally { if (generation === version) advancing = false; } }, delay); updateView(); } function bindVideo() { const found = [...document.querySelectorAll('.group-resource-body .ali_player_wrapper video')].find(visible); if (navigation && found === outgoingVideo && (found?.currentSrc || '') === outgoingSource) return; if (found === video) { // A cached/seeked player can become ready without another loadeddata event. if (running && (navigation || waitingForVideoReady) && !resumeAfterDialog && !hasOpenDialog()) playCurrent(); return; } detachVideo(); if (!found) return; video = found; endedListener = () => scheduleNext(found); readyListener = () => { if (video !== found || found.readyState < 1) return; if (!running) { finishNavigation(); updateView(); } else if ((navigation || waitingForVideoReady) && !resumeAfterDialog && !hasOpenDialog()) playCurrent(); }; pauseListener = () => { if (running && background && backgroundIntent && document.hidden) { queueBackgroundRecovery(); } else if (!document.hidden) { backgroundIntent = false; if (running && !found.ended && !resumeAfterDialog) report('视频已暂停,点击“继续学习”即可继续。'); } updateView(); }; playListener = () => { waitingForVideoReady = false; finishNavigation(); userPaused = false; if (running && background && document.hidden) backgroundIntent = true; updateView(); }; found.addEventListener('ended', endedListener); for (const event of ['loadedmetadata', 'loadeddata', 'canplay', 'seeked']) found.addEventListener(event, readyListener); found.addEventListener('pause', pauseListener); found.addEventListener('play', playListener); if (!running && found.readyState >= 2) finishNavigation(); if (found.ended) scheduleNext(found); else playCurrent(); } function handleContinueDialog() { const dialogs = [...document.querySelectorAll('.ant-modal, [role="dialog"]')].filter(visible); const dialog = dialogs.find(element => /长时间[\s\S]{0,40}(未操作|没有操作)/.test(element.textContent || '')); if (!dialog) { if (resumeAfterDialog) { resumeAfterDialog = false; playCurrent(); } return false; } const button = [...dialog.querySelectorAll('button')].find(element => element.textContent.replace(/\s/g, '') === '继续学习' && !element.disabled && visible(element)); if (button && Date.now() - lastDialogClick > 3000) { lastDialogClick = Date.now(); resumeAfterDialog = true; button.click(); report('已点击“继续学习”,等待弹窗关闭后恢复播放…'); } else if (!button) report('检测到未操作提示,但未找到“继续学习”按钮,请手动处理'); return true; } function tick() { if (loginSurface) { if (auth.state !== 'signedOut' || running || navigation) invalidateAuthentication('请先登录'); else updateView(); return; } const scope = tokenScope(); if (scope !== cacheScope) { authEpoch++; authBusy = false; stop('登录状态已变化,请重新确认。'); resetCourseContext(null); cacheScope = scope; auth = { state: 'checking', id: null, message: '', checkedAt: 0 }; verifyAuthentication(true); return; } if (auth.state !== 'ready') { if (auth.state === 'checking' && !authBusy) verifyAuthentication(); updateView(); return; } if (!authBusy && Date.now() - auth.checkedAt >= 5 * 60000) verifyAuthentication(); const current = route(); const course = current?.course || null; if (course !== observedCourse) { if (running && (!current || activeCourse !== course)) stop('已切换课程,连播停止。'); resetCourseContext(course); if (current) { loadCourseInfo(course); loadQueue(current); loadHistory(course); } } updateView(); if (navigation) { if (Date.now() - navigation.startedAt >= 30000) { stop('跳转等待超时,请刷新页面后重试。'); return; } if (current?.course === navigation.course && current.node === navigation.fromNode && current.node !== navigation.node) return; if (current?.course !== navigation.course || current.node !== navigation.node) finishNavigation(); else if (loading && !queue.length) return; } const key = current ? `${current.course}/${current.node || ''}` : ''; if (key !== routeKey) { updateDocumentClock(running); documentClock = null; routeKey = key; generation++; clearTimeout(nextTimer); nextTimer = null; advancing = false; resumeAfterDialog = false; sessionEnded = false; userPaused = false; resourceFailure = ''; if (video && !video.paused) video.pause(); detachVideo(); syncSettingsInputs(); showQueue(); } if (current && !loading && Date.now() - directoryCheckedAt >= (queue.length ? QUEUE_TTL : 30000)) loadQueue(current); if (current?.node && queueCourse === current.course && Date.now() - currentCheckAt >= 30000) { currentCheckAt = Date.now(); const node = queue.find(item => String(item.id) === current.node); if (node) checkCompletion(node); } if (!running) { if (navigation && currentNode()) { if (isVideo(currentNode())) bindVideo(); else processDocument(); } updateView(); return; } if (courseInfo.state !== 'ready') return; if (!current?.node || current.course !== activeCourse) { stop('已离开当前学习资料页面,连播停止'); return; } if (loading && !queue.length) return; const node = currentNode(); if (!node) { stop('当前项不是视频或自主观看资料,请从列表选择。'); return; } if (handleContinueDialog()) { updateDocumentClock(false); return; } if (isVideo(node)) { bindVideo(); recoverBackground(); } else processDocument(); updateView(); } startButton.onclick = () => { if (startButton.disabled || auth.state !== 'ready' || courseInfo.state !== 'ready') return; if (['playing', 'reading'].includes(viewMode())) { stop('已暂停,点击继续学习。', true); return; } const current = route(); if (!current) { report('请先进入课程'); return; } if (!queue.length) { report('暂未检测到可学习资料,可点击刷新重新检测。'); return; } if (settings.mode === 'single' && !currentNode()) { report('请先从列表选择一项学习资料。'); return; } if (sessionEnded) { if (documentClock) { documentClock.elapsedMs = 0; documentClock.lastAt = null; } if (video) video.currentTime = 0; } sessionEnded = false; resourceFailure = ''; activeCourse = current.course; running = true; userPaused = false; persist(); if (!currentNode()) { const target = queue.find(node => !skipCompleted || completions.get(String(node.id))?.complete !== true); if (!target) { stop('本课程列表中的资料均已完成。'); return; } beginNavigation(target, current.course); location.assign(resourcePath(current.course, target)); return; } tick(); if (video) playCurrent(); }; stopButton.onclick = () => stop(); launcher.onclick = () => { if (suppressLauncherClick) { suppressLauncherClick = false; return; } collapsed = !collapsed; setCollapsed(); persist(); if (!collapsed) { const current = route(); if (current && queueCourse !== current.course) loadQueue(current); else showQueue(); } updateView(); }; collapseButton.onclick = () => { collapsed = !collapsed; setCollapsed(); persist(); }; find('[data-refresh]').onclick = () => { if (auth.state !== 'ready' || tokenScope() !== cacheScope) { verifyAuthentication(true); return; } const current = route(); if (!current) { report('请选择课程'); return; } loadCourseInfo(current.course, true); loadQueue(current, true); loadHistory(current.course, true); }; find('[data-empty-retry]').onclick = () => find('[data-refresh]').click(); for (const tab of ui.querySelectorAll('[data-tab]')) { tab.onclick = () => setTab(tab.dataset.tab); tab.onkeydown = event => { const tabs = [...ui.querySelectorAll('[data-tab]')]; const index = tabs.indexOf(tab); const next = event.key === 'ArrowRight' ? (index + 1) % tabs.length : event.key === 'ArrowLeft' ? (index + tabs.length - 1) % tabs.length : event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : -1; if (next < 0) return; event.preventDefault(); setTab(tabs[next].dataset.tab); tabs[next].focus(); }; } find('[data-search]').oninput = event => { queueSearch = event.target.value; list.scrollTop = 0; showQueue(); }; for (const filter of ui.querySelectorAll('[data-filter]')) { filter.onclick = () => { queueFilter = filter.dataset.filter; for (const item of ui.querySelectorAll('[data-filter]')) item.setAttribute('aria-pressed', String(item === filter)); list.scrollTop = 0; showQueue(); }; } find('[data-locate]').onclick = () => { queueSearch = ''; queueFilter = 'all'; find('[data-search]').value = ''; for (const filter of ui.querySelectorAll('[data-filter]')) filter.setAttribute('aria-pressed', String(filter.dataset.filter === 'all')); showQueue(); const current = list.querySelector('[aria-current="page"]'); current?.scrollIntoView?.({ block: 'nearest', behavior: typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' }); current?.focus({ preventScroll: true }); }; for (const link of ui.querySelectorAll('[data-step]')) link.onclick = event => { const id = event.currentTarget.dataset.targetNode; const node = queue.find(item => String(item.id) === id); openResourceFromClick(event, node, queueCourse); }; ui.addEventListener('keydown', event => { if (event.key !== 'Escape') return; event.preventDefault(); collapsed = true; setCollapsed(); persist(); launcher.focus(); }); function changeSettings(change) { if (auth.state !== 'ready' || !route() || courseInfo.state !== 'ready') return; updateDocumentClock(running && !hasOpenDialog()); if (nextTimer || advancing) { generation++; clearTimeout(nextTimer); nextTimer = null; advancing = false; } change(); skipCompleted = settings.skipCompleted; saveSettings(); syncSettingsInputs(); find('[data-config-error]').textContent = ''; if (running) { const node = currentNode(); if (node && !isVideo(node)) processDocument(); else if (video?.ended) scheduleNext(video); } else report('学习设置已保存到当前课程。'); updateView(); place(); } for (const button of ui.querySelectorAll('[data-learning-mode]')) { button.onclick = () => changeSettings(() => { settings.mode = button.dataset.learningMode; }); } find('[data-default-minutes]').onchange = event => { if (!validMinutes(event.target.value)) { find('[data-config-error]').textContent = '请输入 2 至 1440 的整数分钟数。'; return; } changeSettings(() => { settings.documentMinutes = Number(event.target.value); }); }; find('[data-resource-minutes]').onchange = event => { const node = currentNode(); if (!node || isVideo(node)) return; const value = event.target.value; if (value !== '' && !validMinutes(value)) { find('[data-config-error]').textContent = '请输入 2 至 1440 的整数分钟数,留空使用课程默认。'; return; } changeSettings(() => { if (value === '') delete settings.resourceMinutes[String(node.id)]; else settings.resourceMinutes[String(node.id)] = Number(value); }); }; find('[data-reset-resource]').onclick = () => { const node = currentNode(); if (node) changeSettings(() => { delete settings.resourceMinutes[String(node.id)]; }); }; skipInput.onchange = () => changeSettings(() => { settings.skipCompleted = skipInput.checked; }); muteInput.onchange = () => { muted = muteInput.checked; persist(); if (video) video.muted = muted; }; backgroundInput.onchange = () => { background = backgroundInput.checked; backgroundIntent = background && running && !!video && !video.paused && document.hidden; backgroundAttempts = 0; lastBackgroundAttempt = 0; if (!background) { clearTimeout(backgroundTimer); backgroundTimer = null; } persist(); }; report(running ? '正在加载学习资料…' : '进入课程后自动检测,点击主按钮开始学习。'); window.addEventListener('pagehide', () => { updateDocumentClock(running); if (documentClock) documentClock.lastAt = null; clearTimeout(nextTimer); clearTimeout(backgroundTimer); detachVideo(); }); window.addEventListener('pageshow', tick); setInterval(tick, 1000); setTab(activeTab, false); showQueue(); tick(); })();