您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
课堂习题提示,AI解答习题
// ==UserScript== // @name AI雨课堂助手(模块化构建版) // @namespace https://github.com/ZaytsevZY/yuketang-helper-auto // @version 1.17.2-mod // @description 课堂习题提示,AI解答习题 // @license MIT // @icon https://www.google.com/s2/favicons?sz=64&domain=yuketang.cn // @match https://*.yuketang.cn/lesson/fullscreen/v3/* // @match https://*.yuketang.cn/v2/web/* // @match https://www.yuketang.cn/lesson/fullscreen/v3/* // @match https://www.yuketang.cn/v2/web/* // @match https://pro.yuketang.cn/lesson/fullscreen/v3/* // @match https://pro.yuketang.cn/v2/web/* // @grant GM_addStyle // @grant GM_notification // @grant GM_xmlhttpRequest // @grant GM_openInTab // @grant GM_getTab // @grant GM_getTabs // @grant GM_saveTab // @grant unsafeWindow // @run-at document-start // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/jspdf.umd.min.js // ==/UserScript== (function() { "use strict"; // src/core/env.js const gm = { notify(opt) { if (typeof window.GM_notification === "function") window.GM_notification(opt); }, addStyle(css) { if (typeof window.GM_addStyle === "function") window.GM_addStyle(css); else { const s = document.createElement("style"); s.textContent = css; document.head.appendChild(s); } }, xhr(opt) { if (typeof window.GM_xmlhttpRequest === "function") return window.GM_xmlhttpRequest(opt); throw new Error("GM_xmlhttpRequest is not available"); }, uw: window.unsafeWindow || window }; function loadScriptOnce(src) { return new Promise((resolve, reject) => { if ([ ...document.scripts ].some(s => s.src === src)) return resolve(); const s = document.createElement("script"); s.src = src; s.onload = () => resolve(); s.onerror = () => reject(new Error(`Failed to load: ${src}`)); document.head.appendChild(s); }); } async function ensureHtml2Canvas() { const w = gm.uw || window; // ★ 用页面 window if (typeof w.html2canvas === "function") return w.html2canvas; await loadScriptOnce("https://html2canvas.hertzen.com/dist/html2canvas.min.js"); const h2c = w.html2canvas?.default || w.html2canvas; if (typeof h2c === "function") return h2c; throw new Error("html2canvas 未正确加载"); } async function ensureJsPDF() { if (window.jspdf?.jsPDF) return window.jspdf; await loadScriptOnce("https://cdn.jsdelivr.net/npm/[email protected]/dist/jspdf.umd.min.js"); if (!window.jspdf?.jsPDF) throw new Error("jsPDF 未加载成功"); return window.jspdf; } function randInt(l, r) { return l + Math.floor(Math.random() * (r - l + 1)); } // src/core/types.js const PROBLEM_TYPE_MAP = { 1: "单选题", 2: "多选题", 3: "投票题", 4: "填空题", 5: "主观题" }; const DEFAULT_CONFIG = { notifyProblems: true, autoAnswer: false, autoAnswerDelay: 3e3, autoAnswerRandomDelay: 2e3, ai: { provider: "kimi", // ✅ 改为 kimi kimiApiKey: "", // ✅ 添加 kimi 专用字段 apiKey: "", // 保持兼容 endpoint: "https://api.moonshot.cn/v1/chat/completions", // ✅ Kimi API 端点 model: "moonshot-v1-8k", // ✅ 文本模型 visionModel: "moonshot-v1-8k-vision-preview", // ✅ 添加 Vision 模型配置 temperature: .3, maxTokens: 1e3 }, showAllSlides: false, maxPresentations: 5 }; // src/core/storage.js class StorageManager { constructor(prefix) { this.prefix = prefix; } get(key, dv = null) { try { const v = localStorage.getItem(this.prefix + key); return v ? JSON.parse(v) : dv; } catch { return dv; } } set(key, value) { localStorage.setItem(this.prefix + key, JSON.stringify(value)); } remove(key) { localStorage.removeItem(this.prefix + key); } getMap(key) { const arr = this.get(key, []); try { return new Map(arr); } catch { return new Map; } } setMap(key, map) { this.set(key, [ ...map ]); } alterMap(key, fn) { const m = this.getMap(key); fn(m); this.setMap(key, m); } } const storage = new StorageManager("ykt-helper:"); // src/state/repo.js const repo = { presentations: new Map, // id -> presentation slides: new Map, // slideId -> slide problems: new Map, // problemId -> problem problemStatus: new Map, // problemId -> {presentationId, slideId, startTime, endTime, done, autoAnswerTime, answering} encounteredProblems: [], // [{problemId, ...ref}] currentPresentationId: null, currentSlideId: null, currentLessonId: null, // 1.16.4:按课程分组存储课件(presentations-<lessonId>) setPresentation(id, data) { this.presentations.set(id, { id: id, ...data }); const key = this.currentLessonId ? `presentations-${this.currentLessonId}` : "presentations"; storage.alterMap(key, m => { m.set(id, data); // 仍然做容量裁剪(向后兼容) const max = storage.get("config", {})?.maxPresentations ?? 5; const excess = m.size - max; if (excess > 0) [ ...m.keys() ].slice(0, excess).forEach(k => m.delete(k)); }); }, upsertSlide(slide) { this.slides.set(slide.id, slide); }, upsertProblem(prob) { this.problems.set(prob.problemId, prob); }, pushEncounteredProblem(prob, slide, presentationId) { if (!this.encounteredProblems.some(p => p.problemId === prob.problemId)) this.encounteredProblems.push({ problemId: prob.problemId, problemType: prob.problemType, body: prob.body || `题目ID: ${prob.problemId}`, options: prob.options || [], blanks: prob.blanks || [], answers: prob.answers || [], slide: slide, presentationId: presentationId }); }, // 1.16.4:载入本课(按课程分组)在本地存储过的课件 loadStoredPresentations() { if (!this.currentLessonId) return; const key = `presentations-${this.currentLessonId}`; const stored = storage.getMap(key); for (const [id, data] of stored.entries()) this.setPresentation(id, data); } }; // src/ui/toast.js function toast(message, duration = 2e3) { const el = document.createElement("div"); el.textContent = message; el.style.cssText = `\n position: fixed; top: 20px; left: 50%; transform: translateX(-50%);\n background: rgba(0,0,0,.7); color: #fff; padding: 10px 20px;\n border-radius: 4px; z-index: 10000000; max-width: 80%;\n `; document.body.appendChild(el); setTimeout(() => { el.style.opacity = "0"; el.style.transition = "opacity .5s"; setTimeout(() => el.remove(), 500); }, duration); } var tpl$5 = '<div id="ykt-settings-panel" class="ykt-panel">\n <div class="panel-header">\n <h3>AI雨课堂助手设置</h3>\n <span class="close-btn" id="ykt-settings-close"><i class="fas fa-times"></i></span>\n </div>\n\n <div class="panel-body">\n <div class="settings-content">\n <div class="setting-group">\n <h4>AI配置</h4>\n \x3c!-- 将DeepSeek相关配置替换为Kimi --\x3e\n <div class="setting-item">\n <label for="kimi-api-key">Kimi API Key:</label>\n <input type="password" id="kimi-api-key" placeholder="输入您的 Kimi API Key">\n <small>从 <a href="https://platform.moonshot.cn/" target="_blank">Kimi开放平台</a> 获取</small>\n </div>\n </div>\n\n <div class="setting-group">\n <h4>自动作答设置</h4>\n <div class="setting-item">\n <label class="checkbox-label">\n <input type="checkbox" id="ykt-input-auto-answer">\n <span class="checkmark"></span>\n 启用自动作答\n </label>\n </div>\n <div class="setting-item">\n <label class="checkbox-label">\n <input type="checkbox" id="ykt-input-ai-auto-analyze">\n <span class="checkmark"></span>\n 打开 AI 页面时自动分析\n </label>\n <small>开启后,进入“AI 解答”面板即自动向 AI 询问当前题目</small>\n </div>\n <div class="setting-item">\n <label for="ykt-input-answer-delay">作答延迟时间 (秒):</label>\n <input type="number" id="ykt-input-answer-delay" min="1" max="60">\n <small>题目出现后等待多长时间开始作答</small>\n </div>\n <div class="setting-item">\n <label for="ykt-input-random-delay">随机延迟范围 (秒):</label>\n <input type="number" id="ykt-input-random-delay" min="0" max="30">\n <small>在基础延迟基础上随机增加的时间范围</small>\n </div>\n </div>\n\n <div class="setting-actions">\n <button id="ykt-btn-settings-save">保存设置</button>\n <button id="ykt-btn-settings-reset">重置为默认</button>\n </div>\n </div>\n </div>\n</div>\n'; let mounted$5 = false; let root$4; function mountSettingsPanel() { if (mounted$5) return root$4; root$4 = document.createElement("div"); root$4.innerHTML = tpl$5; document.body.appendChild(root$4.firstElementChild); root$4 = document.getElementById("ykt-settings-panel"); // 初始化表单 const $api = root$4.querySelector("#kimi-api-key"); const $auto = root$4.querySelector("#ykt-input-auto-answer"); const $autoAnalyze = root$4.querySelector("#ykt-input-ai-auto-analyze"); const $delay = root$4.querySelector("#ykt-input-answer-delay"); const $rand = root$4.querySelector("#ykt-input-random-delay"); $api.value = ui.config.ai.kimiApiKey || ""; $auto.checked = !!ui.config.autoAnswer; $autoAnalyze.checked = !!ui.config.aiAutoAnalyze; $delay.value = Math.floor(ui.config.autoAnswerDelay / 1e3); $rand.value = Math.floor(ui.config.autoAnswerRandomDelay / 1e3); root$4.querySelector("#ykt-settings-close").addEventListener("click", () => showSettingsPanel(false)); root$4.querySelector("#ykt-btn-settings-save").addEventListener("click", () => { ui.config.ai.kimiApiKey = $api.value.trim(); ui.config.autoAnswer = !!$auto.checked; ui.config.aiAutoAnalyze = !!$autoAnalyze.checked; ui.config.autoAnswerDelay = Math.max(1e3, (+$delay.value || 0) * 1e3); ui.config.autoAnswerRandomDelay = Math.max(0, (+$rand.value || 0) * 1e3); storage.set("kimiApiKey", ui.config.ai.kimiApiKey); ui.saveConfig(); ui.updateAutoAnswerBtn(); ui.toast("设置已保存"); }); root$4.querySelector("#ykt-btn-settings-reset").addEventListener("click", () => { if (!confirm("确定要重置为默认设置吗?")) return; Object.assign(ui.config, DEFAULT_CONFIG); ui.config.ai.kimiApiKey = ""; ui.config.aiAutoAnalyze = !!(DEFAULT_CONFIG.aiAutoAnalyze ?? false); storage.set("kimiApiKey", ""); ui.saveConfig(); ui.updateAutoAnswerBtn(); $api.value = ""; $auto.checked = DEFAULT_CONFIG.autoAnswer; $delay.value = Math.floor(DEFAULT_CONFIG.autoAnswerDelay / 1e3); $rand.value = Math.floor(DEFAULT_CONFIG.autoAnswerRandomDelay / 1e3); $autoAnalyze.checked = !!(DEFAULT_CONFIG.aiAutoAnalyze ?? false); ui.toast("设置已重置"); }); mounted$5 = true; return root$4; } function showSettingsPanel(visible = true) { mountSettingsPanel(); const panel = document.getElementById("ykt-settings-panel"); if (!panel) return; panel.classList.toggle("visible", !!visible); } function toggleSettingsPanel() { mountSettingsPanel(); const panel = document.getElementById("ykt-settings-panel"); showSettingsPanel(!panel.classList.contains("visible")); } var tpl$4 = '<div id="ykt-ai-answer-panel" class="ykt-panel">\n <div class="panel-header">\n <h3><i class="fas fa-robot"></i> AI 解答</h3>\n <span id="ykt-ai-close" class="close-btn" title="关闭">\n <i class="fas fa-times"></i>\n </span>\n </div>\n <div class="panel-body">\n <div class="ai-question-wrap">\n <textarea id="ykt-ai-question-input" class="ai-question" placeholder="在此编辑要询问 AI 的题目…"></textarea>\n <div class="ai-question-hint" style="font-size:12px;color:#666;margin-top:6px;">\n 提示:可直接编辑上面的题面再点击“向 AI 询问当前题目”或启用自动分析。\n </div>\n </div>\n <div id="ykt-ai-loading" class="ai-loading" style="display: none;">\n <i class="fas fa-spinner fa-spin"></i> AI正在分析...\n </div>\n <div id="ykt-ai-error" class="ai-error" style="display: none;"></div>\n <div id="ykt-ai-answer" class="ai-answer"></div>\n <div class="ai-actions">\n <button id="ykt-ai-ask">向 AI 询问当前题目</button>\n <button id="ykt-ai-ask-vision">Vision模式分析</button>\n </div>\n </div>\n</div>'; // src/tsm/ai-format.js function formatProblemForAI(problem, TYPE_MAP) { let q = `请回答以下${TYPE_MAP[problem.problemType] || "题目"},按照格式回复:先给出答案,然后给出解释。\n\n题目:${problem.body || ""}`; if (problem.options?.length) { q += "\n选项:"; for (const o of problem.options) q += `\n${o.key}. ${o.value}`; } q += `\n\n请按照以下格式回答:\n答案: [你的答案]\n解释: [详细解释]\n\n注意:\n- 单选题和投票题请回答选项字母\n- 多选题请回答多个选项字母\n- 填空题请直接给出答案内容\n- 主观题请给出完整回答`; return q; } function formatProblemForDisplay(problem, TYPE_MAP) { let s = `${TYPE_MAP[problem.problemType] || "题目"}:${problem.body || ""}`; if (problem.options?.length) { s += "\n\n选项:"; for (const o of problem.options) s += `\n${o.key}. ${o.value}`; } return s; } function parseAIAnswer(problem, aiAnswer) { try { const lines = String(aiAnswer || "").split("\n"); let answerLine = ""; for (const line of lines) if (line.includes("答案:") || line.includes("答案:")) { answerLine = line.replace(/答案[::]\s*/, "").trim(); break; } if (!answerLine) answerLine = lines[0]?.trim() || ""; switch (problem.problemType) { case 1: break; // 单选 case 3: { // 投票 let m = answerLine.match(/[ABCD]/i); if (m) return [ m[0].toUpperCase() ]; m = answerLine.match(/[A-Za-z]/); if (m) return [ m[0].toUpperCase() ]; return null; } case 2: { // 多选 let ms = answerLine.match(/[ABCD]/gi); if (ms?.length) return [ ...new Set(ms.map(x => x.toUpperCase())) ].sort(); ms = answerLine.match(/[A-Za-z]/g); if (ms?.length) return [ ...new Set(ms.map(x => x.toUpperCase())) ].sort(); return null; } case 4: { // 填空 const blanks = answerLine.split(/[,,\s]+/).filter(Boolean); return blanks.length ? blanks : null; } case 5: // 主观 return { content: answerLine, pics: [] }; default: return null; } } catch (e) { console.error("[parseAIAnswer] failed", e); return null; } } // src/ai/kimi.js /** * 调用 Kimi 文本模型 * @param {string} question 题目内容 * @param {Object} aiCfg AI配置 * @returns {Promise<string>} AI回答 */ async function queryKimi(question, aiCfg) { const apiKey = aiCfg.kimiApiKey; if (!apiKey) throw new Error("请先配置 Kimi API Key"); return new Promise((resolve, reject) => { gm.xhr({ method: "POST", url: "https://api.moonshot.cn/v1/chat/completions", headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, data: JSON.stringify({ model: "moonshot-v1-8k", // ✅ 文本模型 messages: [ { role: "system", content: "你是 Kimi,由 Moonshot AI 提供的人工智能助手。请简洁准确地回答用户的问题,特别是选择题请直接给出答案选项。" }, { role: "user", content: question } ], temperature: .6 }), onload: res => { try { console.log("[Kimi API] Status:", res.status); console.log("[Kimi API] Response:", res.responseText); if (res.status !== 200) { reject(new Error(`Kimi API 请求失败: ${res.status}`)); return; } const data = JSON.parse(res.responseText); const content = data.choices?.[0]?.message?.content; if (content) resolve(content); else reject(new Error("AI返回内容为空")); } catch (e) { reject(new Error(`解析API响应失败: ${e.message}`)); } }, onerror: () => reject(new Error("网络请求失败")), timeout: 3e4 }); }); } /** * 调用 Kimi Vision模型(图像+文本) * @param {string} imageBase64 图像的base64编码 * @param {string} textPrompt 文本提示(可包含题干) * @param {Object} aiCfg AI配置 * @returns {Promise<string>} AI回答 */ async function queryKimiVision(imageBase64, textPrompt, aiCfg) { const apiKey = aiCfg.kimiApiKey; if (!apiKey) throw new Error("请先配置 Kimi API Key"); // ✅ 检查图像数据格式 if (!imageBase64 || typeof imageBase64 !== "string") throw new Error("图像数据格式错误"); // ✅ 确保 base64 数据格式正确 const cleanBase64 = imageBase64.replace(/^data:image\/[^;]+;base64,/, ""); // ✅ 按照文档要求构建消息格式 const messages = [ { role: "system", content: "你是 Kimi,由 Moonshot AI 提供的人工智能助手,你更擅长中文和英文的对话。你会为用户提供安全,有帮助,准确的回答。同时,你会拒绝一切涉及恐怖主义,种族歧视,黄色暴力等问题的回答。Moonshot AI 为专有名词,不可翻译成其他语言。" }, { role: "user", content: [ { type: "image_url", image_url: { url: `data:image/png;base64,${cleanBase64}` } }, { type: "text", text: textPrompt || "请分析图片中的题目并给出答案" } ] } ]; return new Promise((resolve, reject) => { console.log("[Kimi Vision] 发送请求..."); console.log("[Kimi Vision] 模型: moonshot-v1-8k-vision-preview"); console.log("[Kimi Vision] 图片数据长度:", cleanBase64.length); gm.xhr({ method: "POST", url: "https://api.moonshot.cn/v1/chat/completions", headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, data: JSON.stringify({ model: "moonshot-v1-8k-vision-preview", // ✅ 使用 Vision 专用模型 messages: messages, temperature: .3 }), onload: res => { try { console.log("[Kimi Vision] Status:", res.status); console.log("[Kimi Vision] Response:", res.responseText); if (res.status !== 200) { // ✅ 提供更详细的错误信息 let errorMessage = `Kimi Vision API 请求失败: ${res.status}`; try { const errorData = JSON.parse(res.responseText); if (errorData.error?.message) errorMessage += ` - ${errorData.error.message}`; if (errorData.error?.code) errorMessage += ` (${errorData.error.code})`; } catch (e) { errorMessage += ` - ${res.responseText}`; } reject(new Error(errorMessage)); return; } const data = JSON.parse(res.responseText); const content = data.choices?.[0]?.message?.content; if (content) { console.log("[Kimi Vision] 成功获取回答"); resolve(content); } else reject(new Error("AI返回内容为空")); } catch (e) { console.error("[Kimi Vision] 解析响应失败:", e); reject(new Error(`解析API响应失败: ${e.message}`)); } }, onerror: err => { console.error("[Kimi Vision] 网络请求失败:", err); reject(new Error("网络请求失败")); }, timeout: 6e4 }); }); } var kimi = Object.freeze({ __proto__: null, queryKimi: queryKimi, queryKimiVision: queryKimiVision }); // src/tsm/answer.js // Refactored from v1.16.1 userscript to module style. // Exposes three primary APIs: // - answerProblem(problem, result, options) // - retryAnswer(problem, result, dt, options) // - submitAnswer(problem, result, submitOptions) // orchestrates answer vs retry // Differences vs userscript: // - No global UI (confirm/Toast). Callers control UX. // - Uses options to pass deadline window and behavior flags. // - Allows header overrides for testing and non-browser envs. const DEFAULT_HEADERS = () => ({ "Content-Type": "application/json", xtbz: "ykt", "X-Client": "h5", Authorization: "Bearer " + (typeof localStorage !== "undefined" ? localStorage.getItem("Authorization") : "") }); /** * Low-level POST helper using XMLHttpRequest to align with site requirements. * @param {string} url * @param {object} data * @param {Record<string,string>} headers * @returns {Promise<any>} */ function xhrPost(url, data, headers) { return new Promise((resolve, reject) => { try { const xhr = new XMLHttpRequest; xhr.open("POST", url); for (const [k, v] of Object.entries(headers || {})) xhr.setRequestHeader(k, v); xhr.onload = () => { try { const resp = JSON.parse(xhr.responseText); if (resp && typeof resp === "object") resolve(resp); else reject(new Error("解析响应失败")); } catch { reject(new Error("解析响应失败")); } }; xhr.onerror = () => reject(new Error("网络请求失败")); xhr.send(JSON.stringify(data)); } catch (e) { reject(e); } }); } /** * POST /api/v3/lesson/problem/answer * Mirrors the 1.16.1 logic (no UI). Returns {code, data, msg, ...} on success code===0. * @param {{problemId:number, problemType:number}} problem * @param {any} result * @param {{headers?:Record<string,string>, dt?:number}} [options] */ async function answerProblem(problem, result, options = {}) { const url = "/api/v3/lesson/problem/answer"; const headers = { ...DEFAULT_HEADERS(), ...options.headers || {} }; const payload = { problemId: problem.problemId, problemType: problem.problemType, dt: options.dt ?? Date.now(), result: result }; const resp = await xhrPost(url, payload, headers); if (resp.code === 0) return resp; throw new Error(`${resp.msg} (${resp.code})`); } /** * POST /api/v3/lesson/problem/retry * Expects server to echo success ids in data.success (as in v1.16.1). * @param {{problemId:number, problemType:number}} problem * @param {any} result * @param {number} dt - simulated answer time (epoch ms) * @param {{headers?:Record<string,string>}} [options] */ async function retryAnswer(problem, result, dt, options = {}) { const url = "/api/v3/lesson/problem/retry"; const headers = { ...DEFAULT_HEADERS(), ...options.headers || {} }; const payload = { problems: [ { problemId: problem.problemId, problemType: problem.problemType, dt: dt, result: result } ] }; const resp = await xhrPost(url, payload, headers); if (resp.code !== 0) throw new Error(`${resp.msg} (${resp.code})`); const okList = resp?.data?.success || []; if (!Array.isArray(okList) || !okList.includes(problem.problemId)) throw new Error("服务器未返回成功信息"); return resp; } /** * High-level orchestrator: answer first; if deadline has passed, optionally retry. * This is the module adaptation of the 1.16.1 userscript submit flow. * * @param {{problemId:number, problemType:number}} problem * @param {any} result * @param {Object} submitOptions * @param {number} [submitOptions.startTime] - unlock time (epoch ms). Required for retry path. * @param {number} [submitOptions.endTime] - deadline (epoch ms). If now >= endTime -> retry path. * @param {boolean} [submitOptions.forceRetry=false] - when past deadline, directly use retry without prompting. * @param {number} [submitOptions.retryDtOffsetMs=2000] - dt = startTime + offset when retrying. * @param {Record<string,string>} [submitOptions.headers] - extra/override headers. * @returns {Promise<{'route':'answer'|'retry', resp:any}>} */ async function submitAnswer(problem, result, submitOptions = {}) { const {startTime: startTime, endTime: endTime, forceRetry: forceRetry = false, retryDtOffsetMs: retryDtOffsetMs = 2e3, headers: headers} = submitOptions; const now = Date.now(); const pastDeadline = typeof endTime === "number" && now >= endTime; if (pastDeadline) { if (!forceRetry) { const err = new Error("DEADLINE_PASSED"); err.name = "DeadlineError"; err.details = { startTime: startTime, endTime: endTime, now: now }; throw err; } const base = typeof startTime === "number" ? startTime : now - retryDtOffsetMs; const dt = base + retryDtOffsetMs; const resp = await retryAnswer(problem, result, dt, { headers: headers }); return { route: "retry", resp: resp }; } const resp = await answerProblem(problem, result, { headers: headers, dt: now }); return { route: "answer", resp: resp }; } // src/ui/auto-answer-popup.js // 简单 HTML 转义,避免把题目中的 <> 等插入为标签 function esc(s) { return String(s).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } /** * 显示自动作答成功弹窗 * @param {object} problem - 题目对象 * @param {string} aiAnswer - 原始 AI 文本(未解析前) * @param {object} [cfg] - 可选配置(用于局部覆写) */ function showAutoAnswerPopup(problem, aiAnswer, cfg = {}) { // 避免重复 const existed = document.getElementById("ykt-auto-answer-popup"); if (existed) existed.remove(); const popup = document.createElement("div"); popup.id = "ykt-auto-answer-popup"; popup.className = "auto-answer-popup"; // 模块版签名:需要传 TYPE_MAP const questionText = formatProblemForDisplay(problem, ui.config && ui.config.TYPE_MAP || {}); // 采用“全屏遮罩 + 内部卡片”的结构,外层用于点击关闭 popup.innerHTML = `\n <div class="popup-content">\n <div class="popup-header">\n <h4><i class="fas fa-robot"></i> AI自动作答成功</h4>\n <span class="close-btn" title="关闭"><i class="fas fa-times"></i></span>\n </div>\n <div class="popup-body">\n <div class="popup-row popup-question">\n <div class="label">题目:</div>\n <div class="content">${esc(questionText).replace(/\n/g, "<br>")}</div>\n </div>\n <div class="popup-row popup-answer">\n <div class="label">AI回答:</div>\n <div class="content">${esc(aiAnswer || "").replace(/\n/g, "<br>")}</div>\n </div>\n </div>\n </div>\n `; document.body.appendChild(popup); // 关闭按钮 popup.querySelector(".close-btn")?.addEventListener("click", () => popup.remove()); // 点击遮罩关闭(只在点击外层时才关闭) popup.addEventListener("click", e => { if (e.target === popup) popup.remove(); }); // 自动关闭 const ac = ui.config && ui.config.autoAnswerPopup || {}; const autoClose = cfg.autoClose ?? ac.autoClose ?? true; const autoDelay = cfg.autoCloseDelay ?? ac.autoCloseDelay ?? 4e3; if (autoClose) setTimeout(() => { if (popup.parentNode) popup.remove(); }, autoDelay); // 入场动画 requestAnimationFrame(() => popup.classList.add("visible")); } // src/capture/screenshot.js async function captureProblemScreenshot() { try { const html2canvas = await ensureHtml2Canvas(); const el = document.querySelector(".ques-title") || document.querySelector(".problem-body") || document.querySelector(".ppt-inner") || document.querySelector(".ppt-courseware-inner") || document.body; return await html2canvas(el, { useCORS: true, allowTaint: false, backgroundColor: "#ffffff", // ✅ 设置背景色 scale: 1, // ✅ 设置缩放比例 width: Math.min(el.scrollWidth, 1200), // ✅ 限制宽度 height: Math.min(el.scrollHeight, 800) }); } catch (e) { console.error("[captureProblemScreenshot] failed", e); return null; } } // 新增:获取问题页面截图的base64数据,供Vision API使用 async function captureProblemForVision() { try { console.log("[captureProblemForVision] 开始截图..."); const canvas = await captureProblemScreenshot(); if (!canvas) { console.error("[captureProblemForVision] 截图失败"); return null; } console.log("[captureProblemForVision] 截图成功,转换为base64..."); // ✅ 转换为 JPEG 格式以减小文件大小 const base64 = canvas.toDataURL("image/jpeg", .8).split(",")[1]; console.log("[captureProblemForVision] base64 长度:", base64.length); // ✅ 检查图片大小,如果太大则压缩 if (base64.length > 1e6) { // 1MB console.log("[captureProblemForVision] 图片过大,进行压缩..."); // 重新生成更小的图片 const smallerBase64 = canvas.toDataURL("image/jpeg", .5).split(",")[1]; console.log("[captureProblemForVision] 压缩后长度:", smallerBase64.length); return smallerBase64; } return base64; } catch (e) { console.error("[captureProblemForVision] failed", e); return null; } } var screenshoot = Object.freeze({ __proto__: null, captureProblemForVision: captureProblemForVision, captureProblemScreenshot: captureProblemScreenshot }); let mounted$4 = false; let root$3; function $$4(sel) { return document.querySelector(sel); } function mountAIPanel() { if (mounted$4) return root$3; const host = document.createElement("div"); host.innerHTML = tpl$4; document.body.appendChild(host.firstElementChild); root$3 = document.getElementById("ykt-ai-answer-panel"); // 关闭面板 $$4("#ykt-ai-close")?.addEventListener("click", () => showAIPanel(false)); // 手动点击按钮触发 AI 分析 $$4("#ykt-ai-ask")?.addEventListener("click", askAIForCurrent); // Vision 模式按钮 $$4("#ykt-ai-ask-vision")?.addEventListener("click", askAIVisionForCurrent); mounted$4 = true; return root$3; } window.addEventListener("ykt:open-ai", () => { showAIPanel(true); // 打开面板 }); function showAIPanel(visible = true) { mountAIPanel(); root$3.classList.toggle("visible", !!visible); if (visible) { renderQuestion(); // 自动分析:只有开关打开时才调用 if (ui.config.aiAutoAnalyze) queueMicrotask(() => { askAIForCurrent(); }); } // 同步工具栏按钮状态 const aiBtn = document.getElementById("ykt-btn-ai"); if (aiBtn) aiBtn.classList.toggle("active", !!visible); } function setAILoading(v) { mountAIPanel(); $$4("#ykt-ai-loading").style.display = v ? "" : "none"; } function setAIError(msg = "") { mountAIPanel(); const el = $$4("#ykt-ai-error"); el.style.display = msg ? "" : "none"; el.textContent = msg || ""; } function setAIAnswer(content = "") { mountAIPanel(); $$4("#ykt-ai-answer").textContent = content || ""; } function renderQuestion() { const p = repo.currentSlideId ? repo.slides.get(repo.currentSlideId)?.problem : null; const problem = p || (repo.encounteredProblems.at(-1) ? repo.problems.get(repo.encounteredProblems.at(-1).problemId) : null); const text = problem ? formatProblemForDisplay(problem, ui.config.TYPE_MAP || {}) : "未选择题目"; const el = document.querySelector("#ykt-ai-question-input"); if (el) // 若用户已经编辑过则不覆盖;首次为空时才灌入默认题面 if (!el.value.trim()) el.value = text; } function getEditedQuestion() { const el = document.querySelector("#ykt-ai-question-input"); const v = el ? el.value.trim() : ""; return v; } // 新增:使用Vision模式询问AI // 在 askAIVisionForCurrent 函数中添加更多调试信息 async function askAIVisionForCurrent() { const slide = repo.currentSlideId ? repo.slides.get(repo.currentSlideId) : null; const problem = slide?.problem || (repo.encounteredProblems.at(-1) ? repo.problems.get(repo.encounteredProblems.at(-1).problemId) : null); setAIError(""); setAILoading(true); setAIAnswer(""); try { // 1. 检查 API Key if (!ui.config.ai.kimiApiKey) throw new Error("请先在设置中配置 Kimi API Key"); // 2. 截取当前页面图像 ui.toast("正在截取页面图像...", 2e3); console.log("[Vision] 开始截图..."); const imageBase64 = await captureProblemForVision(); if (!imageBase64) throw new Error("无法截取页面图像,请确保页面内容已加载完成"); console.log("[Vision] 截图完成,图像大小:", imageBase64.length); // 3. 准备文本提示 const edited = getEditedQuestion(); let textPrompt = edited && edited.length > 0 ? `请分析图片并结合以下用户输入的题目信息作答。用户输入的题目信息是:\n\n${edited}\n\n请按“答案/解释”的格式返回。` : "请分析图片中的题目并给出答案。按照以下格式回答:\n答案: [你的答案]\n解释: [详细解释]"; if (!edited && problem && problem.body) { const problemText = formatProblemForAI(problem, ui.config.TYPE_MAP || {}); textPrompt = `请结合以下题目信息分析图片:\n\n${problemText}\n\n请仔细观察图片内容,给出准确答案。`; } // 4. 调用Vision API ui.toast("正在使用Vision模式分析...", 3e3); console.log("[Vision] 调用API..."); const aiContent = await queryKimiVision(imageBase64, textPrompt, ui.config.ai); setAILoading(false); console.log("[Vision] API调用成功"); setAIAnswer(`Vision模式回答:\n${aiContent}`); // 5. 如果有题目对象,尝试解析答案并提供提交按钮 if (problem) { const parsed = parseAIAnswer(problem, aiContent); if (parsed) { setAIAnswer(`Vision模式回答:\n${aiContent}\n\nAI 建议答案:${JSON.stringify(parsed)}`); const submitBtn = document.createElement("button"); submitBtn.textContent = "提交答案"; submitBtn.onclick = async () => { try { await submitAnswer(problem, parsed); ui.toast("提交成功"); showAutoAnswerPopup(problem, aiContent); } catch (e) { ui.toast(`提交失败: ${e.message}`); } }; $$4("#ykt-ai-answer").appendChild(document.createElement("br")); $$4("#ykt-ai-answer").appendChild(submitBtn); } else setAIAnswer(`Vision模式回答:\n${aiContent}\n\n注意:无法自动解析答案格式,请手动查看上述回答。`); } } catch (e) { setAILoading(false); console.error("[Vision] 完整错误信息:", e); // ✅ 提供降级建议 let errorMsg = `Vision模式失败: ${e.message}`; if (e.message.includes("400")) errorMsg += "\n\n可能的解决方案:\n1. 检查 API Key 是否正确\n2. 尝试刷新页面后重试\n3. 使用普通文本模式"; setAIError(errorMsg); } } // 修改原有的askAIForCurrent,保持兼容 async function askAIForCurrent() { const slide = repo.currentSlideId ? repo.slides.get(repo.currentSlideId) : null; const problem = slide?.problem || (repo.encounteredProblems.at(-1) ? repo.problems.get(repo.encounteredProblems.at(-1).problemId) : null); const edited = getEditedQuestion(); // ← 读取用户编辑内容 // 若没有题面文本(用户也没编辑)且无法拿到 problem,则自动切到 Vision 模式 if (!edited && (!problem || !problem.body)) { ui.toast("未检测到题目文本,自动使用Vision模式", 2e3); return askAIVisionForCurrent(); } setAIError(""); setAILoading(true); setAIAnswer(""); try { // 1) 构造要问 AI 的文本:优先使用“用户编辑”的题面 const q = edited || formatProblemForAI(problem, ui.config.TYPE_MAP || {}); // 2) 请求 const aiContent = await queryKimi(q, ui.config.ai); // 3) 若有 problem,尝试解析并提供“提交答案” let parsed = null; if (problem) parsed = parseAIAnswer(problem, aiContent); setAILoading(false); if (parsed) { setAIAnswer(`AI 建议答案:${JSON.stringify(parsed)}`); const submitBtn = document.createElement("button"); submitBtn.textContent = "提交答案"; submitBtn.onclick = async () => { try { await submitAnswer(problem, parsed); ui.toast("提交成功"); showAutoAnswerPopup(problem, typeof aiContent === "string" ? aiContent : JSON.stringify(aiContent, null, 2)); } catch (e) { ui.toast(`提交失败: ${e.message}`); } }; document.querySelector("#ykt-ai-answer").appendChild(document.createElement("br")); document.querySelector("#ykt-ai-answer").appendChild(submitBtn); } else // 无法解析就直接把原文显示给用户 setAIAnswer(typeof aiContent === "string" ? aiContent : JSON.stringify(aiContent, null, 2)); } catch (e) { setAILoading(false); setAIError(e.message); } } var tpl$3 = '<div id="ykt-presentation-panel" class="ykt-panel">\n <div class="panel-header">\n <h3>课件浏览</h3>\n <div class="panel-controls">\n <label>\n <input type="checkbox" id="ykt-show-all-slides"> 切换全部页面/问题页面\n </label>\n <button id="ykt-open-problem-list">题目列表</button>\n <button id="ykt-download-current">截图下载</button>\n <button id="ykt-download-pdf">整册下载(PDF)</button>\n <span class="close-btn" id="ykt-presentation-close"><i class="fas fa-times"></i></span>\n </div>\n </div>\n\n <div class="panel-body">\n <div class="panel-left">\n <div id="ykt-presentation-list" class="presentation-list"></div>\n </div>\n <div class="panel-right">\n <div id="ykt-slide-view" class="slide-view">\n <div class="slide-cover">\n <div class="empty-message">选择左侧的幻灯片查看详情</div>\n </div>\n <div id="ykt-problem-view" class="problem-view"></div>\n </div>\n </div>\n </div>\n</div>\n'; let mounted$3 = false; let host; function $$3(sel) { return document.querySelector(sel); } function mountPresentationPanel() { if (mounted$3) return host; const wrapper = document.createElement("div"); wrapper.innerHTML = tpl$3; document.body.appendChild(wrapper.firstElementChild); host = document.getElementById("ykt-presentation-panel"); $$3("#ykt-presentation-close")?.addEventListener("click", () => showPresentationPanel(false)); $$3("#ykt-open-problem-list")?.addEventListener("click", () => { showPresentationPanel(false); window.dispatchEvent(new CustomEvent("ykt:open-problem-list")); }); $$3("#ykt-download-current")?.addEventListener("click", downloadCurrentSlide); $$3("#ykt-download-pdf")?.addEventListener("click", downloadPresentationPDF); const cb = $$3("#ykt-show-all-slides"); cb.checked = !!ui.config.showAllSlides; cb.addEventListener("change", () => { ui.config.showAllSlides = !!cb.checked; ui.saveConfig(); updatePresentationList(); }); mounted$3 = true; return host; } // 在 showPresentationPanel 函数中添加按钮状态同步 function showPresentationPanel(visible = true) { mountPresentationPanel(); host.classList.toggle("visible", !!visible); if (visible) updatePresentationList(); // 同步工具栏按钮状态 const presBtn = document.getElementById("ykt-btn-pres"); if (presBtn) presBtn.classList.toggle("active", !!visible); } // export function updatePresentationList() { // mountPresentationPanel(); // const list = $('#ykt-presentation-list'); // list.innerHTML = ''; // const showAll = !!ui.config.showAllSlides; // const presEntries = [...repo.presentations.values()].slice(-ui.config.maxPresentations); // presEntries.forEach((pres) => { // const item = document.createElement('div'); // item.className = 'presentation-item'; // const title = document.createElement('div'); // title.className = 'presentation-title'; // title.textContent = pres.title || `课件 ${pres.id}`; // item.appendChild(title); // const slidesWrap = document.createElement('div'); // slidesWrap.className = 'slide-thumb-list'; // (pres.slides || []).forEach((s) => { // if (!showAll && !s.problem) return; // const thumb = document.createElement('div'); // thumb.className = 'slide-thumb'; // thumb.title = s.title || `第 ${s.page} 页`; // if (s.thumbnail) { // const img = document.createElement('img'); // img.src = s.thumbnail; // img.alt = thumb.title; // thumb.appendChild(img); // } else { // thumb.textContent = s.title || String(s.page ?? ''); // } // thumb.addEventListener('click', () => { // repo.currentPresentationId = pres.id; // repo.currentSlideId = s.id; // updateSlideView(); // }); // slidesWrap.appendChild(thumb); // }); // item.appendChild(slidesWrap); // list.appendChild(item); // }); // } //1.16.4 更新课件加载方法 function updatePresentationList() { mountPresentationPanel(); const listEl = document.getElementById("ykt-presentation-list"); if (!listEl) return; listEl.innerHTML = ""; if (repo.presentations.size === 0) { listEl.innerHTML = '<p class="no-presentations">暂无课件记录</p>'; return; } // 只显示当前课程的课件(基于 URL 与 repo.currentLessonId 过滤) const currentPath = window.location.pathname; const m = currentPath.match(/\/lesson\/fullscreen\/v3\/([^/]+)/); const currentLessonFromURL = m ? m[1] : null; const filtered = new Map; for (const [id, presentation] of repo.presentations) // 若 URL 和 repo 同时能取到 lessonId,则要求一致 if (currentLessonFromURL && repo.currentLessonId && currentLessonFromURL === repo.currentLessonId) filtered.set(id, presentation); else if (!currentLessonFromURL) // 向后兼容:无法从 URL 提取课程 ID 时,展示全部 filtered.set(id, presentation); else if (currentLessonFromURL === repo.currentLessonId) filtered.set(id, presentation); const presentationsToShow = filtered.size > 0 ? filtered : repo.presentations; for (const [id, presentation] of presentationsToShow) { const cont = document.createElement("div"); cont.className = "presentation-container"; // 标题 + 下载按钮 const titleEl = document.createElement("div"); titleEl.className = "presentation-title"; titleEl.innerHTML = `\n <span>${presentation.title || `课件 ${id}`}</span>\n <i class="fas fa-download download-btn" title="下载课件"></i>\n `; cont.appendChild(titleEl); // 下载按钮 titleEl.querySelector(".download-btn")?.addEventListener("click", e => { e.stopPropagation(); downloadPresentation(presentation); }); // 幻灯片缩略图区域 const slidesWrap = document.createElement("div"); slidesWrap.className = "slide-thumb-list"; // 是否显示全部页 const showAll = !!ui.config.showAllSlides; const slidesToShow = showAll ? presentation.slides || [] : (presentation.slides || []).filter(s => s.problem); for (const s of slidesToShow) { const thumb = document.createElement("div"); thumb.className = "slide-thumb"; // 当前高亮 if (s.id === repo.currentSlideId) thumb.classList.add("active"); // 状态样式:解锁 / 已作答 if (s.problem) { const pid = s.problem.problemId; const status = repo.problemStatus.get(pid); if (status) thumb.classList.add("unlocked"); if (s.problem.result) thumb.classList.add("answered"); } // 点击跳转 thumb.addEventListener("click", () => { actions.navigateTo(presentation.id, s.id); }); // 缩略图内容 const img = document.createElement("img"); if (presentation.width && presentation.height) img.style.aspectRatio = `${presentation.width}/${presentation.height}`; img.src = s.thumbnail || ""; img.alt = s.title || `第 ${s.page ?? ""} 页`; // 关键:图片加载失败时移除(可能非本章节的页) img.onerror = function() { if (thumb.parentNode) thumb.parentNode.removeChild(thumb); }; const idx = document.createElement("span"); idx.className = "slide-index"; idx.textContent = s.index ?? ""; thumb.appendChild(img); thumb.appendChild(idx); slidesWrap.appendChild(thumb); } cont.appendChild(slidesWrap); listEl.appendChild(cont); } } // 课件下载入口:切换当前课件后调用现有 PDF 导出逻辑 function downloadPresentation(presentation) { // 先切到该课件,再复用“整册下载(PDF)”按钮逻辑 repo.currentPresentationId = presentation.id; // 这里直接调用现有的 downloadPresentationPDF(定义在本文件尾部) // 若你希望仅下载题目页,可根据 ui.config.showAllSlides 控制 downloadPresentationPDF(); } function updateSlideView() { mountPresentationPanel(); const slideView = $$3("#ykt-slide-view"); const problemView = $$3("#ykt-problem-view"); slideView.querySelector(".slide-cover")?.classList.add("hidden"); problemView.innerHTML = ""; if (!repo.currentSlideId) { slideView.querySelector(".slide-cover")?.classList.remove("hidden"); return; } const slide = repo.slides.get(repo.currentSlideId); if (!slide) return; const cover = document.createElement("div"); cover.className = "slide-cover"; const img = document.createElement("img"); img.crossOrigin = "anonymous"; img.src = slide.image || slide.thumbnail || ""; img.alt = slide.title || ""; cover.appendChild(img); if (slide.problem) { const prob = slide.problem; const box = document.createElement("div"); box.className = "problem-box"; const head = document.createElement("div"); head.className = "problem-head"; head.textContent = prob.body || `题目 ${prob.problemId}`; box.appendChild(head); if (Array.isArray(prob.options) && prob.options.length) { const opts = document.createElement("div"); opts.className = "problem-options"; prob.options.forEach(o => { const li = document.createElement("div"); li.className = "problem-option"; li.textContent = `${o.key}. ${o.value}`; opts.appendChild(li); }); box.appendChild(opts); } problemView.appendChild(box); } slideView.innerHTML = ""; slideView.appendChild(cover); slideView.appendChild(problemView); } async function downloadCurrentSlide() { if (!repo.currentSlideId) return ui.toast("请先选择一页课件/题目"); const slide = repo.slides.get(repo.currentSlideId); if (!slide) return; try { const html2canvas = await ensureHtml2Canvas(); const el = document.getElementById("ykt-slide-view"); const canvas = await html2canvas(el, { useCORS: true, allowTaint: false }); const a = document.createElement("a"); a.download = `slide-${slide.id}.png`; a.href = canvas.toDataURL("image/png"); a.click(); } catch (e) { ui.toast(`截图失败: ${e.message}`); } } async function downloadPresentationPDF() { if (!repo.currentPresentationId) return ui.toast("请先在左侧选择一份课件"); const pres = repo.presentations.get(repo.currentPresentationId); if (!pres || !Array.isArray(pres.slides) || pres.slides.length === 0) return ui.toast("未找到该课件的页面"); // 是否导出全部页:沿用你面板的“切换全部/题目页”开关语义 const showAll = !!ui.config.showAllSlides; const slides = pres.slides.filter(s => showAll || s.problem); if (slides.length === 0) return ui.toast("当前筛选下没有可导出的页面"); try { // 1) 确保 jsPDF 就绪 await ensureJsPDF(); const {jsPDF: jsPDF} = window.jspdf || {}; if (!jsPDF) throw new Error("jsPDF 未加载成功"); // 2) A4 纸张(pt):595 x 842(竖版) const doc = new jsPDF({ unit: "pt", format: "a4", orientation: "portrait" }); const pageW = 595, pageH = 842; // 页边距(视觉更好看) const margin = 24; const maxW = pageW - margin * 2; const maxH = pageH - margin * 2; // 简单的图片加载器(拿到原始宽高以保持比例居中) const loadImage = src => new Promise((resolve, reject) => { const img = new Image; img.crossOrigin = "anonymous"; img.onload = () => resolve(img); img.onerror = reject; img.src = src; }); for (let i = 0; i < slides.length; i++) { const s = slides[i]; const url = s.image || s.thumbnail; if (!url) { // 无图页可跳过,也可在此尝试 html2canvas 截图(复杂度更高,此处先跳过) if (i > 0) doc.addPage(); continue; } // 3) 加载图片并按比例缩放到 A4 const img = await loadImage(url); const iw = img.naturalWidth || img.width; const ih = img.naturalHeight || img.height; const r = Math.min(maxW / iw, maxH / ih); const w = Math.floor(iw * r); const h = Math.floor(ih * r); const x = Math.floor((pageW - w) / 2); const y = Math.floor((pageH - h) / 2); // 4) 首页直接画,后续页先 addPage if (i > 0) doc.addPage(); // 通过 <img> 对象加图(jsPDF 自动推断类型;如需可改成 'PNG') doc.addImage(img, "PNG", x, y, w, h); } // 5) 文件名:保留课件标题或 id const name = (pres.title || `课件-${pres.id}`).replace(/[\\/:*?"<>|]/g, "_"); doc.save(`${name}.pdf`); } catch (e) { ui.toast(`导出 PDF 失败:${e.message || e}`); } } var tpl$2 = '<div id="ykt-problem-list-panel" class="ykt-panel">\n <div class="panel-header">\n <h3>课堂习题列表</h3>\n <span class="close-btn" id="ykt-problem-list-close"><i class="fas fa-times"></i></span>\n </div>\n\n <div class="panel-body">\n <div id="ykt-problem-list" class="problem-list">\n \x3c!-- 由 problem-list.js 动态填充:\n .problem-row\n .problem-title\n .problem-meta\n .problem-actions (查看 / AI解答 / 已作答) --\x3e\n </div>\n </div>\n</div>\n'; // ==== [ADD] 工具方法 & 取题接口(兼容旧版多端点) ==== function create(tag, cls) { const n = document.createElement(tag); if (cls) n.className = cls; return n; } const HEADERS = () => ({ "Content-Type": "application/json", xtbz: "ykt", "X-Client": "h5", Authorization: "Bearer " + (typeof localStorage !== "undefined" ? localStorage.getItem("Authorization") || "" : "") }); async function httpGet(url) { return new Promise((resolve, reject) => { try { const xhr = new XMLHttpRequest; xhr.open("GET", url, true); const h = HEADERS(); for (const k in h) xhr.setRequestHeader(k, h[k]); xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch { reject(new Error("解析响应失败")); } }; xhr.onerror = () => reject(new Error("网络失败")); xhr.send(); } catch (e) { reject(e); } }); } // 兼容旧版:依次尝试多个端点,先成功先用 async function fetchProblemDetail(problemId) { const candidates = [ `/api/v3/lesson/problem/detail?problemId=${problemId}`, `/api/v3/lesson/problem/get?problemId=${problemId}`, `/mooc-api/v1/lms/problem/detail?problem_id=${problemId}` ]; for (const url of candidates) try { const resp = await httpGet(url); if (resp && typeof resp === "object" && (resp.code === 0 || resp.success === true)) return resp; } catch (_) {/* try next */} throw new Error("无法获取题目信息"); } function pretty(obj) { try { return JSON.stringify(obj, null, 2); } catch { return String(obj); } } // ==== [ADD] 渲染行上的按钮(查看 / AI解答 / 刷新题目) ==== function bindRowActions(row, e, prob) { const actionsBar = row.querySelector(".problem-actions"); const btnGo = create("button"); btnGo.textContent = "查看"; btnGo.onclick = () => actions.navigateTo(e.presentationId, e.slide?.id || e.slideId); actionsBar.appendChild(btnGo); const btnAI = create("button"); btnAI.textContent = "AI解答"; btnAI.onclick = () => window.dispatchEvent(new CustomEvent("ykt:open-ai", { detail: { problemId: e.problemId } })); actionsBar.appendChild(btnAI); const btnRefresh = create("button"); btnRefresh.textContent = "刷新题目"; btnRefresh.onclick = async () => { row.classList.add("loading"); try { const resp = await fetchProblemDetail(e.problemId); const detail = resp.data?.problem || resp.data || resp.result || {}; const merged = Object.assign({}, prob || {}, detail, { problemId: e.problemId, problemType: e.problemType }); repo.problems.set(e.problemId, merged); updateRow(row, e, merged); ui.toast("已刷新题目"); } catch (err) { ui.toast("刷新失败:" + (err?.message || err)); } finally { row.classList.remove("loading"); } }; actionsBar.appendChild(btnRefresh); } function updateRow(row, e, prob) { // 标题 const title = row.querySelector(".problem-title"); title.textContent = (prob?.body || e.body || prob?.title || `题目 ${e.problemId}`).slice(0, 120); // 元信息(含截止时间) const meta = row.querySelector(".problem-meta"); const status = prob?.status || e.status || {}; const answered = !!(prob?.result || status?.answered || status?.myAnswer); const endTime = Number(status?.endTime || prob?.endTime || e.endTime || 0) || void 0; meta.textContent = `PID: ${e.problemId} / 类型: ${e.problemType} / 状态: ${answered ? "已作答" : "未作答"} / 截止: ${endTime ? new Date(endTime).toLocaleString() : "未知"}`; // 容器 let detail = row.querySelector(".problem-detail"); if (!detail) { detail = create("div", "problem-detail"); row.appendChild(detail); } detail.innerHTML = ""; // ===== 显示“已作答答案” ===== const answeredBox = create("div", "answered-box"); const ansLabel = create("div", "label"); ansLabel.textContent = "已作答答案"; const ansPre = create("pre"); ansPre.textContent = pretty(prob?.result || status?.myAnswer || {}); answeredBox.appendChild(ansLabel); answeredBox.appendChild(ansPre); detail.appendChild(answeredBox); // ===== 手动答题(含补交) ===== const editorBox = create("div", "editor-box"); const editLabel = create("div", "label"); editLabel.textContent = "手动答题(JSON)"; const textarea = create("textarea"); textarea.rows = 6; textarea.placeholder = '{"answers":[...]}'; textarea.value = pretty(prob?.result || status?.myAnswer || prob?.suggested || {}); editorBox.appendChild(editLabel); editorBox.appendChild(textarea); const submitBar = create("div", "submit-bar"); // 保存(仅本地) const btnSaveLocal = create("button"); btnSaveLocal.textContent = "保存(本地)"; btnSaveLocal.onclick = () => { try { const parsed = JSON.parse(textarea.value || "{}"); const merged = Object.assign({}, prob || {}, { result: parsed }); repo.problems.set(e.problemId, merged); ui.toast("已保存到本地列表"); updateRow(row, e, merged); } catch (err) { ui.toast("JSON 解析失败:" + (err?.message || err)); } }; submitBar.appendChild(btnSaveLocal); // 正常提交(过期则提示是否补交) const startTime = Number(status?.startTime || prob?.startTime || e.startTime || 0) || void 0; const btnSubmit = create("button"); btnSubmit.textContent = "提交"; btnSubmit.onclick = async () => { try { const result = JSON.parse(textarea.value || "{}"); row.classList.add("loading"); const {route: route} = await submitAnswer({ problemId: e.problemId, problemType: e.problemType }, result, { startTime: startTime, endTime: endTime }); ui.toast(route === "answer" ? "提交成功" : "补交成功"); const merged = Object.assign({}, prob || {}, { result: result }, { status: { ...prob?.status || {}, answered: true } }); repo.problems.set(e.problemId, merged); updateRow(row, e, merged); } catch (err) { if (err?.name === "DeadlineError") ui.confirm("已过截止,是否执行补交?").then(async ok => { if (!ok) return; try { const result = JSON.parse(textarea.value || "{}"); row.classList.add("loading"); await submitAnswer({ problemId: e.problemId, problemType: e.problemType }, result, { startTime: startTime, endTime: endTime, forceRetry: true }); ui.toast("补交成功"); const merged = Object.assign({}, prob || {}, { result: result }, { status: { ...prob?.status || {}, answered: true } }); repo.problems.set(e.problemId, merged); updateRow(row, e, merged); } catch (e2) { ui.toast("补交失败:" + (e2?.message || e2)); } finally { row.classList.remove("loading"); } }); else ui.toast("提交失败:" + (err?.message || err)); } finally { row.classList.remove("loading"); } }; submitBar.appendChild(btnSubmit); // 强制补交 const btnForceRetry = create("button"); btnForceRetry.textContent = "强制补交"; btnForceRetry.onclick = async () => { try { const result = JSON.parse(textarea.value || "{}"); row.classList.add("loading"); await submitAnswer({ problemId: e.problemId, problemType: e.problemType }, result, { startTime: startTime, endTime: endTime, forceRetry: true }); ui.toast("补交成功"); const merged = Object.assign({}, prob || {}, { result: result }, { status: { ...prob?.status || {}, answered: true } }); repo.problems.set(e.problemId, merged); updateRow(row, e, merged); } catch (err) { ui.toast("补交失败:" + (err?.message || err)); } finally { row.classList.remove("loading"); } }; submitBar.appendChild(btnForceRetry); editorBox.appendChild(submitBar); detail.appendChild(editorBox); } let mounted$2 = false; let root$2; function $$2(sel) { return document.querySelector(sel); } function mountProblemListPanel() { if (mounted$2) return root$2; const wrap = document.createElement("div"); wrap.innerHTML = tpl$2; document.body.appendChild(wrap.firstElementChild); root$2 = document.getElementById("ykt-problem-list-panel"); $$2("#ykt-problem-list-close")?.addEventListener("click", () => showProblemListPanel(false)); window.addEventListener("ykt:open-problem-list", () => showProblemListPanel(true)); mounted$2 = true; updateProblemList(); return root$2; } function showProblemListPanel(visible = true) { mountProblemListPanel(); root$2.classList.toggle("visible", !!visible); if (visible) updateProblemList(); } function updateProblemList() { mountProblemListPanel(); const container = $$2("#ykt-problem-list"); container.innerHTML = ""; (repo.encounteredProblems || []).forEach(e => { const prob = repo.problems.get(e.problemId) || {}; const row = document.createElement("div"); row.className = "problem-row"; // 标题和元信息容器,内容由 updateRow 填充 const title = document.createElement("div"); title.className = "problem-title"; row.appendChild(title); const meta = document.createElement("div"); meta.className = "problem-meta"; row.appendChild(meta); const actionsBar = document.createElement("div"); actionsBar.className = "problem-actions"; row.appendChild(actionsBar); // 绑定按钮(查看 / AI解答 / 刷新题目) bindRowActions(row, e, prob); // 渲染题目信息 + 已作答答案 + 手动提交/补交 UI updateRow(row, e, prob); container.appendChild(row); }); } var tpl$1 = '<div id="ykt-active-problems-panel" class="ykt-active-wrapper">\n <div id="ykt-active-problems" class="active-problems"></div>\n</div>\n'; let mounted$1 = false; let root$1; function $$1(sel) { return document.querySelector(sel); } function mountActiveProblemsPanel() { if (mounted$1) return root$1; const wrap = document.createElement("div"); wrap.innerHTML = tpl$1; document.body.appendChild(wrap.firstElementChild); root$1 = document.getElementById("ykt-active-problems-panel"); mounted$1 = true; // 轻量刷新计时器 setInterval(() => updateActiveProblems(), 1e3); return root$1; } function updateActiveProblems() { mountActiveProblemsPanel(); const box = $$1("#ykt-active-problems"); box.innerHTML = ""; const now = Date.now(); repo.problemStatus.forEach((status, pid) => { const p = repo.problems.get(pid); if (!p || p.result) return; const card = document.createElement("div"); card.className = "active-problem-card"; const title = document.createElement("div"); title.className = "ap-title"; title.textContent = (p.body || `题目 ${pid}`).slice(0, 80); card.appendChild(title); const remain = Math.max(0, Math.floor((status.endTime - now) / 1e3)); const info = document.createElement("div"); info.className = "ap-info"; info.textContent = `剩余 ${remain}s`; card.appendChild(info); const bar = document.createElement("div"); bar.className = "ap-actions"; const go = document.createElement("button"); go.textContent = "查看"; go.onclick = () => actions.navigateTo(status.presentationId, status.slideId); bar.appendChild(go); const ai = document.createElement("button"); ai.textContent = "AI 解答"; ai.onclick = () => window.dispatchEvent(new CustomEvent("ykt:open-ai")); bar.appendChild(ai); card.appendChild(bar); box.appendChild(card); }); } var tpl = '<div id="ykt-tutorial-panel" class="ykt-panel">\n <div class="panel-header">\n <h3>雨课堂助手使用教程</h3>\n <h5>1.17.1<h5>\n <span class="close-btn" id="ykt-tutorial-close"><i class="fas fa-times"></i></span>\n </div>\n\n <div class="panel-body">\n <div class="tutorial-content">\n <h4>功能介绍</h4>\n <p>AI雨课堂助手是一个为雨课堂提供辅助功能的工具,可以帮助你更好地参与课堂互动。</p>\n <p>项目仓库:<a href="https://github.com/ZaytsevZY/yuketang-helper-ai" target="_blank" rel="noopener">GitHub</a></p>\n <p>脚本安装:<a href="https://greasyfork.org/zh-CN/scripts/531469-ai雨课堂助手" target="_blank" rel="noopener">GreasyFork</a></p>\n\n <h4>工具栏按钮说明</h4>\n <ul>\n <li><i class="fas fa-bell"></i> <b>习题提醒</b>:切换是否在新习题出现时显示通知提示(蓝色=开启)。</li>\n <li><i class="fas fa-file-powerpoint"></i> <b>课件浏览</b>:查看课件与题目页面。</li>\n <li><i class="fas fa-robot"></i> <b>AI 解答</b>:向 AI 询问当前题目并显示建议答案。</li>\n <li><i class="fas fa-magic-wand-sparkles"></i> <b>自动作答</b>:切换自动作答(蓝色=开启)。</li>\n <li><i class="fas fa-cog"></i> <b>设置</b>:配置 API 密钥与自动作答参数。</li>\n <li><i class="fas fa-question-circle"></i> <b>使用教程</b>:显示/隐藏当前教程页面。</li>\n </ul>\n\n <h4>自动作答</h4>\n <ul>\n <li>在设置中开启自动作答并配置延迟/随机延迟。</li>\n <li>需要配置 <del>DeepSeek API</del> Kimi API 密钥。</li>\n <li>答案来自 AI,结果仅供参考。</li>\n </ul>\n\n <h4>AI 解答</h4>\n <ol>\n <li>点击设置(<i class="fas fa-cog"></i>)填入 API Key。</li>\n <li>点击 AI 解答(<i class="fas fa-robot"></i>)后会对“当前题目/最近遇到的题目”询问并解析。</li>\n </ol>\n\n <h4>注意事项</h4>\n <p>1) 仅供学习参考,请独立思考;</p>\n <p>2) 合理使用 API 额度;</p>\n <p>3) 答案不保证 100% 正确;</p>\n <p>4) 自动作答有一定风险,谨慎开启。</p>\n\n <h4>联系方式</h4>\n <ul>\n <li>请在Github issue提出问题</li>\n </ul>\n </div>\n </div>\n</div>\n'; let mounted = false; let root; function $(sel) { return document.querySelector(sel); } function mountTutorialPanel() { if (mounted) return root; const host = document.createElement("div"); host.innerHTML = tpl; document.body.appendChild(host.firstElementChild); root = document.getElementById("ykt-tutorial-panel"); $("#ykt-tutorial-close")?.addEventListener("click", () => showTutorialPanel(false)); mounted = true; return root; } function showTutorialPanel(visible = true) { mountTutorialPanel(); root.classList.toggle("visible", !!visible); } function toggleTutorialPanel() { mountTutorialPanel(); const vis = root.classList.contains("visible"); showTutorialPanel(!vis); // 同步工具条按钮激活态(如果存在) const helpBtn = document.getElementById("ykt-btn-help"); if (helpBtn) helpBtn.classList.toggle("active", !vis); } // src/ui/ui-api.js const _config = Object.assign({}, DEFAULT_CONFIG, storage.get("config", {})); _config.ai.kimiApiKey = storage.get("kimiApiKey", _config.ai.kimiApiKey); _config.TYPE_MAP = _config.TYPE_MAP || PROBLEM_TYPE_MAP; function saveConfig() { storage.set("config", _config); } // 面板层级管理 let currentZIndex = 1e7; const ui = { get config() { return _config; }, saveConfig: saveConfig, updatePresentationList: updatePresentationList, updateSlideView: updateSlideView, askAIForCurrent: askAIForCurrent, updateProblemList: updateProblemList, updateActiveProblems: updateActiveProblems, // 提升面板层级的辅助函数 _bringToFront(panelElement) { if (panelElement && panelElement.classList.contains("visible")) { currentZIndex += 1; panelElement.style.zIndex = currentZIndex; } }, // 修改后的面板显示函数,添加z-index管理 showPresentationPanel(visible = true) { showPresentationPanel(visible); if (visible) { const panel = document.getElementById("ykt-presentation-panel"); this._bringToFront(panel); } }, showProblemListPanel(visible = true) { showProblemListPanel(visible); if (visible) { const panel = document.getElementById("ykt-problem-list-panel"); this._bringToFront(panel); } }, showAIPanel(visible = true) { showAIPanel(visible); if (visible) { const panel = document.getElementById("ykt-ai-answer-panel"); this._bringToFront(panel); } }, toggleSettingsPanel() { toggleSettingsPanel(); // 检查面板是否变为可见状态 const panel = document.getElementById("ykt-settings-panel"); if (panel && panel.classList.contains("visible")) this._bringToFront(panel); }, toggleTutorialPanel() { toggleTutorialPanel(); // 检查面板是否变为可见状态 const panel = document.getElementById("ykt-tutorial-panel"); if (panel && panel.classList.contains("visible")) this._bringToFront(panel); }, // 在 index.js 初始化时挂载一次 _mountAll() { mountSettingsPanel(); mountAIPanel(); mountPresentationPanel(); mountProblemListPanel(); mountActiveProblemsPanel(); mountTutorialPanel(); window.addEventListener("ykt:open-ai", () => this.showAIPanel(true)); }, notifyProblem(problem, slide) { gm.notify({ title: "雨课堂习题提示", text: this.getProblemDetail(problem), image: slide?.thumbnail || null, timeout: 5e3 }); }, getProblemDetail(problem) { if (!problem) return "题目未找到"; const lines = [ problem.body || "" ]; if (Array.isArray(problem.options)) lines.push(...problem.options.map(({key: key, value: value}) => `${key}. ${value}`)); return lines.join("\n"); }, toast: toast, nativeNotify: gm.notify, // Buttons 状态 updateAutoAnswerBtn() { const el = document.getElementById("ykt-btn-auto-answer"); if (!el) return; if (_config.autoAnswer) el.classList.add("active"); else el.classList.remove("active"); } }; // src/state/actions.js // 内部自动答题处理函数 async function handleAutoAnswerInternal(problem) { const status = repo.problemStatus.get(problem.problemId); if (!status || status.answering || problem.result) return; if (Date.now() >= status.endTime) return; try { let aiAnswer, parsed; // 优先使用文本模式,如果没有题干则使用Vision模式 if (problem.body && problem.body.trim()) { const q = formatProblemForAI(problem, PROBLEM_TYPE_MAP); aiAnswer = await queryKimi(q, ui.config.ai); parsed = parseAIAnswer(problem, aiAnswer); } // 如果文本模式失败或没有题干,尝试Vision模式 if (!parsed) { const {captureProblemForVision: captureProblemForVision} = await Promise.resolve().then(function() { return screenshoot; }); const {queryKimiVision: queryKimiVision} = await Promise.resolve().then(function() { return kimi; }); const imageBase64 = await captureProblemForVision(); if (imageBase64) { const textPrompt = problem.body ? `请结合题目信息分析图片:\n${formatProblemForAI(problem, PROBLEM_TYPE_MAP)}` : "请分析图片中的题目并给出答案"; aiAnswer = await queryKimiVision(imageBase64, textPrompt, ui.config.ai); parsed = parseAIAnswer(problem, aiAnswer); } } if (!parsed) return ui.toast("无法解析AI答案,跳过自动作答", 2e3); await submitAnswer(problem, parsed); actions.onAnswerProblem(problem.problemId, parsed); ui.toast(`自动作答完成: ${String(problem.body || "").slice(0, 30)}...`, 3e3); showAutoAnswerPopup(problem, typeof aiAnswer === "string" ? aiAnswer : JSON.stringify(aiAnswer, null, 2)); } catch (e) { console.error("[AutoAnswer] failed", e); ui.toast(`自动作答失败: ${e.message}`, 3e3); } } const actions = { onFetchTimeline(timeline) { for (const piece of timeline) if (piece.type === "problem") this.onUnlockProblem(piece); }, onPresentationLoaded(id, data) { repo.setPresentation(id, data); const pres = repo.presentations.get(id); for (const slide of pres.slides) { repo.upsertSlide(slide); if (slide.problem) { repo.upsertProblem(slide.problem); repo.pushEncounteredProblem(slide.problem, slide, id); } } ui.updatePresentationList(); }, onUnlockProblem(data) { const problem = repo.problems.get(data.prob); const slide = repo.slides.get(data.sid); if (!problem || !slide) return; const status = { presentationId: data.pres, slideId: data.sid, startTime: data.dt, endTime: data.dt + 1e3 * data.limit, done: !!problem.result, autoAnswerTime: null, answering: false }; repo.problemStatus.set(data.prob, status); if (Date.now() > status.endTime || problem.result) return; // toast + 通知 if (ui.config.notifyProblems) ui.notifyProblem(problem, slide); // 自动作答 if (ui.config.autoAnswer) { const delay = ui.config.autoAnswerDelay + randInt(0, ui.config.autoAnswerRandomDelay); status.autoAnswerTime = Date.now() + delay; ui.toast(`将在 ${Math.floor(delay / 1e3)} 秒后自动作答本题`, 3e3); } ui.updateActiveProblems(); }, onLessonFinished() { ui.nativeNotify({ title: "下课提示", text: "当前课程已结束", timeout: 5e3 }); }, onAnswerProblem(problemId, result) { const p = repo.problems.get(problemId); if (p) { p.result = result; const i = repo.encounteredProblems.findIndex(e => e.problemId === problemId); if (i !== -1) repo.encounteredProblems[i].result = result; ui.updateProblemList(); } }, async handleAutoAnswer(problem) { return handleAutoAnswerInternal(problem); }, // 定时器驱动(由 index.js 安装) tickAutoAnswer() { const now = Date.now(); for (const [pid, status] of repo.problemStatus) if (status.autoAnswerTime !== null && now >= status.autoAnswerTime) { const p = repo.problems.get(pid); if (p) { status.autoAnswerTime = null; this.handleAutoAnswer(p); } } }, async submit(problem, content) { const result = this.parseManual(problem.problemType, content); await submitAnswer(problem, result); this.onAnswerProblem(problem.problemId, result); }, parseManual(problemType, content) { switch (problemType) { case 1: case 2: case 3: return content.split("").sort(); case 4: return content.split("\n").filter(Boolean); case 5: return { content: content, pics: [] }; default: return null; } }, navigateTo(presId, slideId) { repo.currentPresentationId = presId; repo.currentSlideId = slideId; ui.updateSlideView(); ui.showPresentationPanel(true); }, //1.16.4: 进入课堂:设置 lessonId +(可选)写入 Tab 信息 + 载入本课已存课件 launchLessonHelper() { // 从 URL 提取 lessonId(/lesson/fullscreen/v3/<lessonId>/...) const path = window.location.pathname; const m = path.match(/\/lesson\/fullscreen\/v3\/([^/]+)/); repo.currentLessonId = m ? m[1] : null; if (repo.currentLessonId) console.log(`[雨课堂助手] 检测到课堂页面 lessonId: ${repo.currentLessonId}`); // GM_* Tab 状态(存在才用,向后兼容) if (typeof window.GM_getTab === "function" && typeof window.GM_saveTab === "function" && repo.currentLessonId) window.GM_getTab(tab => { tab.type = "lesson"; tab.lessonId = repo.currentLessonId; window.GM_saveTab(tab); }); // 载入"本课"的历史课件 repo.loadStoredPresentations(); } }; // src/net/ws-interceptor.js function installWSInterceptor() { // 环境识别(标准/荷塘/未知),主要用于日志和后续按需适配 function detectEnvironmentAndAdaptAPI() { const hostname = location.hostname; let envType = "unknown"; if (hostname === "www.yuketang.cn") { envType = "standard"; console.log("[雨课堂助手] 检测到标准雨课堂环境"); } else if (hostname === "pro.yuketang.cn") { envType = "pro"; console.log("[雨课堂助手] 检测到荷塘雨课堂环境"); } else console.log("[雨课堂助手] 未知环境:", hostname); return envType; } class MyWebSocket extends WebSocket { static handlers=[]; static addHandler(h) { this.handlers.push(h); } constructor(url, protocols) { super(url, protocols); const parsed = new URL(url, location.href); for (const h of this.constructor.handlers) h(this, parsed); } intercept(cb) { const raw = this.send; this.send = data => { try { cb(JSON.parse(data)); } catch {} return raw.call(this, data); }; } listen(cb) { this.addEventListener("message", e => { try { cb(JSON.parse(e.data)); } catch {} }); } } // MyWebSocket.addHandler((ws, url) => { // if (url.pathname === '/wsapp/') { // ws.listen((msg) => { // switch (msg.op) { // case 'fetchtimeline': actions.onFetchTimeline(msg.timeline); break; // case 'unlockproblem': actions.onUnlockProblem(msg.problem); break; // case 'lessonfinished': actions.onLessonFinished(); break; // } // }); // } // }); MyWebSocket.addHandler((ws, url) => { const envType = detectEnvironmentAndAdaptAPI(); console.log("[雨课堂助手] 拦截WebSocket通信 - 环境:", envType); console.log("[雨课堂助手] WebSocket连接尝试:", url.href); // 更宽松的路径匹配 const wsPath = url.pathname || ""; const isRainClassroomWS = wsPath === "/wsapp/" || wsPath.includes("/ws") || wsPath.includes("/websocket") || url.href.includes("websocket"); if (!isRainClassroomWS) { console.log("[雨课堂助手] ❌ 非雨课堂WebSocket:", wsPath); return; } console.log("[雨课堂助手] ✅ 检测到雨课堂WebSocket连接:", wsPath); // 发送侧拦截(可用于调试) ws.intercept(message => { console.log("[雨课堂助手] WebSocket发送:", message); }); // 接收侧统一分发 ws.listen(message => { try { console.log("[雨课堂助手] WebSocket接收:", message); switch (message.op) { case "fetchtimeline": console.log("[雨课堂助手] 收到时间线:", message.timeline); actions.onFetchTimeline(message.timeline); break; case "unlockproblem": console.log("[雨课堂助手] 收到解锁问题:", message.problem); actions.onUnlockProblem(message.problem); break; case "lessonfinished": console.log("[雨课堂助手] 课程结束"); actions.onLessonFinished(); break; default: console.log("[雨课堂助手] 未知WebSocket操作:", message.op, message); } } catch (e) { console.debug("[雨课堂助手] 解析WebSocket消息失败", e, message); } }); }); gm.uw.WebSocket = MyWebSocket; } // src/net/xhr-interceptor.js function installXHRInterceptor() { class MyXHR extends XMLHttpRequest { static handlers=[]; static addHandler(h) { this.handlers.push(h); } open(method, url, async) { const parsed = new URL(url, location.href); for (const h of this.constructor.handlers) h(this, method, parsed); return super.open(method, url, async ?? true); } intercept(cb) { let payload; const rawSend = this.send; this.send = body => { payload = body; return rawSend.call(this, body); }; this.addEventListener("load", () => { try { cb(JSON.parse(this.responseText), payload); } catch {} }); } } function detectEnvironmentAndAdaptAPI() { const hostname = location.hostname; if (hostname === "www.yuketang.cn") { console.log("[雨课堂助手] 检测到标准雨课堂环境"); return "standard"; } if (hostname === "pro.yuketang.cn") { console.log("[雨课堂助手] 检测到荷塘雨课堂环境"); return "pro"; } console.log("[雨课堂助手] 未知环境:", hostname); return "unknown"; } MyXHR.addHandler((xhr, method, url) => { detectEnvironmentAndAdaptAPI(); const pathname = url.pathname || ""; console.log("[雨课堂助手] XHR请求:", method, pathname, url.search); // 课件:精确路径或包含关键字 if (pathname === "/api/v3/lesson/presentation/fetch" || pathname.includes("presentation") && pathname.includes("fetch")) { console.log("[雨课堂助手] ✅ 拦截课件请求"); xhr.intercept(resp => { const id = url.searchParams.get("presentation_id"); console.log("[雨课堂助手] 课件响应:", resp); if (resp && (resp.code === 0 || resp.success)) actions.onPresentationLoaded(id, resp.data || resp.result); }); return; } // 答题 if (pathname === "/api/v3/lesson/problem/answer" || pathname.includes("problem") && pathname.includes("answer")) { console.log("[雨课堂助手] ✅ 拦截答题请求"); xhr.intercept((resp, payload) => { try { const {problemId: problemId, result: result} = JSON.parse(payload || "{}"); if (resp && (resp.code === 0 || resp.success)) actions.onAnswerProblem(problemId, result); } catch (e) { console.error("[雨课堂助手] 解析答题响应失败:", e); } }); return; } if (url.pathname === "/api/v3/lesson/problem/retry") { xhr.intercept((resp, payload) => { try { // retry 请求体是 { problems: [{ problemId, result, ...}] } const body = JSON.parse(payload || "{}"); const first = Array.isArray(body?.problems) ? body.problems[0] : null; if (resp?.code === 0 && first?.problemId) actions.onAnswerProblem(first.problemId, first.result); } catch {} }); return; } if (pathname.includes("/api/")) console.log("[雨课堂助手] 其他API:", method, pathname); }); gm.uw.XMLHttpRequest = MyXHR; } var css = '/* ===== 通用 & 修复 ===== */\n#watermark_layer { display: none !important; visibility: hidden !important; }\n.hidden { display: none !important; }\n\n:root{\n --ykt-z: 10000000;\n --ykt-border: #ddd;\n --ykt-border-strong: #ccc;\n --ykt-bg: #fff;\n --ykt-fg: #222;\n --ykt-muted: #607190;\n --ykt-accent: #1d63df;\n --ykt-hover: #1e3050;\n --ykt-shadow: 0 10px 30px rgba(0,0,0,.18);\n}\n\n/* ===== 工具栏 ===== */\n#ykt-helper-toolbar{\n position: fixed; z-index: calc(var(--ykt-z) + 1);\n left: 15px; bottom: 15px;\n /* 移除固定宽度,让内容自适应 */\n height: 36px; padding: 5px;\n display: flex; gap: 6px; align-items: center;\n background: var(--ykt-bg);\n border: 1px solid var(--ykt-border-strong);\n border-radius: 4px;\n box-shadow: 0 1px 4px 3px rgba(0,0,0,.1);\n}\n\n#ykt-helper-toolbar .btn{\n display: inline-block; padding: 4px; cursor: pointer;\n color: var(--ykt-muted); line-height: 1;\n}\n#ykt-helper-toolbar .btn:hover{ color: var(--ykt-hover); }\n#ykt-helper-toolbar .btn.active{ color: var(--ykt-accent); }\n\n/* ===== 面板通用样式 ===== */\n.ykt-panel{\n position: fixed; right: 20px; bottom: 60px;\n width: 560px; max-height: 72vh; overflow: auto;\n background: var(--ykt-bg); color: var(--ykt-fg);\n border: 1px solid var(--ykt-border-strong); border-radius: 8px;\n box-shadow: var(--ykt-shadow);\n display: none; \n /* 提高z-index,确保后打开的面板在最上层 */\n z-index: var(--ykt-z);\n}\n.ykt-panel.visible{ \n display: block; \n /* 动态提升z-index */\n z-index: calc(var(--ykt-z) + 10);\n}\n\n.panel-header{\n display: flex; align-items: center; justify-content: space-between;\n gap: 12px; padding: 10px 12px; border-bottom: 1px solid var(--ykt-border);\n}\n.panel-header h3{ margin: 0; font-size: 16px; font-weight: 600; }\n.panel-body{ padding: 10px 12px; }\n.close-btn{ cursor: pointer; color: var(--ykt-muted); }\n.close-btn:hover{ color: var(--ykt-hover); }\n\n/* ===== 设置面板 (#ykt-settings-panel) ===== */\n#ykt-settings-panel .settings-content{ display: flex; flex-direction: column; gap: 14px; }\n#ykt-settings-panel .setting-group{ border: 1px dashed var(--ykt-border); border-radius: 6px; padding: 10px; }\n#ykt-settings-panel .setting-group h4{ margin: 0 0 8px 0; font-size: 14px; }\n#ykt-settings-panel .setting-item{ display: flex; align-items: center; gap: 8px; margin: 8px 0; flex-wrap: wrap; }\n#ykt-settings-panel label{ font-size: 13px; }\n#ykt-settings-panel input[type="text"],\n#ykt-settings-panel input[type="number"]{\n height: 30px; border: 1px solid var(--ykt-border-strong);\n border-radius: 4px; padding: 0 8px; min-width: 220px;\n}\n#ykt-settings-panel small{ color: #666; }\n#ykt-settings-panel .setting-actions{ display: flex; gap: 8px; margin-top: 6px; }\n#ykt-settings-panel button{\n height: 30px; padding: 0 12px; border-radius: 6px;\n border: 1px solid var(--ykt-border-strong); background: #f7f8fa; cursor: pointer;\n}\n#ykt-settings-panel button:hover{ background: #eef3ff; border-color: var(--ykt-accent); }\n\n/* 自定义复选框(与手写脚本一致的视觉语义) */\n#ykt-settings-panel .checkbox-label{ position: relative; padding-left: 26px; cursor: pointer; user-select: none; }\n#ykt-settings-panel .checkbox-label input{ position: absolute; opacity: 0; cursor: pointer; height: 0; width: 0; }\n#ykt-settings-panel .checkbox-label .checkmark{\n position: absolute; left: 0; top: 50%; transform: translateY(-50%);\n height: 16px; width: 16px; border:1px solid var(--ykt-border-strong); border-radius: 3px; background: #fff;\n}\n#ykt-settings-panel .checkbox-label input:checked ~ .checkmark{\n background: var(--ykt-accent); border-color: var(--ykt-accent);\n}\n#ykt-settings-panel .checkbox-label .checkmark:after{\n content: ""; position: absolute; display: none;\n left: 5px; top: 1px; width: 4px; height: 8px; border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg);\n}\n#ykt-settings-panel .checkbox-label input:checked ~ .checkmark:after{ display: block; }\n\n/* ===== AI 解答面板 (#ykt-ai-answer-panel) ===== */\n#ykt-ai-answer-panel .ai-question{\n white-space: pre-wrap; background: #fafafa; border: 1px solid var(--ykt-border);\n padding: 8px; border-radius: 6px; margin-bottom: 8px; max-height: 160px; overflow: auto;\n}\n#ykt-ai-answer-panel .ai-loading{ color: var(--ykt-accent); margin-bottom: 6px; }\n#ykt-ai-answer-panel .ai-error{ color: #b00020; margin-bottom: 6px; }\n#ykt-ai-answer-panel .ai-answer{ white-space: pre-wrap; margin-top: 4px; }\n#ykt-ai-answer-panel .ai-actions{ margin-top: 10px; }\n#ykt-ai-answer-panel .ai-actions button{\n height: 30px; padding: 0 12px; border-radius: 6px;\n border: 1px solid var(--ykt-border-strong); background: #f7f8fa; cursor: pointer;\n}\n#ykt-ai-answer-panel .ai-actions button:hover{ background: #eef3ff; border-color: var(--ykt-accent); }\n\n/* ===== 课件浏览面板 (#ykt-presentation-panel) ===== */\n#ykt-presentation-panel{ width: 900px; }\n#ykt-presentation-panel .panel-controls{ display: flex; align-items: center; gap: 8px; }\n#ykt-presentation-panel .panel-body{\n display: grid; grid-template-columns: 300px 1fr; gap: 10px;\n}\n#ykt-presentation-panel .presentation-title{\n font-weight: 600; padding: 6px 0; border-bottom: 1px solid var(--ykt-border);\n}\n#ykt-presentation-panel .slide-thumb-list{ display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 8px; }\n#ykt-presentation-panel .slide-thumb{\n border: 1px solid var(--ykt-border); border-radius: 6px; background: #fafafa;\n min-height: 60px; display: flex; align-items: center; justify-content: center; cursor: pointer; padding: 4px; text-align: center;\n}\n#ykt-presentation-panel .slide-thumb:hover{ border-color: var(--ykt-accent); background: #eef3ff; }\n#ykt-presentation-panel .slide-thumb img{ max-width: 100%; max-height: 120px; object-fit: contain; display: block; }\n\n#ykt-presentation-panel .slide-view{\n position: relative; border: 1px solid var(--ykt-border); border-radius: 8px; min-height: 360px; background: #fff; overflow: hidden;\n}\n#ykt-presentation-panel .slide-cover{ display: flex; align-items: center; justify-content: center; min-height: 360px; }\n#ykt-presentation-panel .slide-cover img{ max-width: 100%; max-height: 100%; object-fit: contain; display: block; }\n\n#ykt-presentation-panel .problem-box{\n position: absolute; left: 12px; right: 12px; bottom: 12px;\n background: rgba(255,255,255,.96); border: 1px solid var(--ykt-border);\n border-radius: 8px; padding: 10px; box-shadow: 0 6px 18px rgba(0,0,0,.12);\n}\n#ykt-presentation-panel .problem-head{ font-weight: 600; margin-bottom: 6px; }\n#ykt-presentation-panel .problem-options{ display: grid; grid-template-columns: 1fr; gap: 4px; }\n#ykt-presentation-panel .problem-option{ padding: 6px 8px; border: 1px solid var(--ykt-border); border-radius: 6px; background: #fafafa; }\n\n/* ===== 题目列表面板 (#ykt-problem-list-panel) ===== */\n#ykt-problem-list{ display: flex; flex-direction: column; gap: 10px; }\n#ykt-problem-list .problem-row{\n border: 1px solid var(--ykt-border); border-radius: 8px; padding: 8px; background: #fafafa;\n}\n#ykt-problem-list .problem-title{ font-weight: 600; margin-bottom: 4px; }\n#ykt-problem-list .problem-meta{ color: #666; font-size: 12px; margin-bottom: 6px; }\n#ykt-problem-list .problem-actions{ display: flex; gap: 8px; align-items: center; }\n#ykt-problem-list .problem-actions button{\n height: 28px; padding: 0 10px; border-radius: 6px; border: 1px solid var(--ykt-border-strong); background: #f7f8fa; cursor: pointer;\n}\n#ykt-problem-list .problem-actions button:hover{ background: #eef3ff; border-color: var(--ykt-accent); }\n#ykt-problem-list .problem-done{ color: #0a7a2f; font-weight: 600; }\n\n/* ===== 活动题目列表(右下角小卡片) ===== */\n#ykt-active-problems-panel.ykt-active-wrapper{\n position: fixed; right: 20px; bottom: 60px; z-index: var(--ykt-z);\n}\n#ykt-active-problems{ display: flex; flex-direction: column; gap: 8px; max-height: 60vh; overflow: auto; }\n#ykt-active-problems .active-problem-card{\n width: 320px; background: #fff; border: 1px solid var(--ykt-border);\n border-radius: 8px; box-shadow: var(--ykt-shadow); padding: 10px;\n}\n#ykt-active-problems .ap-title{ font-weight: 600; margin-bottom: 4px; }\n#ykt-active-problems .ap-info{ color: #666; font-size: 12px; margin-bottom: 8px; }\n#ykt-active-problems .ap-actions{ display: flex; gap: 8px; }\n#ykt-active-problems .ap-actions button{\n height: 28px; padding: 0 10px; border-radius: 6px; border: 1px solid var(--ykt-border-strong); background: #f7f8fa; cursor: pointer;\n}\n#ykt-active-problems .ap-actions button:hover{ background: #eef3ff; border-color: var(--ykt-accent); }\n\n/* ===== 教程面板 (#ykt-tutorial-panel) ===== */\n#ykt-tutorial-panel .tutorial-content h4{ margin: 8px 0 6px; }\n#ykt-tutorial-panel .tutorial-content p,\n#ykt-tutorial-panel .tutorial-content li{ line-height: 1.5; }\n#ykt-tutorial-panel .tutorial-content a{ color: var(--ykt-accent); text-decoration: none; }\n#ykt-tutorial-panel .tutorial-content a:hover{ text-decoration: underline; }\n\n/* ===== 小屏适配 ===== */\n@media (max-width: 1200px){\n #ykt-presentation-panel{ width: 760px; }\n #ykt-presentation-panel .panel-body{ grid-template-columns: 260px 1fr; }\n}\n@media (max-width: 900px){\n .ykt-panel{ right: 12px; left: 12px; width: auto; }\n #ykt-presentation-panel{ width: auto; }\n #ykt-presentation-panel .panel-body{ grid-template-columns: 1fr; }\n}\n\n/* ===== 自动作答成功弹窗 ===== */\n.auto-answer-popup{\n position: fixed; inset: 0; z-index: calc(var(--ykt-z) + 2);\n background: rgba(0,0,0,.2);\n display: flex; align-items: flex-end; justify-content: flex-end;\n opacity: 0; transition: opacity .18s ease;\n}\n.auto-answer-popup.visible{ opacity: 1; }\n\n.auto-answer-popup .popup-content{\n width: min(560px, 96vw);\n background: #fff; border: 1px solid var(--ykt-border-strong);\n border-radius: 10px; box-shadow: var(--ykt-shadow);\n margin: 16px; overflow: hidden;\n}\n\n.auto-answer-popup .popup-header{\n display: flex; align-items: center; justify-content: space-between;\n gap: 12px; padding: 10px 12px; border-bottom: 1px solid var(--ykt-border);\n}\n.auto-answer-popup .popup-header h4{ margin: 0; font-size: 16px; }\n.auto-answer-popup .close-btn{ cursor: pointer; color: var(--ykt-muted); }\n.auto-answer-popup .close-btn:hover{ color: var(--ykt-hover); }\n\n.auto-answer-popup .popup-body{ padding: 10px 12px; display: flex; flex-direction: column; gap: 10px; }\n.auto-answer-popup .popup-row{ display: grid; grid-template-columns: 56px 1fr; gap: 8px; align-items: start; }\n.auto-answer-popup .label{ color: #666; font-size: 12px; line-height: 1.8; }\n.auto-answer-popup .content{ white-space: normal; word-break: break-word; }\n\n/* ===== 1.16.6: 课件浏览面板:固定右侧详细视图,左侧独立滚动 ===== */\n#ykt-presentation-panel {\n --ykt-panel-max-h: 72vh; /* 与 .ykt-panel 的最大高度保持一致 */\n}\n\n/* 两列布局:左列表 + 右详细视图 */\n#ykt-presentation-panel .panel-body{\n display: grid;\n grid-template-columns: 300px 1fr; /* 左列宽度可按需调整 */\n gap: 12px;\n overflow: hidden; /* 避免内部再出现双滚动条 */\n align-items: start;\n}\n\n/* 左侧:只让左列滚动,限制在面板可视高度内 */\n#ykt-presentation-panel .panel-left{\n max-height: var(--ykt-panel-max-h);\n overflow: auto;\n min-width: 0; /* 防止子元素撑破 */\n}\n\n/* 右侧:粘性定位为“固定”,始终在面板可视区内 */\n#ykt-presentation-panel .panel-right{\n position: sticky;\n top: 0; /* 相对可滚动祖先(面板)吸顶 */\n align-self: start;\n}\n\n/* 右侧详细视图自身也限制高度并允许内部滚动 */\n#ykt-presentation-panel .slide-view{\n max-height: var(--ykt-panel-max-h);\n overflow: auto;\n border: 1px solid var(--ykt-border);\n border-radius: 8px;\n background: #fff;\n}\n\n/* 小屏自适配:堆叠布局时取消 sticky,避免遮挡 */\n@media (max-width: 900px){\n #ykt-presentation-panel .panel-body{\n grid-template-columns: 1fr;\n }\n #ykt-presentation-panel .panel-right{\n position: static;\n }\n}\n'; // src/ui/styles.js function injectStyles() { gm.addStyle(css); } // src/ui/toolbar.js function installToolbar() { // 仅创建容器与按钮;具体面板之后用 HTML/Vue 接入 const bar = document.createElement("div"); bar.id = "ykt-helper-toolbar"; bar.innerHTML = `\n <span id="ykt-btn-bell" class="btn" title="习题提醒"><i class="fas fa-bell"></i></span>\n <span id="ykt-btn-pres" class="btn" title="课件浏览"><i class="fas fa-file-powerpoint"></i></span>\n <span id="ykt-btn-ai" class="btn" title="AI解答"><i class="fas fa-robot"></i></span>\n <span id="ykt-btn-auto-answer" class="btn" title="自动作答"><i class="fas fa-magic-wand-sparkles"></i></span>\n <span id="ykt-btn-settings" class="btn" title="设置"><i class="fas fa-cog"></i></span>\n <span id="ykt-btn-help" class="btn" title="使用教程"><i class="fas fa-question-circle"></i></span>\n `; document.body.appendChild(bar); // 初始激活态 if (ui.config.notifyProblems) bar.querySelector("#ykt-btn-bell")?.classList.add("active"); ui.updateAutoAnswerBtn(); // 事件绑定 bar.querySelector("#ykt-btn-bell")?.addEventListener("click", () => { ui.config.notifyProblems = !ui.config.notifyProblems; ui.saveConfig(); ui.toast(`习题提醒:${ui.config.notifyProblems ? "开" : "关"}`); bar.querySelector("#ykt-btn-bell")?.classList.toggle("active", ui.config.notifyProblems); }); // 修改课件浏览按钮 - 切换显示/隐藏 bar.querySelector("#ykt-btn-pres")?.addEventListener("click", () => { const btn = bar.querySelector("#ykt-btn-pres"); const isActive = btn.classList.contains("active"); ui.showPresentationPanel?.(!isActive); btn.classList.toggle("active", !isActive); }); // 修改AI按钮 - 切换显示/隐藏 bar.querySelector("#ykt-btn-ai")?.addEventListener("click", () => { const btn = bar.querySelector("#ykt-btn-ai"); const isActive = btn.classList.contains("active"); ui.showAIPanel?.(!isActive); btn.classList.toggle("active", !isActive); }); bar.querySelector("#ykt-btn-auto-answer")?.addEventListener("click", () => { ui.config.autoAnswer = !ui.config.autoAnswer; ui.saveConfig(); ui.toast(`自动作答:${ui.config.autoAnswer ? "开" : "关"}`); ui.updateAutoAnswerBtn(); }); bar.querySelector("#ykt-btn-settings")?.addEventListener("click", () => { ui.toggleSettingsPanel?.(); }); bar.querySelector("#ykt-btn-help")?.addEventListener("click", () => { ui.toggleTutorialPanel?.(); }); } // src/index.js // 可选:统一放到 core/env.js 的 ensureFontAwesome;这里保留现有注入方式也可以 (function loadFA() { const link = document.createElement("link"); link.rel = "stylesheet"; link.href = "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"; document.head.appendChild(link); })(); (function main() { // 1) 样式/图标 injectStyles(); // 2) 先挂 UI(面板、事件桥接) ui._mountAll?.(); // ✅ 现在 ui 已导入,确保执行到位 // 3) 再装网络拦截 installWSInterceptor(); installXHRInterceptor(); // 4) 装工具条(按钮会用到 ui.config 状态) installToolbar(); // 5) 启动自动作答轮询(替代原来的 tickAutoAnswer 占位) actions.startAutoAnswerLoop(); // 6)1.16.4 更新课件加载 actions.launchLessonHelper(); })(); })();