// ==UserScript==
// @name AI答题
// @namespace https://github.com/QfangY/ai-answer
// @homepageURL https://github.com/QfangY/ai-answer
// @icon https://raw.githubusercontent.com/QfangY/ai-answer/main/icon.svg
// @version 2.1.2
// @description 学习通智能答题助手,支持多AI接口、自动识别题目和答题、可配置控制面板。
// @author QfangY
// @license MIT
// @match https://mooc1.chaoxing.com/mooc-ans/mooc2/work/view*
// @match https://mooc1.chaoxing.com/mooc-ans/mooc2/work/dowork*
// @match https://mooc1.chaoxing.com/mooc-ans/mooc2/exam/*
// @match https://mooc1.chaoxing.com/mooc-ans/mooc-ans-work/*
// @grant GM_setClipboard
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @connect api.deepseek.com
// @connect api.openai.com
// @connect api.xiaomimimo.com
// @connect dashscope.aliyuncs.com
// @connect open.bigmodel.cn
// @connect api-inference.modelscope.cn
// @connect localhost
// @connect 127.0.0.1
// @run-at document-end
// ==/UserScript==
(function() {
'use strict';
// ============================================================
// 常量与默认配置
// ============================================================
const STORAGE_PREFIX = 'chaoxing-ai-v2';
const STORAGE_KEYS = {
aiConfig: `${STORAGE_PREFIX}.ai-config`,
ballPos: `${STORAGE_PREFIX}.ball-position`,
settings: `${STORAGE_PREFIX}.ui-settings`,
wizardSeen: `${STORAGE_PREFIX}.wizard-seen`
};
const AI_PROVIDERS = [
{ id: 'custom', name: '自定义', url: '', model: '', desc: '手动填写API地址、模型和密钥' },
{ id: 'deepseek', name: 'DeepSeek', url: 'https://api.deepseek.com/v1/chat/completions', model: 'deepseek-v4-flash', desc: 'V4-Flash快速模型,国内首选' },
{ id: 'deepseek-p', name: 'DeepSeek(Pro)', url: 'https://api.deepseek.com/v1/chat/completions', model: 'deepseek-v4-pro', desc: 'V4-Pro旗舰模型' },
{ id: 'mimo', name: 'MiMo', url: 'https://api.xiaomimimo.com/v1/chat/completions', model: 'mimo-v2.5-pro', desc: '小米MiMo,按量付费' },
{ id: 'mimo-flash', name: 'MiMo-Flash', url: 'https://api.xiaomimimo.com/v1/chat/completions', model: 'mimo-v2-flash', desc: '小米MiMo-Flash,极速推理' },
{ id: 'qwen', name: '通义千问', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions', model: 'qwen-plus', desc: '阿里云百炼,千问Plus' },
{ id: 'qwen-turbo', name: '千问Turbo', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions', model: 'qwen-turbo', desc: '千问Turbo,速度快价格低' },
{ id: 'zhipu', name: '智谱AI', url: 'https://open.bigmodel.cn/api/paas/v4/chat/completions', model: 'glm-4-flash', desc: 'GLM-4-Flash,免费模型' },
{ id: 'zhipu-5', name: '智谱GLM-5', url: 'https://open.bigmodel.cn/api/paas/v4/chat/completions', model: 'glm-5-turbo', desc: 'GLM-5-Turbo,旗舰模型' },
{ id: 'openai', name: 'OpenAI', url: 'https://api.openai.com/v1/chat/completions', model: 'gpt-4o-mini', desc: 'GPT-4o-mini,需要海外Key' },
{ id: 'ollama', name: 'Ollama(本地)', url: 'http://localhost:11434/v1/chat/completions', model: 'deepseek-r1:8b', desc: '本地部署,无需API Key' }
];
const DEFAULT_AI_CONFIG = {
provider: 'deepseek',
apiUrl: 'https://api.deepseek.com/v1/chat/completions',
model: 'deepseek-v4-flash',
apiKey: '',
temperature: 0.1
};
const DEFAULT_SETTINGS = {
ballSize: 64,
floatDuration: 4.8,
panelWidth: 380,
panelOpacity: 97,
rememberPosition: true,
reducedMotion: false,
autoOpenPage: 'basic',
autoAnswerDelay: 1500,
autoNextQuestion: true
};
const DEFAULT_MARGIN = 20;
// ============================================================
// 工具函数
// ============================================================
const clamp = (v, min, max) => Math.min(Math.max(v, min), max);
const safeParse = (t) => { try { return JSON.parse(t); } catch { return null; } };
const loadJSON = (key, fallback) => {
const raw = GM_getValue(key, null);
if (raw === null) return { ...fallback };
const parsed = typeof raw === 'string' ? safeParse(raw) : raw;
return parsed ? { ...fallback, ...parsed } : { ...fallback };
};
const saveJSON = (key, obj) => GM_setValue(key, JSON.stringify(obj));
// ============================================================
// 状态管理
// ============================================================
let aiConfig = loadJSON(STORAGE_KEYS.aiConfig, DEFAULT_AI_CONFIG);
let uiSettings = loadJSON(STORAGE_KEYS.settings, DEFAULT_SETTINGS);
let autoAnswerRunning = false;
let autoAnswerTimer = null;
const saveAiConfig = () => saveJSON(STORAGE_KEYS.aiConfig, aiConfig);
const saveUiSettings = () => saveJSON(STORAGE_KEYS.settings, uiSettings);
// ============================================================
// AI 接口请求
// ============================================================
function callAiApi(prompt) {
return new Promise((resolve, reject) => {
if (!aiConfig.apiUrl) return reject(new Error('请先配置AI接口地址'));
if (!aiConfig.apiKey && !aiConfig.apiUrl.includes('localhost') && !aiConfig.apiUrl.includes('127.0.0.1')) {
return reject(new Error('请先配置API Key'));
}
const headers = { 'Content-Type': 'application/json' };
if (aiConfig.apiKey) {
headers['Authorization'] = `Bearer ${aiConfig.apiKey}`;
}
const body = JSON.stringify({
model: aiConfig.model,
messages: [
{ role: 'system', content: '你是一个精准的答题助手。用户会给你题目和选项,你需要直接给出答案。只返回JSON数组,不要包含任何其他文字、markdown标记或代码块。格式:[{"questionId":"ID","answer":"答案内容","analysis":"简短解析"}]。选择题答案用选项字母(如A、B、AB),填空题直接给答案文本,判断题用"对"或"错"。' },
{ role: 'user', content: prompt }
],
temperature: aiConfig.temperature,
stream: false
});
const bodySize = new Blob([body]).size;
log(`🤖 正在请求AI (${aiConfig.model})...`, '#74b9ff');
log(`📡 请求地址: ${aiConfig.apiUrl}`, '#636e72');
log(`📦 请求体大小: ${(bodySize / 1024).toFixed(1)} KB`, '#636e72');
const startTime = Date.now();
GM_xmlhttpRequest({
method: 'POST',
url: aiConfig.apiUrl,
headers,
data: body,
timeout: 120000, // 增加到120秒
onload(resp) {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
log(`📥 收到响应,耗时 ${elapsed}s,状态码: ${resp.status}`, '#636e72');
if (resp.status >= 200 && resp.status < 300) {
try {
const data = JSON.parse(resp.responseText);
const content = data.choices?.[0]?.message?.content || '';
const usage = data.usage;
if (usage) {
log(`📊 Token用量: 输入${usage.prompt_tokens} + 输出${usage.completion_tokens} = ${usage.total_tokens}`, '#636e72');
}
log(`✅ AI响应成功,响应长度: ${content.length} 字符`, '#55efc4');
resolve(content);
} catch (e) {
log(`❌ JSON解析失败: ${resp.responseText.substring(0, 200)}`, '#ff7675');
reject(new Error('AI返回格式解析失败'));
}
} else {
let errMsg = `AI请求失败 (${resp.status})`;
try {
const errData = JSON.parse(resp.responseText);
errMsg = errData.error?.message || errData.message || errMsg;
log(`❌ 错误详情: ${JSON.stringify(errData).substring(0, 300)}`, '#ff7675');
} catch {
log(`❌ 响应内容: ${resp.responseText.substring(0, 300)}`, '#ff7675');
}
reject(new Error(errMsg));
}
},
onerror(err) {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
log(`❌ 网络请求失败,耗时 ${elapsed}s`, '#ff7675');
log(`❌ 错误信息: ${err.error || '未知错误'}`, '#ff7675');
reject(new Error('网络请求失败: ' + (err.error || '未知错误')));
},
ontimeout() {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
log(`⏰ 请求超时,耗时 ${elapsed}s`, '#ff7675');
reject(new Error('AI请求超时(120s),可能是网络问题或题目过多'));
}
});
});
}
// ============================================================
// 题目识别
// ============================================================
let questionCache = null;
let lastDetectTime = 0;
function detectQuestions(forceRefresh = false) {
const now = Date.now();
if (!forceRefresh && questionCache && (now - lastDetectTime) < 1000) {
return questionCache;
}
const items = document.querySelectorAll('.questionLi');
if (!items.length) {
questionCache = [];
return questionCache;
}
questionCache = Array.from(items).map((q, i) => {
const id = q.getAttribute('data') || q.id?.replace('question', '') || String(i + 1);
const title = q.querySelector('h3')?.innerText?.replace(/\s+/g, ' ').trim() || '';
const type = q.getAttribute('typename') || '';
let answered = false;
// 检测选择题:查找选中的选项
const selectedOptions = q.querySelectorAll('.answerBg[style], .answerBg.on, .answerBg.selected, .answerBg.click, [class*="selected"]');
for (const opt of selectedOptions) {
const style = opt.getAttribute('style') || '';
if (style.includes('background') || style.includes('color') ||
opt.classList.contains('on') || opt.classList.contains('selected') ||
opt.classList.contains('click')) {
answered = true;
break;
}
}
// 检测填空题/简答题:输入框有内容
if (!answered) {
const inputs = q.querySelectorAll('input[type="text"], input:not([type]), textarea');
for (const input of inputs) {
if (input.value && input.value.trim().length > 0) {
answered = true;
break;
}
}
}
// 检测编辑器:有内容
if (!answered) {
const editors = q.querySelectorAll('.edui-body-container, [contenteditable="true"]');
for (const editor of editors) {
const text = editor.innerText?.trim();
if (text && text.length > 0 && text !== '请在此处输入答案' && text !== '请输入答案') {
answered = true;
break;
}
}
}
// 检测iframe编辑器
if (!answered) {
const iframes = q.querySelectorAll('iframe');
for (const iframe of iframes) {
try {
const body = iframe.contentDocument?.body;
if (body && body.innerText?.trim()?.length > 0) {
answered = true;
break;
}
} catch (e) {}
}
}
const opts = Array.from(q.querySelectorAll('.answerBg, .answer_item, .options li')).map(o => o.innerText.trim()).filter(Boolean);
return { id, title, type, options: opts, answered, element: q };
});
lastDetectTime = now;
return questionCache;
}
function invalidateQuestionCache() {
questionCache = null;
}
// ============================================================
// 题目网格更新(全局)
// ============================================================
let startFromIndex = -1;
let lastGridUpdate = 0;
let gridUpdateTimer = null;
function updateQuestionGrid(force = false) {
const now = Date.now();
if (!force && (now - lastGridUpdate) < 2000) {
return;
}
const grid = document.getElementById('yan-question-grid');
if (!grid) return;
const questions = detectQuestions(force);
if (!questions.length) {
grid.innerHTML = '
未检测到题目
';
lastGridUpdate = now;
return;
}
const answeredCount = questions.filter(q => q.answered).length;
const fragment = document.createDocumentFragment();
questions.forEach((q, i) => {
const cell = document.createElement('div');
cell.className = `yan-question-cell ${q.answered ? 'answered' : 'unanswered'}`;
cell.dataset.index = i;
cell.title = `题目 ${i+1} (ID: ${q.id}) - ${q.answered ? '已答' : '未答'}`;
cell.textContent = i + 1;
fragment.appendChild(cell);
});
grid.innerHTML = '';
grid.appendChild(fragment);
// 添加统计信息
const stats = document.createElement('div');
stats.style.cssText = 'grid-column:1/-1;text-align:center;font-size:11px;color:#718197;padding:4px 0;';
stats.textContent = `共 ${questions.length} 题,已答 ${answeredCount} 题,未答 ${questions.length - answeredCount} 题`;
grid.appendChild(stats);
// 绑定点击事件
grid.querySelectorAll('.yan-question-cell').forEach(cell => {
cell.addEventListener('click', () => {
const index = parseInt(cell.dataset.index);
startFromIndex = index;
if (typeof startAutoAnswerFromIndex === 'function') {
startAutoAnswerFromIndex(index);
}
});
});
lastGridUpdate = now;
}
async function startAutoAnswerFromIndex(startIndex) {
if (autoAnswerRunning) {
stopAutoAnswerLoop();
await new Promise(r => setTimeout(r, 500));
}
autoAnswerRunning = true;
updateAutoAnswerUI();
log(`🚀 从第 ${startIndex + 1} 题开始自动答题`, '#00b894');
const questions = detectQuestions(true);
const unansweredFromStart = questions.slice(startIndex).filter(q => !q.answered);
if (!unansweredFromStart.length) {
log('📋 从该题开始没有未答题目', '#dfe6e9');
stopAutoAnswerLoop();
return;
}
log(`📊 从第 ${startIndex + 1} 题开始,有 ${unansweredFromStart.length} 道未答题`, '#74b9ff');
let totalFilled = 0;
let consecutiveErrors = 0;
for (let i = 0; i < unansweredFromStart.length; i++) {
if (!autoAnswerRunning) {
log('⏹️ 自动答题已停止', '#fdcb6e');
break;
}
const q = unansweredFromStart[i];
const originalIndex = questions.indexOf(q);
log(`📦 处理第 ${originalIndex + 1} 题 (ID: ${q.id})`, '#74b9ff');
let prompt;
try {
prompt = buildPrompt([q]);
log(`📝 Prompt生成成功,长度: ${prompt.length}`, '#636e72');
} catch (e) {
log(`❌ Prompt生成失败: ${e.message}`, '#ff7675');
continue;
}
let response = null;
let success = false;
for (let retry = 0; retry < MAX_RETRIES; retry++) {
if (!autoAnswerRunning) break;
try {
if (retry > 0) {
log(`🔄 第${retry + 1}次重试...`, '#fdcb6e');
await new Promise(r => setTimeout(r, RETRY_DELAY));
} else if (consecutiveErrors >= 3) {
log(`⚠️ 连续失败,等待${RATE_LIMIT_DELAY/1000}秒...`, '#fdcb6e');
await new Promise(r => setTimeout(r, RATE_LIMIT_DELAY));
}
log(`📡 开始请求AI...`, '#636e72');
response = await callAiApi(prompt);
log(`📥 收到AI响应,长度: ${response?.length || 0}`, '#636e72');
success = true;
consecutiveErrors = 0;
break;
} catch (e) {
log(`❌ 请求失败: ${e.message}`, '#ff7675');
consecutiveErrors++;
if (consecutiveErrors >= 6) {
log(`🚫 连续${consecutiveErrors}次失败,自动停止`, '#ff7675');
autoAnswerRunning = false;
updateAutoAnswerUI();
invalidateQuestionCache();
updateQuestionGrid(true);
return;
}
}
}
if (!success || !response) {
log(`⏭️ 跳过第${originalIndex + 1}题`, '#fdcb6e');
await new Promise(r => setTimeout(r, REQUEST_DELAY));
continue;
}
const answers = parseAiResponse(response);
if (!answers || !Array.isArray(answers)) {
log(`❌ AI返回格式无法解析`, '#ff7675');
continue;
}
for (const item of answers) {
const qDom = document.querySelector(`.questionLi[data="${item.questionId}"], #question${item.questionId}`);
if (!qDom) {
log(`⚠️ 未找到题目 ${item.questionId}`, '#fdcb6e');
continue;
}
log(`📋 题目${item.questionId}答案: ${item.answer}`, '#00cec9');
const type = qDom.getAttribute('typename') || '';
const ok = await fillAnswer(qDom, item, type);
if (ok) {
totalFilled++;
log(`✅ [${totalFilled}/${unansweredFromStart.length}] 题目${item.questionId}已填写`, '#55efc4');
invalidateQuestionCache();
updateQuestionGrid(true);
} else {
log(`⚠️ 题目${item.questionId}填写失败`, '#fdcb6e');
}
}
if (i < unansweredFromStart.length - 1 && autoAnswerRunning) {
log(`⏳ 等待${REQUEST_DELAY/1000}秒...`, '#636e72');
await new Promise(r => setTimeout(r, REQUEST_DELAY));
}
}
log(`🎯 总计已填写 ${totalFilled}/${unansweredFromStart.length} 道题`, '#00b894');
autoAnswerRunning = false;
updateAutoAnswerUI();
invalidateQuestionCache();
updateQuestionGrid(true);
}
function buildPrompt(questions) {
const lines = questions.map((q, i) => {
let line = `[${i + 1}] ID:${q.id}`;
if (q.type) line += ` 题型:${q.type}`;
line += ` 题目:${q.title}`;
if (q.options && q.options.length) line += ` 选项:${q.options.join('|')}`;
return line;
});
return `分析以下题目并严格返回JSON数组。必须包含"analysis"字段给出简短理由。\n格式:[{"questionId":"ID","answer":"内容","analysis":"解析"}]\n\n题目:\n${lines.join('\n\n')}`;
}
function parseAiResponse(text) {
if (!text) return null;
let cleaned = text.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
const firstBracket = cleaned.indexOf('[');
const lastBracket = cleaned.lastIndexOf(']');
if (firstBracket === -1 || lastBracket === -1) return null;
try {
return JSON.parse(cleaned.substring(firstBracket, lastBracket + 1));
} catch {
return null;
}
}
// ============================================================
// 答案回填
// ============================================================
async function fillAnswer(qDom, item, type) {
if (!qDom || !item) return false;
if (item.analysis) {
log(`💡 题${item.questionId}解析: ${item.analysis}`, '#eccc68');
}
if (type.includes('填空')) {
const answers = String(item.answer).split(/[;;\n]/);
for (let idx = 0; idx < answers.length; idx++) {
const val = answers[idx].trim();
if (!val) continue;
const editorId = `answerEditor${item.questionId}${idx + 1}`;
const s = document.createElement('script');
s.textContent = `(function(){ if(window.UE && UE.getEditor("${editorId}")){ UE.getEditor("${editorId}").ready(function(){ this.setContent("${val.replace(/"/g, '\\"')}"); }); } })();`;
document.body.appendChild(s); s.remove();
await new Promise(r => setTimeout(r, 300));
}
return true;
}
if (type.includes('简答') || type.includes('论述') || type.includes('名词解释') || type.includes('问答')) {
log(`📝 答案: ${item.answer}`, '#74b9ff');
const answerText = String(item.answer);
const simulateTyping = (doc, body) => {
body.focus();
doc.execCommand('selectAll', false, null);
doc.execCommand('delete', false, null);
doc.execCommand('insertText', false, answerText);
return body.innerText?.trim()?.length > 0;
};
const iframe = qDom.querySelector('iframe.edui-editor-iframeholder iframe, .edui-editor-iframeholder iframe, iframe');
if (iframe) {
try {
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
const iframeBody = iframeDoc.body;
log(`🔍 找到iframe编辑器,模拟输入中...`, '#636e72');
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
iframeBody.dispatchEvent(clickEvent);
await new Promise(r => setTimeout(r, 200));
const success = simulateTyping(iframeDoc, iframeBody);
if (success) {
iframeBody.dispatchEvent(new Event('input', { bubbles: true }));
iframeBody.dispatchEvent(new Event('change', { bubbles: true }));
iframeBody.dispatchEvent(new Event('blur', { bubbles: true }));
log(`✅ 已模拟输入到iframe编辑器`, '#55efc4');
return true;
}
} catch (e) {
log(`⚠️ iframe输入失败: ${e.message}`, '#fdcb6e');
}
}
const contentEditable = qDom.querySelector('[contenteditable="true"]');
if (contentEditable) {
log(`🔍 找到contenteditable编辑器,模拟输入中...`, '#636e72');
contentEditable.click();
contentEditable.focus();
await new Promise(r => setTimeout(r, 200));
document.execCommand('selectAll', false, null);
document.execCommand('delete', false, null);
document.execCommand('insertText', false, answerText);
if (contentEditable.innerText?.trim()?.length > 0) {
contentEditable.dispatchEvent(new Event('input', { bubbles: true }));
contentEditable.dispatchEvent(new Event('change', { bubbles: true }));
contentEditable.dispatchEvent(new Event('blur', { bubbles: true }));
log(`✅ 已模拟输入到contenteditable编辑器`, '#55efc4');
return true;
}
}
const textarea = qDom.querySelector('textarea');
if (textarea) {
log(`🔍 找到textarea,模拟输入中...`, '#636e72');
textarea.click();
textarea.focus();
await new Promise(r => setTimeout(r, 200));
const nativeSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype, 'value'
)?.set;
if (nativeSetter) {
nativeSetter.call(textarea, answerText);
} else {
textarea.value = answerText;
}
textarea.dispatchEvent(new Event('input', { bubbles: true }));
textarea.dispatchEvent(new Event('change', { bubbles: true }));
textarea.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
textarea.dispatchEvent(new Event('blur', { bubbles: true }));
if (textarea.value.length > 0) {
log(`✅ 已模拟输入到textarea`, '#55efc4');
return true;
}
}
try {
GM_setClipboard(answerText);
log(`📋 答案已复制到剪贴板,请手动粘贴`, '#fdcb6e');
log(`💡 提示:点击输入框后使用 Ctrl+V 粘贴`, '#74b9ff');
return false;
} catch (e) {
log(`⚠️ 无法复制到剪贴板`, '#fdcb6e');
}
log(`⚠️ 未找到编辑器,答案已在日志中显示`, '#fdcb6e');
return false;
}
const ansStr = String(item.answer).toUpperCase().trim();
const opts = qDom.querySelectorAll('.answerBg, .answer_item, .options li');
let filled = false;
if (type.includes('判断')) {
for (const opt of opts) {
const label = opt.innerText?.trim();
const isCorrect = (ansStr.includes('对') && (label.includes('对') || label.includes('√') || label.includes('正确') || label.includes('A'))) ||
(ansStr.includes('错') && (label.includes('错') || label.includes('×') || label.includes('不正确') || label.includes('B')));
if (isCorrect) {
opt.click();
filled = true;
break;
}
}
} else {
for (let i = 0; i < opts.length; i++) {
const opt = opts[i];
const label = opt.querySelector('.num_option, .num_option_dx, b')?.innerText?.trim().replace('.', '') ||
opt.getAttribute('data') || '';
if (label && ansStr.includes(label.toUpperCase())) {
if (type.includes('多选') && typeof addMultipleChoice === 'function') {
addMultipleChoice(opt);
} else if (typeof addChoice === 'function') {
addChoice(opt);
} else {
opt.click();
}
filled = true;
await new Promise(r => setTimeout(r, 200));
}
}
}
return filled;
}
// ============================================================
// 自动答题核心(逐题处理,带重试和限流检测)
// ============================================================
const BATCH_SIZE = 1;
const REQUEST_DELAY = 5000; // 增加到5秒
const MAX_RETRIES = 3;
const RETRY_DELAY = 15000; // 重试间隔15秒
const RATE_LIMIT_DELAY = 30000; // 限流后等待30秒
async function doAutoAnswer() {
const questions = detectQuestions();
const unanswered = questions.filter(q => !q.answered);
if (!unanswered.length) {
log('📋 未发现未答题目,所有题目已作答', '#dfe6e9');
return 0;
}
const answeredCount = questions.length - unanswered.length;
log(`� 共 ${questions.length} 道题,已答 ${answeredCount} 道,未答 ${unanswered.length} 道`, '#74b9ff');
log(`🔍 将逐题处理未答题目`, '#74b9ff');
log(`⚙️ 配置: 请求间隔${REQUEST_DELAY/1000}秒, 失败重试${MAX_RETRIES}次, 重试间隔${RETRY_DELAY/1000}秒`, '#636e72');
let totalFilled = 0;
let consecutiveErrors = 0;
for (let i = 0; i < unanswered.length; i++) {
if (!autoAnswerRunning) {
log('⏹️ 自动答题已停止', '#fdcb6e');
break;
}
const q = unanswered[i];
log(`📦 处理第 ${i + 1}/${unanswered.length} 题 (ID: ${q.id})`, '#74b9ff');
const prompt = buildPrompt([q]);
let response = null;
let success = false;
for (let retry = 0; retry < MAX_RETRIES; retry++) {
if (!autoAnswerRunning) break;
try {
if (retry > 0) {
log(`🔄 第${retry + 1}次重试...`, '#fdcb6e');
await new Promise(r => setTimeout(r, RETRY_DELAY));
} else if (consecutiveErrors >= 3) {
log(`⚠️ 检测到连续失败,等待${RATE_LIMIT_DELAY/1000}秒避免限流...`, '#fdcb6e');
await new Promise(r => setTimeout(r, RATE_LIMIT_DELAY));
}
response = await callAiApi(prompt);
success = true;
consecutiveErrors = 0;
break;
} catch (e) {
log(`❌ 请求失败: ${e.message}`, '#ff7675');
consecutiveErrors++;
// 检测限流错误
const isRateLimit = e.message.includes('429') ||
e.message.includes('rate') ||
e.message.includes('limit') ||
e.message.includes('too many');
if (isRateLimit) {
log(`🚫 检测到API限流,等待${RATE_LIMIT_DELAY/1000}秒后重试...`, '#fdcb6e');
await new Promise(r => setTimeout(r, RATE_LIMIT_DELAY));
}
if (consecutiveErrors >= 6) {
log(`🚫 连续${consecutiveErrors}次失败,自动停止`, '#ff7675');
log(`💡 建议:等待5-10分钟后再尝试,或更换API Key`, '#fdcb6e');
autoAnswerRunning = false;
updateAutoAnswerUI();
return -1;
}
}
}
if (!success || !response) {
log(`⏭️ 跳过第${i + 1}题,继续下一题`, '#fdcb6e');
// 跳过后也等待一段时间
await new Promise(r => setTimeout(r, REQUEST_DELAY));
continue;
}
const answers = parseAiResponse(response);
if (!answers || !Array.isArray(answers)) {
log(`❌ AI返回格式无法解析`, '#ff7675');
log(`原始响应: ${response.substring(0, 300)}`, '#636e72');
continue;
}
for (const item of answers) {
const qDom = document.querySelector(`.questionLi[data="${item.questionId}"], #question${item.questionId}, [data-qid="${item.questionId}"]`);
if (!qDom) {
log(`⚠️ 未找到题目 ${item.questionId}`, '#fdcb6e');
continue;
}
// 显示答案内容
log(`📋 题目${item.questionId}答案: ${item.answer}`, '#00cec9');
const type = qDom.getAttribute('typename') || '';
const ok = await fillAnswer(qDom, item, type);
if (ok) {
totalFilled++;
log(`✅ [${totalFilled}/${unanswered.length}] 题目${item.questionId}已填写`, '#55efc4');
} else {
log(`⚠️ 题目${item.questionId}填写失败`, '#fdcb6e');
}
}
if (i < unanswered.length - 1 && autoAnswerRunning) {
log(`⏳ 等待${REQUEST_DELAY/1000}秒...`, '#636e72');
await new Promise(r => setTimeout(r, REQUEST_DELAY));
}
}
log(`🎯 总计已填写 ${totalFilled}/${unanswered.length} 道题`, '#00b894');
if (autoAnswerRunning) {
log(`✅ 自动答题完成`, '#00b894');
}
return totalFilled;
}
async function startAutoAnswerLoop() {
if (autoAnswerRunning) return;
autoAnswerRunning = true;
updateAutoAnswerUI();
log('🚀 自动答题已启动', '#00b894');
const loop = async () => {
if (!autoAnswerRunning) return;
const result = await doAutoAnswer();
if (result === -1) {
log('⚠️ 发生错误,自动答题已停止', '#ff7675');
stopAutoAnswerLoop();
return;
}
if (autoAnswerRunning && uiSettings.autoNextQuestion) {
const nextBtn = document.querySelector('.nextDiv a, .next-btn, .btn_next, a[class*="next"]');
if (nextBtn) {
log('➡️ 自动切换下一题...', '#74b9ff');
nextBtn.click();
autoAnswerTimer = setTimeout(loop, uiSettings.autoAnswerDelay + 2000);
} else {
log('✅ 所有题目已完成,自动答题停止', '#00b894');
stopAutoAnswerLoop();
}
} else {
stopAutoAnswerLoop();
}
};
autoAnswerTimer = setTimeout(loop, 500);
}
function stopAutoAnswerLoop() {
autoAnswerRunning = false;
if (autoAnswerTimer) {
clearTimeout(autoAnswerTimer);
autoAnswerTimer = null;
}
updateAutoAnswerUI();
log('⏹️ 自动答题已停止', '#dfe6e9');
}
function updateAutoAnswerUI() {
const btn = document.getElementById('btn-auto-answer');
if (!btn) return;
if (autoAnswerRunning) {
btn.textContent = '⏹️ 停止自动答题';
btn.style.background = 'linear-gradient(135deg, #e74c3c 0%, #c0392b 100%)';
} else {
btn.textContent = '🤖 开始自动答题';
btn.style.background = 'linear-gradient(135deg, #00b894 0%, #00a085 100%)';
}
}
// ============================================================
// 日志系统
// ============================================================
const log = (msg, color = '#00ff00') => {
const box = document.getElementById('yan-log');
if (box) {
const div = document.createElement('div');
div.style.color = color;
const time = new Date().toLocaleTimeString('zh-CN', { hour12: false });
div.innerText = `[${time}] ${msg}`;
box.appendChild(div);
box.scrollTop = box.scrollHeight;
if (box.children.length > 500) {
box.removeChild(box.firstChild);
}
}
};
// ============================================================
// 解锁粘贴限制
// ============================================================
const hookUEditor = () => {
if (window.UE && window.UE.Editor) {
const originalFire = window.UE.Editor.prototype.fireEvent;
window.UE.Editor.prototype.fireEvent = function(type) {
if (type === 'beforepaste') return;
return originalFire.apply(this, arguments);
};
log("🔓 UEditor粘贴限制已解除");
} else {
setTimeout(hookUEditor, 1500);
}
};
const hookPasteEvents = () => {
document.addEventListener('paste', function(e) {
e.stopPropagation();
}, true);
document.addEventListener('copy', function(e) {
e.stopPropagation();
}, true);
document.addEventListener('cut', function(e) {
e.stopPropagation();
}, true);
document.addEventListener('keydown', function(e) {
if (e.ctrlKey && (e.key === 'v' || e.key === 'V' || e.key === 'c' || e.key === 'C')) {
e.stopPropagation();
}
}, true);
log("🔓 全局复制粘贴限制已解除");
};
const forceSetValue = (element, value) => {
if (!element) return false;
element.focus();
if (element.tagName === 'TEXTAREA' || element.tagName === 'INPUT') {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype, 'value'
)?.set || Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype, 'value'
)?.set;
if (nativeInputValueSetter) {
nativeInputValueSetter.call(element, value);
} else {
element.value = value;
}
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
element.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
return true;
}
if (element.contentEditable === 'true') {
element.innerHTML = value.replace(/\n/g, '
');
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
element.dispatchEvent(new Event('blur', { bubbles: true }));
return true;
}
if (element.tagName === 'IFRAME') {
try {
const body = element.contentDocument?.body;
if (body) {
body.innerHTML = value.replace(/\n/g, '
');
body.dispatchEvent(new Event('input', { bubbles: true }));
return true;
}
} catch (e) {
log(`⚠️ iframe访问受限`, '#fdcb6e');
}
}
return false;
};
// ============================================================
// UI 样式
// ============================================================
GM_addStyle(`
:root {
--yan-panel-width: ${uiSettings.panelWidth}px;
--yan-panel-opacity: ${uiSettings.panelOpacity / 100};
--yan-float-duration: ${uiSettings.floatDuration}s;
--yan-ball-size: ${uiSettings.ballSize}px;
}
#yan-ball {
position: fixed; left: 20px; top: 20px;
width: var(--yan-ball-size); height: var(--yan-ball-size);
background: radial-gradient(circle at 28% 24%, rgba(255,255,255,0.98) 0 8%, rgba(255,255,255,0.38) 10%, rgba(255,255,255,0) 30%),
radial-gradient(circle at 68% 72%, rgba(31,145,255,0.24) 0 14%, rgba(31,145,255,0) 54%),
linear-gradient(145deg, #58b9ff 0%, #2d93ff 40%, #0f68ef 100%);
border-radius: 46% 54% 52% 48% / 44% 42% 58% 56%;
box-shadow: 0 18px 34px rgba(8,72,170,0.28), inset 0 1px 3px rgba(255,255,255,0.45), inset 0 -10px 18px rgba(0,0,0,0.14);
cursor: grab; z-index: 9999999; display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 13px; font-weight: 700; letter-spacing: 0.04em;
user-select: none; -webkit-user-select: none; touch-action: none; overflow: hidden;
transition: box-shadow 0.22s ease, filter 0.22s ease;
animation: yan-morph var(--yan-float-duration) ease-in-out infinite, yan-breathe 3.1s ease-in-out infinite, yan-drift 7.8s ease-in-out infinite;
will-change: transform, left, top, border-radius;
}
#yan-ball::before {
content: ""; position: absolute; inset: -12%;
border-radius: 48% 52% 54% 46% / 50% 46% 54% 50%;
background: radial-gradient(circle at 28% 28%, rgba(255,255,255,0.56) 0 10%, rgba(255,255,255,0.12) 28%, rgba(255,255,255,0) 56%),
radial-gradient(circle at 68% 72%, rgba(34,141,255,0.32) 0 12%, rgba(34,141,255,0) 60%);
filter: blur(6px); opacity: 0.9; transform: translate3d(0,0,0);
animation: yan-swim calc(var(--yan-float-duration)*1.15) ease-in-out infinite;
pointer-events: none;
}
#yan-ball::after {
content: ""; position: absolute; inset: 12% 16% 50% 16%;
border-radius: 50%; background: linear-gradient(to bottom, rgba(255,255,255,0.72), rgba(255,255,255,0));
filter: blur(1px); opacity: 0.95; animation: yan-sheen 2.7s ease-in-out infinite; pointer-events: none;
}
#yan-ball span { position: relative; z-index: 1; text-shadow: 0 1px 2px rgba(0,0,0,0.16); }
#yan-ball:hover { filter: saturate(1.05) brightness(1.03); box-shadow: 0 20px 38px rgba(8,72,170,0.32), inset 0 1px 3px rgba(255,255,255,0.5), inset 0 -10px 18px rgba(0,0,0,0.12); }
#yan-ball.dragging { cursor: grabbing; animation: none; transform: scale(1.08); }
#yan-ball.was-dragged { animation: yan-settle 0.22s ease-out; }
#yan-ball.releasing { animation: yan-release 0.36s cubic-bezier(.2,.8,.2,1); }
#yan-panel {
position: fixed; width: var(--yan-panel-width);
background: rgba(255,255,255,var(--yan-panel-opacity));
border-radius: 22px; box-shadow: 0 24px 60px rgba(0,0,0,0.18);
border: 1px solid rgba(255,255,255,0.72); backdrop-filter: blur(14px);
z-index: 9999998; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
opacity: 0; transform: translateY(14px) scale(0.96);
transition: opacity 220ms ease, transform 260ms cubic-bezier(.2,.9,.2,1);
pointer-events: none;
}
#yan-panel.is-open { opacity: 1; transform: translateY(0) scale(1); pointer-events: auto; }
.yan-panel-shell { display: flex; flex-direction: column; max-height: min(85vh, 800px); }
.yan-header { padding: 14px 16px 12px; background: linear-gradient(180deg, #fafcff 0%, #eff5ff 100%); border-bottom: 1px solid #e8eef9; }
.yan-header-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.yan-title-wrap { display: flex; flex-direction: column; gap: 6px; }
.yan-title { font-weight: 800; font-size: 16px; color: #20324a; display: block; letter-spacing: 0.01em; }
.yan-subtitle { font-size: 12px; color: #62738b; line-height: 1.4; }
.yan-close { border: none; background: rgba(27,52,89,0.08); color: #20324a; width: 30px; height: 30px; border-radius: 50%; cursor: pointer; flex: 0 0 auto; font-size: 16px; }
.yan-body { padding: 14px; overflow: auto; }
.yan-tabs { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; padding: 10px 14px 0; }
.yan-tab {
border: 1px solid #e6ecf6; background: #f8fbff; color: #42526b;
border-radius: 999px; padding: 8px 6px; font-size: 11px; font-weight: 800;
cursor: pointer; text-align: center;
transition: transform 160ms ease, background 160ms ease, color 160ms ease, border-color 160ms ease;
}
.yan-tab:hover { transform: translateY(-1px); background: #eef4ff; border-color: #cfdcf8; }
.yan-tab.is-active { background: linear-gradient(135deg, #3e76ff 0%, #1a57e8 100%); border-color: transparent; color: #fff; box-shadow: 0 10px 22px rgba(34,89,226,0.18); }
.yan-page { display: none; animation: yan-page-in 180ms ease-out; }
.yan-page.is-active { display: block; }
.yan-section { border: 1px solid #edf1f7; background: #fff; border-radius: 18px; margin-bottom: 12px; overflow: hidden; }
.yan-section-title { padding: 10px 14px 8px; font-size: 13px; font-weight: 800; color: #21314a; background: linear-gradient(180deg, #fbfcfe 0%, #f7f9fd 100%); border-bottom: 1px solid #edf1f7; letter-spacing: 0.02em; }
.yan-section-desc { padding: 10px 14px 0; font-size: 12px; line-height: 1.55; color: #718197; }
.yan-action-grid { display: grid; grid-template-columns: 1fr; gap: 10px; padding: 12px 14px 14px; }
.yan-action-grid-2col { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 12px 14px 14px; }
.yan-btn {
width: 100%; padding: 13px 16px; border: none; cursor: pointer; font-weight: 700; font-size: 14px;
color: #eaf1ff; text-align: left; border-radius: 14px;
box-shadow: 0 8px 18px rgba(0,0,0,0.1);
transition: transform 160ms ease, box-shadow 160ms ease, filter 160ms ease;
}
.yan-btn:hover { transform: translateY(-1px); filter: brightness(1.03); box-shadow: 0 12px 24px rgba(0,0,0,0.14); }
.yan-btn:active { transform: translateY(0); }
.yan-btn-sm { padding: 10px 12px; font-size: 12px; }
#btn-auto-answer { background: linear-gradient(135deg, #00b894 0%, #00a085 100%); }
.yan-question-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 6px;
padding: 12px 14px 14px;
}
.yan-question-cell {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
font-size: 12px;
font-weight: 700;
cursor: pointer;
transition: all 160ms ease;
border: 1px solid #e6ecf6;
}
.yan-question-cell.answered {
background: linear-gradient(135deg, #00b894 0%, #00a085 100%);
color: #fff;
border-color: transparent;
box-shadow: 0 4px 12px rgba(0, 184, 148, 0.3);
}
.yan-question-cell.unanswered {
background: #f0f2f5;
color: #636e72;
}
.yan-question-cell:hover {
transform: scale(1.05);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
}
.yan-question-cell.active {
background: linear-gradient(135deg, #0984e3 0%, #6c5ce7 100%);
color: #fff;
border-color: transparent;
box-shadow: 0 4px 12px rgba(9, 132, 227, 0.4);
}
#btn-export { background: linear-gradient(135deg, #364a63 0%, #243449 100%); }
#btn-import { background: linear-gradient(135deg, #25b46b 0%, #18a35d 100%); }
#btn-reset { background: linear-gradient(135deg, #f59b23 0%, #e77b12 100%); }
#btn-wizard { background: linear-gradient(135deg, #f8fbff 0%, #eef4ff 100%); color: #243449; border: 1px solid #dbe5fb; box-shadow: 0 8px 18px rgba(25,53,96,0.06); }
#btn-wizard:hover { color: #1f3357; background: linear-gradient(135deg, #eff4ff 0%, #e4ecff 100%); }
.yan-settings { display: grid; gap: 12px; padding: 12px 14px 14px; }
.yan-field { display: grid; gap: 8px; }
.yan-field-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; font-size: 12px; color: #42526b; }
.yan-field-label { font-weight: 700; color: #22324a; }
.yan-field-value { font-variant-numeric: tabular-nums; color: #61738b; }
.yan-range { width: 100%; margin: 0; accent-color: #3f79ff; }
.yan-switch { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; border: 1px solid #edf1f7; border-radius: 14px; background: #fbfcfe; }
.yan-switch small { display: block; color: #7a8797; margin-top: 4px; line-height: 1.4; }
.yan-switch strong { color: #21314a; }
.yan-switch input { width: 18px; height: 18px; }
.yan-input {
width: 100%; padding: 10px 12px; border: 1px solid #dfe6f1; border-radius: 12px;
background: #fff; color: #22324a; font-size: 13px; outline: none; font-family: monospace;
box-sizing: border-box;
}
.yan-input:focus { border-color: #3f79ff; box-shadow: 0 0 0 3px rgba(63,121,255,0.12); }
.yan-input::placeholder { color: #b0bec5; }
.yan-select {
width: 100%; padding: 11px 12px; border: 1px solid #dfe6f1; border-radius: 14px;
background: #fff; color: #22324a; font-weight: 700; outline: none;
box-sizing: border-box;
}
.yan-provider-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; padding: 12px 14px; }
.yan-provider-btn {
border: 2px solid #e6ecf6; background: #f8fbff; color: #42526b;
border-radius: 12px; padding: 8px 4px; font-size: 11px; font-weight: 700;
cursor: pointer; text-align: center;
transition: all 160ms ease;
}
.yan-provider-btn:hover { background: #eef4ff; border-color: #cfdcf8; transform: translateY(-1px); }
.yan-provider-btn.is-active { background: linear-gradient(135deg, #3e76ff 0%, #1a57e8 100%); border-color: transparent; color: #fff; box-shadow: 0 6px 14px rgba(34,89,226,0.18); }
#yan-log {
height: 180px; background: #20242a; color: #89ff9c; overflow-y: auto;
padding: 12px; font-size: 12px; font-family: 'Cascadia Code', 'Fira Code', monospace;
border-radius: 0 0 18px 18px; line-height: 1.6;
}
.yan-log-frame { border: 1px solid #edf1f7; border-radius: 18px; overflow: hidden; background: #20242a; }
.yan-status-bar {
padding: 8px 14px; background: #f0f4ff; border-radius: 12px;
font-size: 12px; color: #42526b; display: flex; align-items: center; gap: 8px;
margin: 8px 14px; border: 1px solid #e6ecf6;
}
#yan-search-input {
font-family: inherit;
line-height: 1.5;
}
#yan-search-result {
font-family: inherit;
line-height: 1.6;
overflow-y: auto;
max-height: 200px;
}
.yan-status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.yan-status-dot.ok { background: #00b894; box-shadow: 0 0 6px rgba(0,184,148,0.4); }
.yan-status-dot.warn { background: #fdcb6e; box-shadow: 0 0 6px rgba(253,203,110,0.4); }
.yan-status-dot.error { background: #ff7675; box-shadow: 0 0 6px rgba(255,118,117,0.4); }
.yan-status-dot.idle { background: #b2bec3; }
@keyframes yan-morph { 0%,100%{border-radius:46% 54% 52% 48%/44% 42% 58% 56%;transform:translate3d(0,0,0) rotate(-2deg)} 25%{border-radius:52% 48% 41% 59%/50% 58% 42% 50%;transform:translate3d(0,-4px,0) rotate(1deg)} 50%{border-radius:42% 58% 56% 44%/58% 46% 54% 42%;transform:translate3d(0,2px,0) rotate(2deg)} 75%{border-radius:58% 42% 48% 52%/42% 56% 44% 58%;transform:translate3d(0,-2px,0) rotate(-1deg)} }
@keyframes yan-swim { 0%,100%{transform:translate3d(-6px,-2px,0) scale(1)} 33%{transform:translate3d(5px,3px,0) scale(1.03)} 66%{transform:translate3d(-2px,5px,0) scale(1.01)} }
@keyframes yan-sheen { 0%,100%{transform:translate3d(0,0,0) scale(1);opacity:.95} 50%{transform:translate3d(2px,4px,0) scale(.98);opacity:.8} }
@keyframes yan-breathe { 0%,100%{filter:saturate(1) brightness(1)} 50%{filter:saturate(1.08) brightness(1.03)} }
@keyframes yan-settle { 0%{transform:scale(1.12)} 70%{transform:scale(.98)} 100%{transform:scale(1)} }
@keyframes yan-release { 0%{transform:scale(1.05)} 40%{transform:scale(.96)} 100%{transform:scale(1)} }
@keyframes yan-drift { 0%,100%{filter:hue-rotate(0deg) saturate(1)} 50%{filter:hue-rotate(10deg) saturate(1.08)} }
@keyframes yan-page-in { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:translateY(0)} }
@media (prefers-reduced-motion: reduce) { #yan-ball,#yan-ball::before,#yan-ball::after,#yan-panel{animation:none!important;transition-duration:.01ms!important} }
`);
// ============================================================
// 主界面初始化
// ============================================================
function createRangeField(id, label, min, max, step, unit, value) {
return ``;
}
function getProviderStatus() {
if (!aiConfig.apiUrl) return { cls: 'warn', text: '未配置AI接口' };
if (!aiConfig.apiKey && !aiConfig.apiUrl.includes('localhost') && !aiConfig.apiUrl.includes('127.0.0.1')) return { cls: 'warn', text: '未配置API Key' };
return { cls: 'ok', text: `${AI_PROVIDERS.find(p => p.id === aiConfig.provider)?.name || '自定义'} · ${aiConfig.model}` };
}
function init() {
if (document.getElementById('yan-ball')) return;
const ball = document.createElement('div');
ball.id = 'yan-ball';
ball.innerHTML = 'AI 答题';
document.body.appendChild(ball);
const status = getProviderStatus();
const panel = document.createElement('div');
panel.id = 'yan-panel';
panel.innerHTML = `
🤖 智能答题
自动识别页面题目,调用AI生成答案并回填。
📊 题目进度
点击格子从该题开始答题,绿色=已答,灰色=未答
🔧 AI 服务商预设
${AI_PROVIDERS.map(p => ``).join('')}
⚙️ 接口参数
${createRangeField('yan-ai-temp', '温度 (越低越精确)', 0, 1, 0.05, '', aiConfig.temperature)}
⚙️ 通用设置
${createRangeField('yan-setting-ball-size', '浮球大小', 52, 92, 1, ' px', uiSettings.ballSize)}
${createRangeField('yan-setting-float-duration', '浮动速度', 2.8, 8, 0.1, ' s', uiSettings.floatDuration.toFixed(1))}
${createRangeField('yan-setting-panel-width', '面板宽度', 320, 520, 10, ' px', uiSettings.panelWidth)}
${createRangeField('yan-setting-panel-opacity', '面板透明度', 85, 100, 1, ' %', uiSettings.panelOpacity)}
${createRangeField('yan-setting-delay', '答题间隔', 500, 5000, 100, ' ms', uiSettings.autoAnswerDelay)}
`;
document.body.appendChild(panel);
// ---- 向导面板 ----
const wizardMask = document.createElement('div');
wizardMask.id = 'yan-wizard-mask';
wizardMask.style.cssText = 'position:fixed;inset:0;background:rgba(13,22,39,0.42);backdrop-filter:blur(8px);z-index:9999997;opacity:0;pointer-events:none;transition:opacity 220ms ease;';
wizardMask.innerHTML = `
配置向导
首次使用请按步骤配置AI接口,之后即可自动答题。
1 配置AI接口
切换到「AI配置」页面,选择一个AI服务商(推荐DeepSeek),填入API Key后保存。
2 开始答题
回到「答题」页面,点击「开始自动答题」即可自动识别题目并AI作答。也可使用「单次答题」手动触发一次。
3 查看日志
在「日志」页面可以查看AI请求状态、答题结果和错误信息。
`;
document.body.appendChild(wizardMask);
// ============================================================
// 事件绑定
// ============================================================
const pageMap = Array.from(panel.querySelectorAll('.yan-page'));
const tabButtons = Array.from(panel.querySelectorAll('.yan-tab'));
const setPage = (page) => {
tabButtons.forEach(b => b.classList.toggle('is-active', b.dataset.page === page));
pageMap.forEach(n => n.classList.toggle('is-active', n.dataset.pagePanel === page));
if (panel.classList.contains('is-open')) positionPanel(ball, panel);
};
const openPanelTo = (page) => {
setPage(page);
panel.style.display = 'block';
panel.getBoundingClientRect();
positionPanel(ball, panel);
requestAnimationFrame(() => panel.classList.add('is-open'));
};
const hidePanel = () => {
panel.classList.remove('is-open');
setTimeout(() => { if (!panel.classList.contains('is-open')) panel.style.display = 'none'; }, 260);
};
const togglePanel = () => {
if (panel.classList.contains('is-open')) hidePanel();
else openPanelTo(uiSettings.autoOpenPage);
};
tabButtons.forEach(btn => {
btn.addEventListener('click', () => {
setPage(btn.dataset.page);
uiSettings.autoOpenPage = btn.dataset.page;
saveUiSettings();
});
});
document.getElementById('yan-close').addEventListener('click', hidePanel);
// ---- 向导 ----
const showWizard = () => {
wizardMask.style.opacity = '1'; wizardMask.style.pointerEvents = 'auto';
const wp = document.getElementById('yan-wizard-panel');
wp.style.opacity = '1'; wp.style.transform = 'translate(-50%,-50%) scale(1)'; wp.style.pointerEvents = 'auto';
GM_setValue(STORAGE_KEYS.wizardSeen, true);
};
const hideWizard = () => {
wizardMask.style.opacity = '0'; wizardMask.style.pointerEvents = 'none';
const wp = document.getElementById('yan-wizard-panel');
wp.style.opacity = '0'; wp.style.transform = 'translate(-50%,-46%) scale(0.96)'; wp.style.pointerEvents = 'none';
};
document.getElementById('yan-wizard-close').addEventListener('click', hideWizard);
document.getElementById('yan-wizard-start').addEventListener('click', hideWizard);
document.getElementById('yan-wizard-never').addEventListener('click', hideWizard);
wizardMask.addEventListener('click', e => { if (e.target === wizardMask) hideWizard(); });
document.getElementById('btn-wizard')?.addEventListener('click', showWizard);
// ---- AI配置 ----
document.querySelectorAll('.yan-provider-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.yan-provider-btn').forEach(b => b.classList.remove('is-active'));
btn.classList.add('is-active');
const provider = AI_PROVIDERS.find(p => p.id === btn.dataset.provider);
if (provider) {
aiConfig.provider = provider.id;
if (provider.url) document.getElementById('yan-ai-url').value = provider.url;
if (provider.model) document.getElementById('yan-ai-model').value = provider.model;
aiConfig.apiUrl = provider.url || aiConfig.apiUrl;
aiConfig.model = provider.model || aiConfig.model;
}
});
});
document.getElementById('yan-ai-url').addEventListener('input', e => { aiConfig.apiUrl = e.target.value.trim(); });
document.getElementById('yan-ai-model').addEventListener('input', e => { aiConfig.model = e.target.value.trim(); });
document.getElementById('yan-ai-key').addEventListener('input', e => { aiConfig.apiKey = e.target.value.trim(); });
document.getElementById('yan-ai-temp').addEventListener('input', e => {
aiConfig.temperature = parseFloat(e.target.value);
document.getElementById('yan-ai-temp-value').textContent = aiConfig.temperature;
});
document.getElementById('btn-save-ai').addEventListener('click', () => {
saveAiConfig();
const s = getProviderStatus();
const statusEl = panel.querySelector('.yan-status-bar');
if (statusEl) statusEl.innerHTML = `${s.text}`;
log('💾 AI配置已保存', '#55efc4');
});
// ---- 答题按钮 ----
document.getElementById('btn-auto-answer').addEventListener('click', () => {
if (autoAnswerRunning) {
stopAutoAnswerLoop();
} else {
startFromIndex = 0;
startAutoAnswerFromIndex(0);
}
});
// ---- 导出/回填 ----
document.getElementById('btn-export').addEventListener('click', () => {
const questions = detectQuestions();
if (!questions.length) { log('❌ 未检测到题目', '#ff7675'); return; }
const prompt = buildPrompt(questions);
GM_setClipboard(prompt);
log(`✅ 已导出 ${questions.length} 题到剪贴板`, '#55efc4');
});
document.getElementById('btn-import').addEventListener('click', async () => {
try {
const text = await navigator.clipboard.readText();
const answers = parseAiResponse(text);
if (!answers) { log('❌ 剪贴板内容无法解析为JSON', '#ff7675'); return; }
for (const item of answers) {
const qDom = document.querySelector(`.questionLi[data="${item.questionId}"], #question${item.questionId}`);
if (!qDom) continue;
const type = qDom.getAttribute('typename') || '';
await fillAnswer(qDom, item, type);
await new Promise(r => setTimeout(r, 300));
}
log(`🎯 回填完成`, '#00b894');
} catch (e) { log('❌ 剪贴板读取失败: ' + e.message, '#ff7675'); }
});
// ---- 手动搜题 ----
document.getElementById('btn-search-answer').addEventListener('click', async () => {
const input = document.getElementById('yan-search-input');
const result = document.getElementById('yan-search-result');
const question = input.value.trim();
if (!question) {
result.textContent = '❌ 请输入题目内容';
result.style.color = '#ff7675';
return;
}
result.textContent = '🤖 正在请求AI...';
result.style.color = '#74b9ff';
const prompt = `请回答以下题目,直接给出答案和简短解析。\n\n题目:${question}`;
try {
const response = await callAiApi(prompt);
result.textContent = response;
result.style.color = '#333';
log('✅ 手动搜题完成', '#55efc4');
} catch (e) {
result.textContent = `❌ 请求失败: ${e.message}`;
result.style.color = '#ff7675';
}
});
document.getElementById('btn-copy-answer').addEventListener('click', () => {
const result = document.getElementById('yan-search-result');
const text = result.textContent;
if (!text || text.includes('请输入题目') || text.includes('正在请求')) {
return;
}
GM_setClipboard(text);
const btn = document.getElementById('btn-copy-answer');
const originalText = btn.textContent;
btn.textContent = '✅ 已复制';
setTimeout(() => { btn.textContent = originalText; }, 1500);
log('📋 答案已复制到剪贴板', '#55efc4');
});
// ---- 设置控件 ----
const bindRange = (id, key, transform) => {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener('input', e => {
uiSettings[key] = transform ? transform(e.target.value) : parseFloat(e.target.value);
const valEl = document.getElementById(`${id}-value`);
if (valEl) valEl.textContent = typeof uiSettings[key] === 'number' && uiSettings[key] % 1 !== 0 ? uiSettings[key].toFixed(1) : uiSettings[key];
saveUiSettings();
applyUiToElements(ball, panel);
});
};
bindRange('yan-setting-ball-size', 'ballSize', v => clamp(Number(v), 52, 92));
bindRange('yan-setting-float-duration', 'floatDuration', v => clamp(Number(v), 2.8, 8));
bindRange('yan-setting-panel-width', 'panelWidth', v => clamp(Number(v), 320, 520));
bindRange('yan-setting-panel-opacity', 'panelOpacity', v => clamp(Number(v), 85, 100));
bindRange('yan-setting-delay', 'autoAnswerDelay', v => clamp(Number(v), 500, 5000));
document.getElementById('yan-setting-next')?.addEventListener('change', e => { uiSettings.autoNextQuestion = e.target.checked; saveUiSettings(); });
document.getElementById('yan-setting-remember')?.addEventListener('change', e => { uiSettings.rememberPosition = e.target.checked; saveUiSettings(); });
document.getElementById('yan-setting-motion')?.addEventListener('change', e => { uiSettings.reducedMotion = e.target.checked; saveUiSettings(); applyUiToElements(ball, panel); });
document.getElementById('btn-reset').addEventListener('click', () => {
uiSettings = { ...DEFAULT_SETTINGS };
saveUiSettings();
saveJSON(STORAGE_KEYS.ballPos, {});
applyUiToElements(ball, panel);
const pos = getDefaultBallPos();
applyBallPos(ball, pos.left, pos.top);
log('↺ 已恢复默认参数', '#fdcb6e');
});
// ---- 拖拽 ----
const drag = { active: false, moved: false, startX: 0, startY: 0, originLeft: 0, originTop: 0 };
ball.addEventListener('pointerdown', e => {
if (e.button !== 0) return;
e.preventDefault();
drag.active = true; drag.moved = false;
drag.startX = e.clientX; drag.startY = e.clientY;
const r = ball.getBoundingClientRect();
drag.originLeft = r.left; drag.originTop = r.top;
ball.classList.remove('was-dragged');
ball.setPointerCapture(e.pointerId);
});
ball.addEventListener('pointermove', e => {
if (!drag.active) return;
const dx = e.clientX - drag.startX, dy = e.clientY - drag.startY;
if (!drag.moved && Math.hypot(dx, dy) > 6) { drag.moved = true; ball.classList.add('dragging'); }
if (!drag.moved) return;
applyBallPos(ball, drag.originLeft + dx, drag.originTop + dy);
if (panel.classList.contains('is-open')) positionPanel(ball, panel);
});
ball.addEventListener('pointerup', e => {
if (!drag.active) return;
drag.active = false;
try { ball.releasePointerCapture(e.pointerId); } catch {}
if (drag.moved) {
saveBallPos(ball);
ball.classList.remove('dragging');
ball.classList.add('was-dragged', 'releasing');
if (panel.classList.contains('is-open')) positionPanel(ball, panel);
setTimeout(() => ball.classList.remove('was-dragged', 'releasing'), 260);
return;
}
togglePanel();
});
ball.addEventListener('pointercancel', () => { drag.active = false; ball.classList.remove('dragging'); });
window.addEventListener('resize', () => {
const r = ball.getBoundingClientRect();
applyBallPos(ball, r.left, r.top);
if (panel.classList.contains('is-open')) positionPanel(ball, panel);
});
// ---- 初始化位置和样式 ----
applyUiToElements(ball, panel);
const pos = loadBallPos() || getDefaultBallPos();
applyBallPos(ball, pos.left, pos.top);
if (!GM_getValue(STORAGE_KEYS.wizardSeen, false)) {
setTimeout(showWizard, 500);
}
log('🚀 学习通AI答题助手已加载 v1.0.0', '#55efc4');
}
// ============================================================
// UI 辅助
// ============================================================
function getDefaultBallPos() {
return { left: DEFAULT_MARGIN, top: Math.max(DEFAULT_MARGIN, window.innerHeight - uiSettings.ballSize - 80) };
}
function loadBallPos() {
if (!uiSettings.rememberPosition) return null;
const p = safeParse(GM_getValue(STORAGE_KEYS.ballPos, ''));
return p && Number.isFinite(p.left) && Number.isFinite(p.top) ? p : null;
}
function saveBallPos(ball) {
if (!uiSettings.rememberPosition) return;
const r = ball.getBoundingClientRect();
saveJSON(STORAGE_KEYS.ballPos, { left: Math.round(r.left), top: Math.round(r.top) });
}
function applyBallPos(ball, left, top) {
const s = uiSettings.ballSize;
const maxL = Math.max(DEFAULT_MARGIN, window.innerWidth - s - DEFAULT_MARGIN);
const maxT = Math.max(DEFAULT_MARGIN, window.innerHeight - s - DEFAULT_MARGIN);
ball.style.left = `${clamp(left, DEFAULT_MARGIN, maxL)}px`;
ball.style.top = `${clamp(top, DEFAULT_MARGIN, maxT)}px`;
ball.style.right = 'auto'; ball.style.bottom = 'auto';
}
function positionPanel(ball, panel) {
const bR = ball.getBoundingClientRect();
const pR = panel.getBoundingClientRect();
const gap = 14;
let left = bR.left, top = bR.top - pR.height - gap;
const below = top < DEFAULT_MARGIN;
if (below) top = bR.bottom + gap;
left = clamp(left, DEFAULT_MARGIN, Math.max(DEFAULT_MARGIN, window.innerWidth - pR.width - DEFAULT_MARGIN));
top = clamp(top, DEFAULT_MARGIN, Math.max(DEFAULT_MARGIN, window.innerHeight - pR.height - DEFAULT_MARGIN));
panel.style.left = `${left}px`; panel.style.top = `${top}px`; panel.style.bottom = 'auto';
panel.style.transformOrigin = below ? 'left top' : 'left bottom';
}
function applyUiToElements(ball, panel) {
const s = uiSettings.ballSize;
ball.style.width = `${s}px`; ball.style.height = `${s}px`;
ball.style.fontSize = `${clamp(Math.round(s * 0.2), 11, 16)}px`;
ball.style.setProperty('--yan-float-duration', `${uiSettings.floatDuration}s`);
ball.style.animation = uiSettings.reducedMotion ? 'none' : `yan-morph ${uiSettings.floatDuration}s ease-in-out infinite, yan-breathe 3.1s ease-in-out infinite`;
document.documentElement.style.setProperty('--yan-panel-width', `${uiSettings.panelWidth}px`);
document.documentElement.style.setProperty('--yan-panel-opacity', `${uiSettings.panelOpacity / 100}`);
}
// ============================================================
// 启动
// ============================================================
hookUEditor();
hookPasteEvents();
const startUpdateGrid = () => {
const grid = document.getElementById('yan-question-grid');
if (grid && typeof updateQuestionGrid === 'function') {
updateQuestionGrid(true);
}
};
// 初始化并立即检测
init();
// 页面加载后立即开始检测题目
setTimeout(() => {
startUpdateGrid();
log('🔍 正在检测页面题目...', '#74b9ff');
}, 1000);
// 定期更新
setInterval(init, 5000);
setInterval(startUpdateGrid, 10000);
})();