您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
课堂习题提示,AI解答习题
// ==UserScript== // @name AI雨课堂助手(模块化构建版) // @namespace https://github.com/ZaytsevZY/yuketang-helper-auto // @version 1.18.6 // @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, currentSelectedUrl: 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">\r\n <div class="panel-header">\r\n <h3>AI雨课堂助手设置</h3>\r\n <span class="close-btn" id="ykt-settings-close"><i class="fas fa-times"></i></span>\r\n </div>\r\n\r\n <div class="panel-body">\r\n <div class="settings-content">\r\n <div class="setting-group">\r\n <h4>AI配置</h4>\r\n \x3c!-- 将DeepSeek相关配置替换为Kimi --\x3e\r\n <div class="setting-item">\r\n <label for="kimi-api-key">Kimi API Key:</label>\r\n <input type="password" id="kimi-api-key" placeholder="输入您的 Kimi API Key">\r\n <small>从 <a href="https://platform.moonshot.cn/" target="_blank">Kimi开放平台</a> 获取</small>\r\n </div>\r\n </div>\r\n\r\n <div class="setting-group">\r\n <h4>自动作答设置</h4>\r\n <div class="setting-item">\r\n <label class="checkbox-label">\r\n <input type="checkbox" id="ykt-input-auto-answer">\r\n <span class="checkmark"></span>\r\n 启用自动作答\r\n </label>\r\n </div>\r\n <div class="setting-item">\r\n <label class="checkbox-label">\r\n <input type="checkbox" id="ykt-input-ai-auto-analyze">\r\n <span class="checkmark"></span>\r\n 打开 AI 页面时自动分析\r\n </label>\r\n <small>开启后,进入“AI 解答”面板即自动向 AI 询问当前题目</small>\r\n </div>\r\n <div class="setting-item">\r\n <label for="ykt-input-answer-delay">作答延迟时间 (秒):</label>\r\n <input type="number" id="ykt-input-answer-delay" min="1" max="60">\r\n <small>题目出现后等待多长时间开始作答</small>\r\n </div>\r\n <div class="setting-item">\r\n <label for="ykt-input-random-delay">随机延迟范围 (秒):</label>\r\n <input type="number" id="ykt-input-random-delay" min="0" max="30">\r\n <small>在基础延迟基础上随机增加的时间范围</small>\r\n </div><div class="setting-item">\r\n <label class="checkbox-label">\r\n <input type="checkbox" id="ykt-ai-pick-main-first">\r\n <span class="checkmark"></span>\r\n 主界面优先(未勾选则课件浏览优先)\r\n </label>\r\n <small>仅在普通打开 AI 面板(ykt:open-ai)时生效;从“提问当前PPT”跳转保持最高优先。</small>\r\n </div>\r\n </div> \r\n <div class="setting-actions">\r\n <button id="ykt-btn-settings-save">保存设置</button>\r\n <button id="ykt-btn-settings-reset">重置为默认</button>\r\n </div>\r\n </div>\r\n </div>\r\n</div>\r\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"); const $priorityRadios = root$4.querySelector("#ykt-ai-pick-main-first"); $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); ui.config.aiSlidePickPriority || "main"; $priorityRadios.checked = ui.config.aiSlidePickMainFirst !== false; 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); ui.config.aiSlidePickPriority = !!$priorityRadios.checked; 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); ui.config.aiSlidePickPriority = DEFAULT_CONFIG.aiSlidePickPriority ?? true; 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); $priorityRadios.checked = DEFAULT_CONFIG.aiSlidePickPriority ?? true; 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">\r\n <div class="panel-header">\r\n <h3><i class="fas fa-robot"></i> AI 融合分析</h3>\r\n <span id="ykt-ai-close" class="close-btn" title="关闭">\r\n <i class="fas fa-times"></i>\r\n </span>\r\n </div>\r\n <div class="panel-body">\r\n <div style="margin-bottom: 10px;">\r\n <strong>当前题目:</strong>\r\n <div style="font-size: 12px; color: #666; margin: 4px 0;">\r\n 系统将自动识别当前页面的题目\r\n </div>\r\n <div id="ykt-ai-text-status" class="text-status warning">\r\n 正在检测题目信息...\r\n </div>\r\n <div id="ykt-ai-question-display" class="ykt-question-display">\r\n 提示:系统使用融合模式,同时分析题目文本信息和页面图像,提供最准确的答案。\r\n </div>\r\n </div>\r\n \x3c!-- 当前要提问的PPT预览(来自presentation传入时显示) --\x3e\r\n <div id="ykt-ai-selected" style="display:none; margin: 10px 0;">\r\n <strong>已选PPT预览:</strong>\r\n <div style="font-size: 12px; color: #666; margin: 4px 0;">\r\n 下方小图为即将用于分析的PPT页面截图\r\n </div>\r\n <div style="border: 1px solid var(--ykt-border-strong); padding: 6px; border-radius: 6px; display: inline-block;">\r\n <img id="ykt-ai-selected-thumb"\r\n alt="已选PPT预览"\r\n style="max-width: 180px; max-height: 120px; display:block;" />\r\n </div>\r\n </div>\r\n <div style="margin-bottom: 10px;">\r\n <strong>自定义提示(可选):</strong>\r\n <div style="font-size: 12px; color: #666; margin: 4px 0;">\r\n 提示:此内容将追加到系统生成的prompt后面,可用于补充特殊要求或背景信息。\r\n </div>\r\n <textarea \r\n id="ykt-ai-custom-prompt" \r\n class="ykt-custom-prompt"\r\n placeholder="例如:请用中文回答、注重解题思路、考虑XXX知识点等"\r\n ></textarea>\r\n </div>\r\n\r\n <button id="ykt-ai-ask" style="width: 100%; height: 32px; border-radius: 6px; border: 1px solid var(--ykt-border-strong); background: #f7f8fa; cursor: pointer; margin-bottom: 10px;">\r\n <i class="fas fa-brain"></i> 融合模式分析(文本+图像)\r\n </button>\r\n\r\n <div id="ykt-ai-loading" class="ai-loading" style="display: none;">\r\n <i class="fas fa-spinner fa-spin"></i> AI正在使用融合模式分析...\r\n </div>\r\n <div id="ykt-ai-error" class="ai-error" style="display: none;"></div>\r\n <div>\r\n <strong>AI 分析结果:</strong>\r\n <div id="ykt-ai-answer" class="ai-answer"></div>\r\n </div>\r\n \x3c!-- ✅ 新增:可编辑答案区(默认隐藏;当检测到题目并成功解析parsed时显示) --\x3e\r\n <div id="ykt-ai-edit-section" style="display:none; margin-top:12px;">\r\n <strong>提交前可编辑答案:</strong>\r\n <div style="font-size: 12px; color: #666; margin: 4px 0;">\r\n 提示:这里是将要提交的“结构化答案”。可直接编辑。支持:\r\n <br>• 选择题/投票:填写 <code>["A"]</code> 或 <code>A,B</code>\r\n <br>• 填空题:填写 <code>[" 1"]</code> 或 直接写 <code> 1</code>(自动包成数组)\r\n <br>• 主观题:可填 JSON(如 <code>{"content":"略","pics":[]}</code>)或直接输入文本\r\n </div>\r\n <textarea id="ykt-ai-answer-edit"\r\n style="width:100%; min-height:88px; border:1px solid var(--ykt-border-strong); border-radius:6px; padding:6px; font-family:monospace;"></textarea>\r\n <div id="ykt-ai-validate" style="font-size:12px; color:#666; margin-top:6px;"></div>\r\n <div style="margin-top:8px; display:flex; gap:8px;">\r\n <button id="ykt-ai-submit" class="ykt-btn ykt-btn-primary" style="flex:0 0 auto;">\r\n 提交编辑后的答案\r\n </button>\r\n <button id="ykt-ai-reset-edit" class="ykt-btn" style="flex:0 0 auto;">重置为 AI 建议</button>\r\n </div>\r\n </div>\r\n </div>\r\n</div>'; // src/ai/kimi.js // ----------------------------------------------- // Unified Prompt blocks for Text & Vision // ----------------------------------------------- const BASE_SYSTEM_PROMPT = [ "你是 Kimi,由 Moonshot AI 提供的人工智能助手。你需要在以下规则下工作:", "1) 任何时候优先遵循【用户输入(优先级最高)】中的明确要求;", "2) 当输入是课件页面(PPT)图像或题干文本时,先判断是否存在“明确题目”;", "3) 若存在明确题目,则输出以下格式的内容:", " 单选:格式要求:\n答案: [单个字母]\n解释: [选择理由]\n\n注意:只选一个,如A", " 多选:格式要求:\n答案: [多个字母用顿号分开]\n解释: [选择理由]\n\n注意:格式如A、B、C", " 投票:格式要求:\n答案: [单个字母]\n解释: [选择理由]\n\n注意:只选一个选项", " 填空/主观题: 格式要求:答案: [直接给出答案内容],解释: [简要说明]", "4) 若识别不到明确题目,直接使用回答用户输入的问题", "3) 如果PROMPT格式不正确,或者你只接收了图片,输出:", " STATE: NO_PROMPT", " SUMMARY: <介绍页面/上下文的主要内容>" ].join("\n"); // Vision 补充:识别题型与版面元素的步骤说明 const VISION_GUIDE = [ "【视觉识别要求】", "A. 先判断是否为题目页面(是否有题干/选项/空格/问句等)", "B. 若是题目,尝试提取题干、选项与关键信息;", "C. 否则参考用户输入回答" ].join("\n"); /** * 调用 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,/, ""); // 统一化:使用 BASE_SYSTEM_PROMPT + VISION_GUIDE,并要求先做“是否有题目”的决策 const visionTextHeader = [ "【融合模式说明】你将看到一张课件/PPT截图与可选的附加文本。", VISION_GUIDE ].join("\n"); // ✅ 按照文档要求构建消息格式 const messages = [ { role: "system", content: BASE_SYSTEM_PROMPT }, { role: "user", content: [ { type: "image_url", image_url: { url: `data:image/png;base64,${cleanBase64}` } }, { type: "text", text: [ visionTextHeader, "【用户输入(优先级最高)】", textPrompt || "(无)" ].join("\n") } ] } ]; 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 }); }); } // 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/panels/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"; 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-answer">\n <div class="label">AI分析结果:</div>\n <div class="content">${esc(aiAnswer || "无AI回答").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?.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; } } /** * ✅ 新方法:获取指定幻灯片的截图 * @param {string} slideId - 幻灯片ID * @returns {Promise<string|null>} base64图片数据 */ async function captureSlideImage(slideId) { try { console.log("[captureSlideImage] 获取幻灯片图片:", slideId); const slide = repo.slides.get(slideId); if (!slide) { console.error("[captureSlideImage] 找不到幻灯片:", slideId); return null; } // ✅ 使用 cover 或 coverAlt 图片URL const imageUrl = slide.coverAlt || slide.cover; if (!imageUrl) { console.error("[captureSlideImage] 幻灯片没有图片URL"); return null; } console.log("[captureSlideImage] 图片URL:", imageUrl); // ✅ 下载图片并转换为base64 const base64 = await downloadImageAsBase64(imageUrl); if (!base64) { console.error("[captureSlideImage] 下载图片失败"); return null; } console.log("[captureSlideImage] ✅ 成功获取图片, 大小:", Math.round(base64.length / 1024), "KB"); return base64; } catch (e) { console.error("[captureSlideImage] 失败:", e); return null; } } /** * ✅ 下载图片并转换为base64 * @param {string} url - 图片URL * @returns {Promise<string|null>} */ async function downloadImageAsBase64(url) { return new Promise(resolve => { try { const img = new Image; img.crossOrigin = "anonymous"; // ✅ 允许跨域 img.onload = () => { try { const canvas = document.createElement("canvas"); canvas.width = img.width; canvas.height = img.height; const ctx = canvas.getContext("2d"); ctx.drawImage(img, 0, 0); // ✅ 转换为JPEG格式,压缩质量0.8 const base64 = canvas.toDataURL("image/jpeg", .8).split(",")[1]; // ✅ 如果图片太大,进一步压缩 if (base64.length > 1e6) { // 1MB console.log("[downloadImageAsBase64] 图片过大,进行压缩..."); const compressed = canvas.toDataURL("image/jpeg", .5).split(",")[1]; console.log("[downloadImageAsBase64] 压缩后大小:", Math.round(compressed.length / 1024), "KB"); resolve(compressed); } else resolve(base64); } catch (e) { console.error("[downloadImageAsBase64] Canvas处理失败:", e); resolve(null); } }; img.onerror = e => { console.error("[downloadImageAsBase64] 图片加载失败:", e); resolve(null); }; img.src = url; } catch (e) { console.error("[downloadImageAsBase64] 失败:", e); resolve(null); } }); } // 原有的 captureProblemForVision 保留作为后备方案 async function captureProblemForVision() { try { console.log("[captureProblemForVision] 开始截图..."); const canvas = await captureProblemScreenshot(); if (!canvas) { console.error("[captureProblemForVision] 截图失败"); return null; } console.log("[captureProblemForVision] 截图成功,转换为base64..."); const base64 = canvas.toDataURL("image/jpeg", .8).split(",")[1]; console.log("[captureProblemForVision] base64 长度:", base64.length); if (base64.length > 1e6) { 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; } } // src/tsm/ai-format.js // 预处理题目内容,去除题目类型标识 function cleanProblemBody(body, problemType, TYPE_MAP) { if (!body) return ""; const typeLabel = TYPE_MAP[problemType]; if (!typeLabel) return body; // 去除题目开头的类型标识,如 "填空题:" "单选题:" 等 const pattern = new RegExp(`^${typeLabel}[::\\s]+`, "i"); return body.replace(pattern, "").trim(); } // 改进的融合模式 prompt 格式化函数 function formatProblemForVision(problem, TYPE_MAP, hasTextInfo = false) { const problemType = TYPE_MAP[problem.problemType] || "题目"; let basePrompt = hasTextInfo ? `结合文本信息和图片内容分析${problemType},按格式回答:` : `观察图片内容,识别${problemType}并按格式回答:`; if (hasTextInfo && problem.body) { // ✅ 清理题目内容 const cleanBody = cleanProblemBody(problem.body, problem.problemType, TYPE_MAP); basePrompt += `\n\n【文本信息】\n题目:${cleanBody}`; if (problem.options?.length) { basePrompt += "\n选项:"; for (const o of problem.options) basePrompt += `\n${o.key}. ${o.value}`; } basePrompt += "\n\n若图片内容与文本冲突,以图片为准。"; } // 根据题目类型添加具体格式要求 switch (problem.problemType) { case 1: // 单选题 basePrompt += `\n\n格式要求:\n答案: [单个字母]\n解释: [选择理由]\n\n注意:只选一个,如A`; break; case 2: // 多选题 basePrompt += `\n\n格式要求:\n答案: [多个字母用顿号分开]\n解释: [选择理由]\n\n注意:格式如A、B、C`; break; case 3: // 投票题 basePrompt += `\n\n格式要求:\n答案: [单个字母]\n解释: [选择理由]\n\n注意:只选一个选项`; break; case 4: // 填空题 basePrompt += `\n\n这是一道填空题。\n\n重要说明:\n- 题目内容已经处理,不含"填空题"等字样\n- 观察图片和文本,找出需要填入的内容\n- 答案中不要出现任何题目类型标识\n\n格式要求:\n答案: [直接给出填空内容]\n解释: [简要说明]\n\n示例:\n答案: 氧气,葡萄糖\n解释: 光合作用的产物\n\n多个填空用逗号分开`; break; case 5: // 主观题 basePrompt += `\n\n格式要求:\n答案: [完整回答]\n解释: [补充说明]\n\n注意:直接回答,不要重复题目`; break; default: basePrompt += `\n\n格式要求:\n答案: [你的答案]\n解释: [详细解释]`; } return basePrompt; } // 改进的答案解析函数 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() || ""; console.log("[parseAIAnswer] 题目类型:", problem.problemType, "原始答案行:", answerLine); switch (problem.problemType) { case 1: // 单选题 case 3: { // 投票题 let m = answerLine.match(/[ABCDEFGHIJKLMNOPQRSTUVWXYZ]/); if (m) { console.log("[parseAIAnswer] 单选/投票解析结果:", [ m[0] ]); return [ m[0] ]; } const chineseMatch = answerLine.match(/选择?([ABCDEFGHIJKLMNOPQRSTUVWXYZ])/); if (chineseMatch) { console.log("[parseAIAnswer] 单选/投票中文解析结果:", [ chineseMatch[1] ]); return [ chineseMatch[1] ]; } console.log("[parseAIAnswer] 单选/投票解析失败"); return null; } case 2: { // 多选题 if (answerLine.includes("、")) { const options = answerLine.split("、").map(s => s.trim().match(/[ABCDEFGHIJKLMNOPQRSTUVWXYZ]/)).filter(m => m).map(m => m[0]); if (options.length > 0) { const result = [ ...new Set(options) ].sort(); console.log("[parseAIAnswer] 多选顿号解析结果:", result); return result; } } if (answerLine.includes(",") || answerLine.includes(",")) { const options = answerLine.split(/[,,]/).map(s => s.trim().match(/[ABCDEFGHIJKLMNOPQRSTUVWXYZ]/)).filter(m => m).map(m => m[0]); if (options.length > 0) { const result = [ ...new Set(options) ].sort(); console.log("[parseAIAnswer] 多选逗号解析结果:", result); return result; } } const letters = answerLine.match(/[ABCDEFGHIJKLMNOPQRSTUVWXYZ]/g); if (letters && letters.length > 1) { const result = [ ...new Set(letters) ].sort(); console.log("[parseAIAnswer] 多选连续解析结果:", result); return result; } if (letters && letters.length === 1) { console.log("[parseAIAnswer] 多选单个解析结果:", letters); return letters; } console.log("[parseAIAnswer] 多选解析失败"); return null; } case 4: { // 填空题 // ✅ 更激进的清理策略 let cleanAnswer = answerLine.replace(/^(填空题|简答题|问答题|题目|答案是?)[::\s]*/gi, "").trim(); console.log("[parseAIAnswer] 清理后答案:", cleanAnswer); // 如果清理后还包含这些词,继续清理 if (/填空题|简答题|问答题|题目/i.test(cleanAnswer)) { cleanAnswer = cleanAnswer.replace(/填空题|简答题|问答题|题目/gi, "").trim(); console.log("[parseAIAnswer] 二次清理后:", cleanAnswer); } const answerLength = cleanAnswer.length; if (answerLength <= 50) { cleanAnswer = cleanAnswer.replace(/^[^\w\u4e00-\u9fa5]+/, "").replace(/[^\w\u4e00-\u9fa5]+$/, ""); const blanks = cleanAnswer.split(/[,,;;\s]+/).filter(Boolean); if (blanks.length > 0) { console.log("[parseAIAnswer] 填空解析结果:", blanks); return blanks; } } if (cleanAnswer) { const result = { content: cleanAnswer, pics: [] }; console.log("[parseAIAnswer] 简答题解析结果:", result); return result; } console.log("[parseAIAnswer] 填空/简答解析失败"); return null; } case 5: { // 主观题 const content = answerLine.replace(/^(主观题|论述题)[::\s]*/i, "").trim(); if (content) { const result = { content: content, pics: [] }; console.log("[parseAIAnswer] 主观题解析结果:", result); return result; } console.log("[parseAIAnswer] 主观题解析失败"); return null; } default: console.log("[parseAIAnswer] 未知题目类型:", problem.problemType); return null; } } catch (e) { console.error("[parseAIAnswer] 解析失败", e); return null; } } /** * Vuex 辅助工具 - 用于获取雨课堂主界面状态 */ /** * 获取 Vue 根实例 * @returns {Vue | null} */ function getVueApp() { try { const app = document.querySelector("#app").__vue__; return app || null; } catch (e) { console.error("[getVueApp] 错误:", e); return null; } } /** * 从 Vuex state 获取主界面当前页面的 slideId * @returns {string | null} */ function getCurrentMainPageSlideId() { try { const app = getVueApp(); if (!app || !app.$store) { console.log("[getCurrentMainPageSlideId] 无法获取 Vue 实例或 store"); return null; } const currSlide = app.$store.state.currSlide; if (!currSlide || !currSlide.sid) { console.log("[getCurrentMainPageSlideId] currSlide 或 sid 未定义"); return null; } console.log("[getCurrentMainPageSlideId] 获取到 slideId:", currSlide.sid, { type: currSlide.type, problemID: currSlide.problemID, index: currSlide.index }); return currSlide.sid; } catch (e) { console.error("[getCurrentMainPageSlideId] 错误:", e); return null; } } /** * 监听主界面页面切换 * @param {Function} callback - 回调函数 (slideId, slideInfo) => void * @returns {Function} - 取消监听的函数 */ function watchMainPageChange(callback) { const app = getVueApp(); if (!app || !app.$store) { console.error("[watchMainPageChange] 无法获取 Vue 实例"); return () => {}; } const unwatch = app.$store.watch(state => state.currSlide, (newSlide, oldSlide) => { if (newSlide && newSlide.sid) { console.log("[主界面页面切换]", { oldSid: oldSlide?.sid, newSid: newSlide.sid, type: newSlide.type, problemID: newSlide.problemID }); callback(newSlide.sid, newSlide); } }, { deep: false }); console.log("✅ 已启动主界面页面切换监听"); return unwatch; } /** * 等待 Vue 实例准备就绪 * @returns {Promise<Vue>} */ function waitForVueReady() { return new Promise(resolve => { const check = () => { const app = getVueApp(); if (app && app.$store) resolve(app); else setTimeout(check, 100); }; check(); }); } let mounted$4 = false; let root$3; // 来自 presentation 的优先提示(一次性优先使用) let preferredSlideFromPresentation = null; 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)); // 使用融合模式 $$4("#ykt-ai-ask")?.addEventListener("click", askAIFusionMode); // ✅ 新增:启动主界面页面切换监听 waitForVueReady().then(() => { watchMainPageChange((slideId, slideInfo) => { console.log("[AI Panel] 主界面页面切换到:", slideId); // 自动更新显示 renderQuestion(); }); }).catch(e => { console.warn("[AI Panel] Vue 实例初始化失败,将使用备用方案:", e); }); mounted$4 = true; return root$3; } window.addEventListener("ykt:open-ai", () => { showAIPanel(true); }); // ✅ 来自 presentation 的“提问当前PPT”事件 window.addEventListener("ykt:ask-ai-for-slide", ev => { const detail = ev?.detail || {}; const {slideId: slideId, imageUrl: imageUrl} = detail; if (slideId) { preferredSlideFromPresentation = { slideId: slideId, imageUrl: imageUrl }; // 若有 URL,直接覆盖 repo 内该页的 image,确保后续 capture 使用该 URL const s = repo.slides.get(slideId); if (s && imageUrl) s.image = imageUrl; } // 打开并刷新 UI + 预览 showAIPanel(true); renderQuestion(); const img = document.getElementById("ykt-ai-selected-thumb"); const box = document.getElementById("ykt-ai-selected"); if (img && box) { img.src = preferredSlideFromPresentation?.imageUrl || ""; box.style.display = preferredSlideFromPresentation?.imageUrl ? "" : "none"; } }); function showAIPanel(visible = true) { mountAIPanel(); root$3.classList.toggle("visible", !!visible); if (visible) { renderQuestion(); if (ui.config.aiAutoAnalyze) queueMicrotask(() => { askAIFusionMode(); }); } 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 || ""; } // 新增:获取用户自定义prompt function getCustomPrompt() { const customPromptEl = $$4("#ykt-ai-custom-prompt"); if (customPromptEl) { const customText = customPromptEl.value.trim(); return customText || ""; } return ""; } function renderQuestion() { // ✅ 显示当前选择逻辑的状态 let displayText = ""; let hasPageSelected = false; let selectionSource = ""; // 0. 若来自 presentation 的优先提示存在,则最高优先 let slide = null; if (preferredSlideFromPresentation?.slideId) { slide = repo.slides.get(preferredSlideFromPresentation.slideId); if (slide) { displayText = `来自课件面板:${slide.title || `第 ${slide.page || slide.index || ""} 页`}`; selectionSource = "课件浏览(传入)"; hasPageSelected = true; } } // 1. 若未命中优先提示,检查主界面 if (!slide) { const prio = !!(ui?.config?.aiSlidePickPriority ?? true); if (prio) { const mainSlideId = getCurrentMainPageSlideId(); slide = mainSlideId ? repo.slides.get(mainSlideId) : null; if (slide) { displayText = `主界面当前页: ${slide.title || `第 ${slide.page || slide.index || ""} 页`}`; selectionSource = "主界面检测"; if (slide.problem) displayText += "\n📝 此页面包含题目"; else displayText += "\n📄 此页面为普通内容页"; hasPageSelected = true; } } else { // 2. 检查课件面板选择 const presentationPanel = document.getElementById("ykt-presentation-panel"); const isPresentationPanelOpen = presentationPanel && presentationPanel.classList.contains("visible"); if (isPresentationPanelOpen && repo.currentSlideId) { slide = repo.slides.get(repo.currentSlideId); if (slide) { displayText = `课件面板选中: ${slide.title || `第 ${slide.page || slide.index || ""} 页`}`; selectionSource = "课件浏览面板"; hasPageSelected = true; if (slide.problem) displayText += "\n📝 此页面包含题目"; else displayText += "\n📄 此页面为普通内容页"; } } else { displayText = `未检测到当前页面${presentationPanel}\n💡 请在课件面板(非侧边栏)中选择页面。`; selectionSource = "无"; } } } const el = document.querySelector("#ykt-ai-question-display"); if (el) el.textContent = displayText; // 同步预览块显示 const img = document.getElementById("ykt-ai-selected-thumb"); const box = document.getElementById("ykt-ai-selected"); if (img && box) if (preferredSlideFromPresentation?.imageUrl) { img.src = preferredSlideFromPresentation.imageUrl; box.style.display = ""; } else box.style.display = "none"; const statusEl = document.querySelector("#ykt-ai-text-status"); if (statusEl) { statusEl.textContent = hasPageSelected ? `✓ 已选择页面(来源:${selectionSource}),可进行图像分析` : "⚠ 请选择要分析的页面"; statusEl.className = hasPageSelected ? "text-status success" : "text-status warning"; } } // 融合模式AI询问函数(仅图像分析)- 支持自定义prompt async function askAIFusionMode() { setAIError(""); setAILoading(true); setAIAnswer(""); try { if (!ui.config.ai.kimiApiKey) throw new Error("请先在设置中配置 Kimi API Key"); // ✅ 智能选择当前页面:优先“presentation 传入”,其后主界面、最后课件面板 let currentSlideId = null; let slide = null; let selectionSource = ""; let forcedImageUrl = null; // 0) 优先使用 presentation 传入的 slide if (preferredSlideFromPresentation?.slideId) { currentSlideId = preferredSlideFromPresentation.slideId; slide = repo.slides.get(currentSlideId); forcedImageUrl = preferredSlideFromPresentation.imageUrl || null; selectionSource = "课件浏览(传入)"; console.log("[AI Panel] 使用presentation传入的页面:", currentSlideId); } // 1) 其后:主界面当前页面 if (!slide) { const prio = !!(ui?.config?.aiSlidePickPriority ?? true); if (prio) { const mainSlideId = getCurrentMainPageSlideId(); if (mainSlideId) { currentSlideId = mainSlideId; slide = repo.slides.get(currentSlideId); selectionSource = "主界面当前页面"; console.log("[AI Panel] 使用主界面当前页面:", currentSlideId); } } else { const presentationPanel = document.getElementById("ykt-presentation-panel"); const isPresentationPanelOpen = presentationPanel && presentationPanel.classList.contains("visible"); if (isPresentationPanelOpen && repo.currentSlideId) { currentSlideId = repo.currentSlideId; slide = repo.slides.get(currentSlideId); selectionSource = "课件浏览面板"; console.log("[AI Panel] 使用课件面板选中的页面:", currentSlideId); } } } // 3. 检查是否成功获取到页面 if (!currentSlideId || !slide) throw new Error("无法确定要分析的页面。请在主界面打开一个页面,或在课件浏览中选择页面。"); console.log("[AI Panel] 页面选择来源:", selectionSource); console.log("[AI Panel] 分析页面ID:", currentSlideId); console.log("[AI Panel] 页面信息:", slide); // ✅ 直接使用选中页面的图片 console.log("[AI Panel] 获取页面图片..."); ui.toast(`正在获取${selectionSource}图片...`, 2e3); let imageBase64 = null; // 若 presentation 传入了 URL,则优先用该 URL(captureSlideImage 会读 slide.image) if (forcedImageUrl) // 确保 slide.image 是这张图,captureSlideImage 将基于 slideId 取图 if (slide) slide.image = forcedImageUrl; imageBase64 = await captureSlideImage(currentSlideId); if (!imageBase64) throw new Error("无法获取页面图片,请确保页面已加载完成"); console.log("[AI Panel] ✅ 页面图片获取成功"); console.log("[AI Panel] 图像大小:", Math.round(imageBase64.length / 1024), "KB"); // ✅ 构建纯图像分析提示(不使用题目文本) let textPrompt = `【页面说明】当前页面可能不是题目页;请结合用户提示作答。`; // 获取用户自定义prompt并追加 const customPrompt = getCustomPrompt(); if (customPrompt) { textPrompt += `\n\n【用户自定义要求】\n${customPrompt}`; console.log("[AI Panel] 用户添加了自定义prompt:", customPrompt); } ui.toast(`正在分析${selectionSource}内容...`, 3e3); console.log("[AI Panel] 调用Vision API..."); console.log("[AI Panel] 使用的提示:", textPrompt); const aiContent = await queryKimiVision(imageBase64, textPrompt, ui.config.ai); setAILoading(false); console.log("[AI Panel] Vision API调用成功"); console.log("[AI Panel] AI回答:", aiContent); // ✅ 尝试解析答案(如果当前页面有题目的话) let parsed = null; const problem = slide?.problem; if (problem) { parsed = parseAIAnswer(problem, aiContent); console.log("[AI Panel] 解析结果:", parsed); } // 构建显示内容 let displayContent = `${selectionSource}图像分析结果:\n${aiContent}`; if (customPrompt) displayContent = `${selectionSource}图像分析结果(包含自定义要求):\n${aiContent}`; if (parsed && problem) { setAIAnswer(`${displayContent}\n\nAI 建议答案:${JSON.stringify(parsed)}`); // // ✅ 只有当前页面有题目时才显示提交按钮 // const submitBtn = document.createElement('button'); // submitBtn.textContent = '提交答案'; // submitBtn.className = 'ykt-btn ykt-btn-primary'; // submitBtn.onclick = async () => { // try { // if (!problem || !problem.problemId) { // ui.toast('当前页面没有可提交的题目'); // return; // } // console.log('[AI Panel] 准备提交答案'); // console.log('[AI Panel] Problem:', problem); // console.log('[AI Panel] Parsed:', parsed); // await submitAnswer(problem, parsed); // ui.toast('提交成功'); // showAutoAnswerPopup(problem, aiContent); // } catch (e) { // console.error('[AI Panel] 提交失败:', e); // ui.toast(`提交失败: ${e.message}`); // } // }; // $('#ykt-ai-answer').appendChild(document.createElement('br')); // $('#ykt-ai-answer').appendChild(submitBtn); // ✅ 改为:显示“可编辑答案区”,预填 parsed,并提供“提交编辑后的答案” const editBox = $$4("#ykt-ai-answer-edit"); const editSec = $$4("#ykt-ai-edit-section"); const submitBtn = $$4("#ykt-ai-submit"); const resetBtn = $$4("#ykt-ai-reset-edit"); const validEl = $$4("#ykt-ai-validate"); if (editBox && editSec && submitBtn && resetBtn) { editSec.style.display = ""; const aiSuggested = JSON.stringify(parsed); editBox.value = aiSuggested; validEl.textContent = "已载入 AI 建议答案,可编辑后提交。"; // 变化时做一次轻量校验提示 editBox.oninput = () => { try { // 尝试 JSON;失败也不报红,交由 coerceEditedAnswer 兜底 JSON.parse(editBox.value); validEl.textContent = "解析正常(JSON)。"; validEl.style.color = "#2a6"; } catch { validEl.textContent = "非 JSON,将按题型做容错解析。"; validEl.style.color = "#666"; } }; submitBtn.onclick = async () => { try { if (!problem?.problemId) { ui.toast("当前页面没有可提交的题目"); return; } // ✅ 关键修复:先尝试把文本解析为 JSON;失败则回退到 parsed(结构化对象/数组) const raw = (editBox.value || "").trim(); let payload = null; try { payload = raw ? JSON.parse(raw) : null; } catch { payload = null; } if (payload == null) payload = parsed; // 回退 console.log("[AI Panel] 准备提交(编辑后):", payload); await submitAnswer(problem, payload); ui.toast("提交成功"); showAutoAnswerPopup(problem, aiContent); } catch (e) { console.error("[AI Panel] 提交失败:", e); ui.toast(`提交失败: ${e.message}`); } }; resetBtn.onclick = () => { editBox.value = aiSuggested; validEl.textContent = "已重置为 AI 建议答案。"; validEl.style.color = "#666"; }; } } else { // ✅ 如果当前页面没有题目,告知用户 if (!problem) displayContent += "\n\n💡 当前页面不是题目页面(或未识别到题目)。若要提问,请在上方输入框中补充你的问题(已被最高优先级处理)。"; else displayContent += "\n\n⚠️ 无法自动解析答案格式,请检查AI回答是否符合要求格式。"; setAIAnswer(displayContent); } } catch (e) { setAILoading(false); console.error("[AI Panel] 页面分析失败:", e); // 失败后不清除 preferred,便于用户修正后重试 let errorMsg = `页面分析失败: ${e.message}`; if (e.message.includes("400")) errorMsg += "\n\n可能的解决方案:\n1. 检查 API Key 是否正确\n2. 尝试刷新页面后重试\n3. 确保页面已完全加载"; setAIError(errorMsg); } } /** * 获取主界面当前显示的页面ID * @returns {string|null} 当前页面的slideId */ // function getCurrentMainPageSlideId() { // try { // // 方法1:从当前最近遇到的问题获取(最可能是当前页面) // if (repo.encounteredProblems.length > 0) { // const latestProblem = repo.encounteredProblems.at(-1); // const problemStatus = repo.problemStatus.get(latestProblem.problemId); // if (problemStatus && problemStatus.slideId) { // console.log('[getCurrentMainPageSlideId] 从最近问题获取:', problemStatus.slideId); // return problemStatus.slideId; // } // } // // 方法2:从DOM结构尝试获取(雨课堂可能的DOM结构) // const slideElements = [ // document.querySelector('[data-slide-id]'), // document.querySelector('.slide-wrapper.active'), // document.querySelector('.ppt-slide.active'), // document.querySelector('.current-slide') // ]; // for (const el of slideElements) { // if (el) { // const slideId = el.dataset?.slideId || el.getAttribute('data-slide-id'); // if (slideId) { // console.log('[getCurrentMainPageSlideId] 从DOM获取:', slideId); // return slideId; // } // } // } // // 方法3:如果没有找到,返回null // console.log('[getCurrentMainPageSlideId] 无法获取主界面当前页面'); // return null; // } catch (e) { // console.error('[getCurrentMainPageSlideId] 获取失败:', e); // return null; // } // } // 保留其他函数以向后兼容,但现在都指向融合模式 async function askAIForCurrent() { return askAIFusionMode(); } var tpl$3 = '<div id="ykt-presentation-panel" class="ykt-panel">\r\n <div class="panel-header">\r\n <h3>课件浏览</h3>\r\n <div class="panel-controls">\r\n <label>\r\n <input type="checkbox" id="ykt-show-all-slides"> 切换全部页面/问题页面\r\n </label>\r\n <button id="ykt-ask-current">提问当前PPT</button>\r\n <button id="ykt-open-problem-list">题目列表</button>\r\n <button id="ykt-download-current">截图下载</button>\r\n <button id="ykt-download-pdf">整册下载(PDF)</button>\r\n <span class="close-btn" id="ykt-presentation-close"><i class="fas fa-times"></i></span>\r\n </div>\r\n </div>\r\n\r\n <div class="panel-body">\r\n <div class="panel-left">\r\n <div id="ykt-presentation-list" class="presentation-list"></div>\r\n </div>\r\n <div class="panel-right">\r\n <div id="ykt-slide-view" class="slide-view">\r\n <div class="slide-cover">\r\n <div class="empty-message">选择左侧的幻灯片查看详情</div>\r\n </div>\r\n <div id="ykt-problem-view" class="problem-view"></div>\r\n </div>\r\n </div>\r\n </div>\r\n</div>\r\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")); }); // 1.18.4: 提问当前PPT:把当前 slide 信息传给 AI 面板 $$3("#ykt-ask-current")?.addEventListener("click", () => { if (!repo.currentSlideId) return ui.toast("请先在左侧选择一页PPT", 2500); const slide = repo.slides.get(repo.currentSlideId); const imageUrl = slide?.image || slide?.thumbnail || ""; // 通知 AI 面板:优先使用传入的 slide 和 URL window.dispatchEvent(new CustomEvent("ykt:ask-ai-for-slide", { detail: { slideId: repo.currentSlideId, imageUrl: imageUrl } })); // 打开 AI 面板 window.dispatchEvent(new CustomEvent("ykt:open-ai")); }); $$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">\r\n <div class="panel-header">\r\n <h3>课堂习题列表</h3>\r\n <span class="close-btn" id="ykt-problem-list-close"><i class="fas fa-times"></i></span>\r\n </div>\r\n\r\n <div class="panel-body">\r\n <div id="ykt-problem-list" class="problem-list">\r\n \x3c!-- 由 problem-list.js 动态填充:\r\n .problem-row\r\n .problem-title\r\n .problem-meta\r\n .problem-actions (查看 / AI解答 / 已作答) --\x3e\r\n </div>\r\n </div>\r\n</div>\r\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">\r\n <div id="ykt-active-problems" class="active-problems"></div>\r\n</div>\r\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(); let hasActiveProblems = false; // ✅ 跟踪是否有活跃题目 repo.problemStatus.forEach((status, pid) => { const p = repo.problems.get(pid); if (!p || p.result) return; const remain = Math.max(0, Math.floor((status.endTime - now) / 1e3)); // ✅ 如果倒计时结束(剩余时间为0),跳过显示这个卡片 if (remain <= 0) { console.log(`[ActiveProblems] 题目 ${pid} 倒计时已结束,移除卡片`); return; } // ✅ 有至少一个活跃题目 hasActiveProblems = true; 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 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); }); // ✅ 如果没有活跃题目,隐藏整个面板容器 if (!hasActiveProblems) root$1.style.display = "none"; else root$1.style.display = ""; } var tpl = '<div id="ykt-tutorial-panel" class="ykt-panel">\r\n <div class="panel-header">\r\n <h3>雨课堂助手使用教程</h3>\r\n <h5>1.18.5</h5>\r\n <span class="close-btn" id="ykt-tutorial-close"><i class="fas fa-times"></i></span>\r\n </div>\r\n\r\n <div class="panel-body">\r\n <div class="tutorial-content">\r\n <h4>功能介绍</h4>\r\n <p>AI雨课堂助手是一个为雨课堂提供辅助功能的工具,可以帮助你更好地参与课堂互动。</p>\r\n <p>项目仓库:<a href="https://github.com/ZaytsevZY/yuketang-helper-auto" target="_blank" rel="noopener">GitHub</a></p>\r\n <p>脚本安装:<a href="https://greasyfork.org/zh-CN/scripts/531469-ai%E9%9B%A8%E8%AF%BE%E5%A0%82%E5%8A%A9%E6%89%8B-%E6%A8%A1%E5%9D%97%E5%8C%96%E6%9E%84%E5%BB%BA%E7%89%88" target="_blank" rel="noopener">GreasyFork</a></p>\r\n\r\n <h4>工具栏按钮说明</h4>\r\n <ul>\r\n <li><i class="fas fa-bell"></i> <b>习题提醒</b>:切换是否在新习题出现时显示通知提示(蓝色=开启)。</li>\r\n <li><i class="fas fa-file-powerpoint"></i> <b>课件浏览</b>:查看课件与题目页面,提问可见内容。</li>\r\n <li><i class="fas fa-robot"></i> <b>AI 解答</b>:向 AI 询问当前题目并显示建议答案。</li>\r\n <li><i class="fas fa-magic-wand-sparkles"></i> <b>自动作答</b>:切换自动作答(蓝色=开启)。</li>\r\n <li><i class="fas fa-cog"></i> <b>设置</b>:配置 API 密钥与自动作答参数。</li>\r\n <li><i class="fas fa-question-circle"></i> <b>使用教程</b>:显示/隐藏当前教程页面。</li>\r\n </ul>\r\n\r\n <h4>自动作答</h4>\r\n <ul>\r\n <li>在设置中开启自动作答并配置延迟/随机延迟。</li>\r\n <li>需要配置 <del>DeepSeek API</del> Kimi API 密钥。</li>\r\n <li>答案来自 AI,结果仅供参考。</li>\r\n </ul>\r\n\r\n <h4>AI 解答</h4>\r\n <ol>\r\n <li>点击设置(<i class="fas fa-cog"></i>)填入 API Key。</li>\r\n <li>点击 AI 解答(<i class="fas fa-robot"></i>)后会对“当前题目/最近遇到的题目”询问并解析。</li>\r\n </ol>\r\n\r\n <h4>注意事项</h4>\r\n <p>1) 仅供学习参考,请独立思考;</p>\r\n <p>2) 合理使用 API 额度;</p>\r\n <p>3) 答案不保证 100% 正确;</p>\r\n <p>4) 自动作答有一定风险,谨慎开启。</p>\r\n\r\n <h4>联系方式</h4>\r\n <ul>\r\n <li>请在Github issue提出问题</li>\r\n </ul>\r\n </div>\r\n </div>\r\n</div>\r\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: 5000, // }); }, 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 let _autoLoopStarted = false; // 1.18.5: 本地默认答案生成(无 API Key 时使用,保持 AutoAnswer 流程通畅) function makeDefaultAnswer(problem) { switch (problem.problemType) { case 1: // 单选 case 2: // 多选 case 3: // 投票 return [ "A" ]; case 4: // 填空 // 按需求示例返回 [" 1"](保留前导空格) return [ " 1" ]; case 5: // 主观/问答 return { content: "略", pics: [] }; default: // 兜底:按单选处理 return [ "A" ]; } } // 内部自动答题处理函数 - 融合模式(文本+图像) async function handleAutoAnswerInternal(problem) { const status = repo.problemStatus.get(problem.problemId); if (!status || status.answering || problem.result) { console.log("[AutoAnswer] 跳过:", { hasStatus: !!status, answering: status?.answering, hasResult: !!problem.result }); return; } if (Date.now() >= status.endTime) { console.log("[AutoAnswer] 跳过:已超时"); return; } status.answering = true; try { console.log("[AutoAnswer] ================================="); console.log("[AutoAnswer] 开始自动答题"); console.log("[AutoAnswer] 题目ID:", problem.problemId); console.log("[AutoAnswer] 题目类型:", PROBLEM_TYPE_MAP[problem.problemType]); console.log("[AutoAnswer] 题目内容:", problem.body?.slice(0, 50) + "..."); if (!ui.config.ai.kimiApiKey) { // ✅ 无 API Key:使用本地默认答案直接提交,确保流程不中断 const parsed = makeDefaultAnswer(problem); console.log("[AutoAnswer] 无 API Key,使用本地默认答案:", JSON.stringify(parsed)); // 提交答案(根据时限自动选择 answer/retry 逻辑) await submitAnswer(problem, parsed, { startTime: status.startTime, endTime: status.endTime, forceRetry: false }); // 更新状态与UI actions.onAnswerProblem(problem.problemId, parsed); status.done = true; status.answering = false; ui.toast("✅ 使用默认答案完成作答(未配置 API Key)", 3e3); showAutoAnswerPopup(problem, "(本地默认答案:无 API Key)"); console.log("[AutoAnswer] ✅ 默认答案提交流程结束"); return; // 提前返回,避免继续走图像+AI流程 } const slideId = status.slideId; console.log("[AutoAnswer] 题目所在幻灯片:", slideId); console.log("[AutoAnswer] ================================="); // ✅ 关键修复:直接使用幻灯片的cover图片,而不是截图DOM console.log("[AutoAnswer] 使用融合模式分析(文本+幻灯片图片)..."); const imageBase64 = await captureSlideImage(slideId); // ✅ 如果获取幻灯片图片失败,回退到DOM截图 if (!imageBase64) { console.log("[AutoAnswer] 无法获取幻灯片图片,尝试使用DOM截图..."); const fallbackImage = await captureProblemForVision(); if (!fallbackImage) { status.answering = false; console.error("[AutoAnswer] 所有截图方法都失败"); return ui.toast("无法获取题目图像,跳过自动作答", 3e3); } imageBase64 = fallbackImage; console.log("[AutoAnswer] ✅ DOM截图成功"); } else console.log("[AutoAnswer] ✅ 幻灯片图片获取成功"); console.log("[AutoAnswer] 图片大小:", Math.round(imageBase64.length / 1024), "KB"); // 构建提示 const hasTextInfo = problem.body && problem.body.trim(); const textPrompt = formatProblemForVision(problem, PROBLEM_TYPE_MAP, hasTextInfo); console.log("[AutoAnswer] 文本信息:", hasTextInfo ? "有" : "无"); console.log("[AutoAnswer] 提示长度:", textPrompt.length); // 调用 AI ui.toast("AI 正在分析题目...", 2e3); const aiAnswer = await queryKimiVision(imageBase64, textPrompt, ui.config.ai); console.log("[AutoAnswer] ✅ AI回答:", aiAnswer); // 解析答案 const parsed = parseAIAnswer(problem, aiAnswer); console.log("[AutoAnswer] 解析结果:", parsed); if (!parsed) { status.answering = false; console.error("[AutoAnswer] 解析失败,AI回答格式不正确"); return ui.toast("无法解析AI答案,请检查格式", 3e3); } console.log("[AutoAnswer] ✅ 准备提交答案:", JSON.stringify(parsed)); // 提交答案 await submitAnswer(problem, parsed, { startTime: status.startTime, endTime: status.endTime, forceRetry: false }); console.log("[AutoAnswer] ✅ 提交成功"); // 更新状态 actions.onAnswerProblem(problem.problemId, parsed); status.done = true; status.answering = false; ui.toast(`✅ 自动作答完成`, 3e3); showAutoAnswerPopup(problem, aiAnswer); } catch (e) { console.error("[AutoAnswer] ❌ 失败:", e); console.error("[AutoAnswer] 错误堆栈:", e.stack); status.answering = false; ui.toast(`自动作答失败: ${e.message}`, 4e3); } } 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) { console.log("[onUnlockProblem] 题目或幻灯片不存在"); return; } console.log("[onUnlockProblem] 题目解锁"); console.log("[onUnlockProblem] 题目ID:", data.prob); console.log("[onUnlockProblem] 幻灯片ID:", data.sid); console.log("[onUnlockProblem] 课件ID:", data.pres); 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) { console.log("[onUnlockProblem] 题目已过期或已作答,跳过"); return; } 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; console.log(`[onUnlockProblem] 将在 ${Math.floor(delay / 1e3)} 秒后自动作答`); 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); }, 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); }, launchLessonHelper() { 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}`); 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(); }, startAutoAnswerLoop() { if (_autoLoopStarted) return; _autoLoopStarted = true; setInterval(() => { const now = Date.now(); repo.problemStatus.forEach((status, pid) => { if (status.autoAnswerTime !== null && now >= status.autoAnswerTime) { const problem = repo.problems.get(pid); if (problem && !problem.result) { status.autoAnswerTime = null; handleAutoAnswerInternal(problem); } } }); }, 500); } }; // 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); } // 监听后端传递的url const url = function findUrl(obj) { if (!obj || typeof obj !== "object") return null; if (typeof obj.url === "string") return obj.url; if (Array.isArray(obj)) for (const it of obj) { const u = findUrl(it); if (u) return u; } else for (const k in obj) { const v = obj[k]; if (v && typeof v === "object") { const u = findUrl(v); if (u) return u; } } return null; }(message); if (url) { window.dispatchEvent(new CustomEvent("ykt:url-change", { detail: { url: url, raw: message } })); // 如需持久化到 repo,请取消下一行注释(确保已在 repo 定义该字段) repo.currentSelectedUrl = url; console.debug("[雨课堂助手] 当前选择 URL:", url); } } 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 = '/* ===== 通用 & 修复 ===== */\r\n#watermark_layer { display: none !important; visibility: hidden !important; }\r\n.hidden { display: none !important; }\r\n\r\n:root{\r\n --ykt-z: 10000000;\r\n --ykt-border: #ddd;\r\n --ykt-border-strong: #ccc;\r\n --ykt-bg: #fff;\r\n --ykt-fg: #222;\r\n --ykt-muted: #607190;\r\n --ykt-accent: #1d63df;\r\n --ykt-hover: #1e3050;\r\n --ykt-shadow: 0 10px 30px rgba(0,0,0,.18);\r\n}\r\n\r\n/* ===== 工具栏 ===== */\r\n#ykt-helper-toolbar{\r\n position: fixed; z-index: calc(var(--ykt-z) + 1);\r\n left: 15px; bottom: 15px;\r\n /* 移除固定宽度,让内容自适应 */\r\n height: 36px; padding: 5px;\r\n display: flex; gap: 6px; align-items: center;\r\n background: var(--ykt-bg);\r\n border: 1px solid var(--ykt-border-strong);\r\n border-radius: 4px;\r\n box-shadow: 0 1px 4px 3px rgba(0,0,0,.1);\r\n}\r\n\r\n#ykt-helper-toolbar .btn{\r\n display: inline-block; padding: 4px; cursor: pointer;\r\n color: var(--ykt-muted); line-height: 1;\r\n}\r\n#ykt-helper-toolbar .btn:hover{ color: var(--ykt-hover); }\r\n#ykt-helper-toolbar .btn.active{ color: var(--ykt-accent); }\r\n\r\n/* ===== 面板通用样式 ===== */\r\n.ykt-panel{\r\n position: fixed; right: 20px; bottom: 60px;\r\n width: 560px; max-height: 72vh; overflow: auto;\r\n background: var(--ykt-bg); color: var(--ykt-fg);\r\n border: 1px solid var(--ykt-border-strong); border-radius: 8px;\r\n box-shadow: var(--ykt-shadow);\r\n display: none; \r\n /* 提高z-index,确保后打开的面板在最上层 */\r\n z-index: var(--ykt-z);\r\n}\r\n.ykt-panel.visible{ \r\n display: block; \r\n /* 动态提升z-index */\r\n z-index: calc(var(--ykt-z) + 10);\r\n}\r\n\r\n.panel-header{\r\n display: flex; align-items: center; justify-content: space-between;\r\n gap: 12px; padding: 10px 12px; border-bottom: 1px solid var(--ykt-border);\r\n}\r\n.panel-header h3{ margin: 0; font-size: 16px; font-weight: 600; }\r\n.panel-body{ padding: 10px 12px; }\r\n.close-btn{ cursor: pointer; color: var(--ykt-muted); }\r\n.close-btn:hover{ color: var(--ykt-hover); }\r\n\r\n/* ===== 设置面板 (#ykt-settings-panel) ===== */\r\n#ykt-settings-panel .settings-content{ display: flex; flex-direction: column; gap: 14px; }\r\n#ykt-settings-panel .setting-group{ border: 1px dashed var(--ykt-border); border-radius: 6px; padding: 10px; }\r\n#ykt-settings-panel .setting-group h4{ margin: 0 0 8px 0; font-size: 14px; }\r\n#ykt-settings-panel .setting-item{ display: flex; align-items: center; gap: 8px; margin: 8px 0; flex-wrap: wrap; }\r\n#ykt-settings-panel label{ font-size: 13px; }\r\n#ykt-settings-panel input[type="text"],\r\n#ykt-settings-panel input[type="number"]{\r\n height: 30px; border: 1px solid var(--ykt-border-strong);\r\n border-radius: 4px; padding: 0 8px; min-width: 220px;\r\n}\r\n#ykt-settings-panel small{ color: #666; }\r\n#ykt-settings-panel .setting-actions{ display: flex; gap: 8px; margin-top: 6px; }\r\n#ykt-settings-panel button{\r\n height: 30px; padding: 0 12px; border-radius: 6px;\r\n border: 1px solid var(--ykt-border-strong); background: #f7f8fa; cursor: pointer;\r\n}\r\n#ykt-settings-panel button:hover{ background: #eef3ff; border-color: var(--ykt-accent); }\r\n\r\n/* 自定义复选框(与手写脚本一致的视觉语义) */\r\n#ykt-settings-panel .checkbox-label{ position: relative; padding-left: 26px; cursor: pointer; user-select: none; }\r\n#ykt-settings-panel .checkbox-label input{ position: absolute; opacity: 0; cursor: pointer; height: 0; width: 0; }\r\n#ykt-settings-panel .checkbox-label .checkmark{\r\n position: absolute; left: 0; top: 50%; transform: translateY(-50%);\r\n height: 16px; width: 16px; border:1px solid var(--ykt-border-strong); border-radius: 3px; background: #fff;\r\n}\r\n#ykt-settings-panel .checkbox-label input:checked ~ .checkmark{\r\n background: var(--ykt-accent); border-color: var(--ykt-accent);\r\n}\r\n#ykt-settings-panel .checkbox-label .checkmark:after{\r\n content: ""; position: absolute; display: none;\r\n left: 5px; top: 1px; width: 4px; height: 8px; border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg);\r\n}\r\n#ykt-settings-panel .checkbox-label input:checked ~ .checkmark:after{ display: block; }\r\n\r\n/* ===== AI 解答面板 (#ykt-ai-answer-panel) ===== */\r\n#ykt-ai-answer-panel .ai-question{\r\n white-space: pre-wrap; background: #fafafa; border: 1px solid var(--ykt-border);\r\n padding: 8px; border-radius: 6px; margin-bottom: 8px; max-height: 160px; overflow: auto;\r\n}\r\n#ykt-ai-answer-panel .ai-loading{ color: var(--ykt-accent); margin-bottom: 6px; }\r\n#ykt-ai-answer-panel .ai-error{ color: #b00020; margin-bottom: 6px; }\r\n#ykt-ai-answer-panel .ai-answer{ white-space: pre-wrap; margin-top: 4px; }\r\n#ykt-ai-answer-panel .ai-actions{ margin-top: 10px; }\r\n#ykt-ai-answer-panel .ai-actions button{\r\n height: 30px; padding: 0 12px; border-radius: 6px;\r\n border: 1px solid var(--ykt-border-strong); background: #f7f8fa; cursor: pointer;\r\n}\r\n#ykt-ai-answer-panel .ai-actions button:hover{ background: #eef3ff; border-color: var(--ykt-accent); }\r\n\r\n/* ===== 课件浏览面板 (#ykt-presentation-panel) ===== */\r\n#ykt-presentation-panel{ width: 900px; }\r\n#ykt-presentation-panel .panel-controls{ display: flex; align-items: center; gap: 8px; }\r\n#ykt-presentation-panel .panel-body{\r\n display: grid; grid-template-columns: 300px 1fr; gap: 10px;\r\n}\r\n#ykt-presentation-panel .presentation-title{\r\n font-weight: 600; padding: 6px 0; border-bottom: 1px solid var(--ykt-border);\r\n}\r\n#ykt-presentation-panel .slide-thumb-list{ display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 8px; }\r\n#ykt-presentation-panel .slide-thumb{\r\n border: 1px solid var(--ykt-border); border-radius: 6px; background: #fafafa;\r\n min-height: 60px; display: flex; align-items: center; justify-content: center; cursor: pointer; padding: 4px; text-align: center;\r\n}\r\n#ykt-presentation-panel .slide-thumb:hover{ border-color: var(--ykt-accent); background: #eef3ff; }\r\n#ykt-presentation-panel .slide-thumb img{ max-width: 100%; max-height: 120px; object-fit: contain; display: block; }\r\n\r\n#ykt-presentation-panel .slide-view{\r\n position: relative; border: 1px solid var(--ykt-border); border-radius: 8px; min-height: 360px; background: #fff; overflow: hidden;\r\n}\r\n#ykt-presentation-panel .slide-cover{ display: flex; align-items: center; justify-content: center; min-height: 360px; }\r\n#ykt-presentation-panel .slide-cover img{ max-width: 100%; max-height: 100%; object-fit: contain; display: block; }\r\n\r\n#ykt-presentation-panel .problem-box{\r\n position: absolute; left: 12px; right: 12px; bottom: 12px;\r\n background: rgba(255,255,255,.96); border: 1px solid var(--ykt-border);\r\n border-radius: 8px; padding: 10px; box-shadow: 0 6px 18px rgba(0,0,0,.12);\r\n}\r\n#ykt-presentation-panel .problem-head{ font-weight: 600; margin-bottom: 6px; }\r\n#ykt-presentation-panel .problem-options{ display: grid; grid-template-columns: 1fr; gap: 4px; }\r\n#ykt-presentation-panel .problem-option{ padding: 6px 8px; border: 1px solid var(--ykt-border); border-radius: 6px; background: #fafafa; }\r\n\r\n/* ===== 题目列表面板 (#ykt-problem-list-panel) ===== */\r\n#ykt-problem-list{ display: flex; flex-direction: column; gap: 10px; }\r\n#ykt-problem-list .problem-row{\r\n border: 1px solid var(--ykt-border); border-radius: 8px; padding: 8px; background: #fafafa;\r\n}\r\n#ykt-problem-list .problem-title{ font-weight: 600; margin-bottom: 4px; }\r\n#ykt-problem-list .problem-meta{ color: #666; font-size: 12px; margin-bottom: 6px; }\r\n#ykt-problem-list .problem-actions{ display: flex; gap: 8px; align-items: center; }\r\n#ykt-problem-list .problem-actions button{\r\n height: 28px; padding: 0 10px; border-radius: 6px; border: 1px solid var(--ykt-border-strong); background: #f7f8fa; cursor: pointer;\r\n}\r\n#ykt-problem-list .problem-actions button:hover{ background: #eef3ff; border-color: var(--ykt-accent); }\r\n#ykt-problem-list .problem-done{ color: #0a7a2f; font-weight: 600; }\r\n\r\n/* ===== 活动题目列表(右下角小卡片) ===== */\r\n#ykt-active-problems-panel.ykt-active-wrapper{\r\n position: fixed; right: 20px; bottom: 60px; z-index: var(--ykt-z);\r\n}\r\n#ykt-active-problems{ display: flex; flex-direction: column; gap: 8px; max-height: 60vh; overflow: auto; }\r\n#ykt-active-problems .active-problem-card{\r\n width: 320px; background: #fff; border: 1px solid var(--ykt-border);\r\n border-radius: 8px; box-shadow: var(--ykt-shadow); padding: 10px;\r\n}\r\n#ykt-active-problems .ap-title{ font-weight: 600; margin-bottom: 4px; }\r\n#ykt-active-problems .ap-info{ color: #666; font-size: 12px; margin-bottom: 8px; }\r\n#ykt-active-problems .ap-actions{ display: flex; gap: 8px; }\r\n#ykt-active-problems .ap-actions button{\r\n height: 28px; padding: 0 10px; border-radius: 6px; border: 1px solid var(--ykt-border-strong); background: #f7f8fa; cursor: pointer;\r\n}\r\n#ykt-active-problems .ap-actions button:hover{ background: #eef3ff; border-color: var(--ykt-accent); }\r\n\r\n/* ===== 教程面板 (#ykt-tutorial-panel) ===== */\r\n#ykt-tutorial-panel .tutorial-content h4{ margin: 8px 0 6px; }\r\n#ykt-tutorial-panel .tutorial-content p,\r\n#ykt-tutorial-panel .tutorial-content li{ line-height: 1.5; }\r\n#ykt-tutorial-panel .tutorial-content a{ color: var(--ykt-accent); text-decoration: none; }\r\n#ykt-tutorial-panel .tutorial-content a:hover{ text-decoration: underline; }\r\n\r\n/* ===== 小屏适配 ===== */\r\n@media (max-width: 1200px){\r\n #ykt-presentation-panel{ width: 760px; }\r\n #ykt-presentation-panel .panel-body{ grid-template-columns: 260px 1fr; }\r\n}\r\n@media (max-width: 900px){\r\n .ykt-panel{ right: 12px; left: 12px; width: auto; }\r\n #ykt-presentation-panel{ width: auto; }\r\n #ykt-presentation-panel .panel-body{ grid-template-columns: 1fr; }\r\n}\r\n\r\n/* ===== 自动作答成功弹窗 ===== */\r\n.auto-answer-popup{\r\n position: fixed; inset: 0; z-index: calc(var(--ykt-z) + 2);\r\n background: rgba(0,0,0,.2);\r\n display: flex; align-items: flex-end; justify-content: flex-end;\r\n opacity: 0; transition: opacity .18s ease;\r\n}\r\n.auto-answer-popup.visible{ opacity: 1; }\r\n\r\n.auto-answer-popup .popup-content{\r\n width: min(560px, 96vw);\r\n background: #fff; border: 1px solid var(--ykt-border-strong);\r\n border-radius: 10px; box-shadow: var(--ykt-shadow);\r\n margin: 16px; overflow: hidden;\r\n}\r\n\r\n.auto-answer-popup .popup-header{\r\n display: flex; align-items: center; justify-content: space-between;\r\n gap: 12px; padding: 10px 12px; border-bottom: 1px solid var(--ykt-border);\r\n}\r\n.auto-answer-popup .popup-header h4{ margin: 0; font-size: 16px; }\r\n.auto-answer-popup .close-btn{ cursor: pointer; color: var(--ykt-muted); }\r\n.auto-answer-popup .close-btn:hover{ color: var(--ykt-hover); }\r\n\r\n.auto-answer-popup .popup-body{ padding: 10px 12px; display: flex; flex-direction: column; gap: 10px; }\r\n.auto-answer-popup .popup-row{ display: grid; grid-template-columns: 56px 1fr; gap: 8px; align-items: start; }\r\n.auto-answer-popup .label{ color: #666; font-size: 12px; line-height: 1.8; }\r\n.auto-answer-popup .content{ white-space: normal; word-break: break-word; }\r\n\r\n/* ===== 1.16.6: 课件浏览面板:固定右侧详细视图,左侧独立滚动 ===== */\r\n#ykt-presentation-panel {\r\n --ykt-panel-max-h: 72vh; /* 与 .ykt-panel 的最大高度保持一致 */\r\n}\r\n\r\n/* 两列布局:左列表 + 右详细视图 */\r\n#ykt-presentation-panel .panel-body{\r\n display: grid;\r\n grid-template-columns: 300px 1fr; /* 左列宽度可按需调整 */\r\n gap: 12px;\r\n overflow: hidden; /* 避免内部再出现双滚动条 */\r\n align-items: start;\r\n}\r\n\r\n/* 左侧:只让左列滚动,限制在面板可视高度内 */\r\n#ykt-presentation-panel .panel-left{\r\n max-height: var(--ykt-panel-max-h);\r\n overflow: auto;\r\n min-width: 0; /* 防止子元素撑破 */\r\n}\r\n\r\n/* 右侧:粘性定位为“固定”,始终在面板可视区内 */\r\n#ykt-presentation-panel .panel-right{\r\n position: sticky;\r\n top: 0; /* 相对可滚动祖先(面板)吸顶 */\r\n align-self: start;\r\n}\r\n\r\n/* 右侧详细视图自身也限制高度并允许内部滚动 */\r\n#ykt-presentation-panel .slide-view{\r\n max-height: var(--ykt-panel-max-h);\r\n overflow: auto;\r\n border: 1px solid var(--ykt-border);\r\n border-radius: 8px;\r\n background: #fff;\r\n}\r\n\r\n/* 小屏自适配:堆叠布局时取消 sticky,避免遮挡 */\r\n@media (max-width: 900px){\r\n #ykt-presentation-panel .panel-body{\r\n grid-template-columns: 1fr;\r\n }\r\n #ykt-presentation-panel .panel-right{\r\n position: static;\r\n }\r\n}\r\n\r\n/* 在现有样式基础上添加 */\r\n\r\n.text-status {\r\n font-size: 12px;\r\n padding: 4px 8px;\r\n border-radius: 4px;\r\n margin: 4px 0;\r\n display: inline-block;\r\n}\r\n\r\n.text-status.success {\r\n background-color: #d4edda;\r\n color: #155724;\r\n border: 1px solid #c3e6cb;\r\n}\r\n\r\n.text-status.warning {\r\n background-color: #fff3cd;\r\n color: #856404;\r\n border: 1px solid #ffeaa7;\r\n}\r\n\r\n.ykt-question-display {\r\n background: #f8f9fa;\r\n border: 1px solid #dee2e6;\r\n border-radius: 4px;\r\n padding: 8px;\r\n margin: 4px 0;\r\n max-height: 150px;\r\n overflow-y: auto;\r\n font-family: monospace;\r\n font-size: 13px;\r\n line-height: 1.4;\r\n}\r\n\r\n/* 在现有样式基础上添加 */\r\n\r\n.ykt-custom-prompt {\r\n width: 100%;\r\n min-height: 60px;\r\n padding: 8px;\r\n border: 1px solid #ddd;\r\n border-radius: 4px;\r\n font-family: inherit;\r\n font-size: 13px;\r\n line-height: 1.4;\r\n resize: vertical;\r\n background-color: #fff;\r\n transition: border-color 0.3s ease;\r\n}\r\n\r\n.ykt-custom-prompt:focus {\r\n outline: none;\r\n border-color: #007bff;\r\n box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);\r\n}\r\n\r\n.ykt-custom-prompt::placeholder {\r\n color: #999;\r\n font-style: italic;\r\n}\r\n\r\n.ykt-custom-prompt:empty::before {\r\n content: attr(placeholder);\r\n color: #999;\r\n font-style: italic;\r\n pointer-events: none;\r\n}\r\n\r\n/* 确保输入框在暗色主题下也能正常显示 */\r\n.ykt-panel.dark .ykt-custom-prompt {\r\n background-color: #2d3748;\r\n border-color: #4a5568;\r\n color: #e2e8f0;\r\n}\r\n\r\n.ykt-panel.dark .ykt-custom-prompt::placeholder {\r\n color: #a0aec0;\r\n}\r\n\r\n.ykt-panel.dark .ykt-custom-prompt:focus {\r\n border-color: #63b3ed;\r\n box-shadow: 0 0 0 2px rgba(99, 179, 237, 0.25);\r\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(); })(); })();