// ==UserScript== // @name 右键加入墨墨背单词·阅读理解版(多域名·美化版) // @namespace maimemo.contextmenu // @version 2.0.0 // @description 任意网页右键单词一键加入墨墨待背列表;新增「阅读理解生成」:拉取今日词汇,自选/随机选词,对接 DeepSeek 生成考研风格阅读理解+译文+题目。原功能保持不变。 // @author you // @match *://*/* // @connect open.maimemo.com // @connect api.deepseek.com // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @run-at document-idle // @license MIT // ==/UserScript== (function () { 'use strict'; // ============================================================ // 一、配置(本地存储) // ============================================================ const CONFIG_KEY = 'mm_catchword_config'; const HISTORY_KEY = 'mm_catchword_history'; // 阅读理解历史(带上限) const DEFAULT_CONFIG = { token: '', // 墨墨开放 API 凭证(App: 我的->更多设置->实验功能->开放API) apiBase: 'https://open.maimemo.com/open', domains: '', // 生效域名白名单:每行一个,或用逗号/分号/空格分隔;留空=所有网站 enabled: true, // 总开关 advance: false, // 添加后是否一并提前复习 maxWords: 20, // 单次批量最大单词数(受频控 10秒20次 限制) showFab: true, // 是否显示右下角悬浮按钮 debug: false, // 调试日志开关 // ---- 阅读理解 / AI 生成 ---- deepseekApiKey: '', // DeepSeek API Key(仅存脚本本地) deepseekApiBase: 'https://api.deepseek.com', // DeepSeek 基址 deepseekModel: 'deepseek-chat', // 模型名,可改 deepseek-reasoner / deepseek-flash 等 storeHistory: true, // 是否保存生成历史 historyCap: 50, // 历史最大条数(约每条3-5KB,50条≈250KB,本地无压力) readingCount: 20, // 默认生成词数 readingDifficulty: '中' // 易 / 中 / 难 }; function loadConfig() { try { const s = GM_getValue(CONFIG_KEY, {}); return Object.assign({}, DEFAULT_CONFIG, s || {}); } catch (e) { return Object.assign({}, DEFAULT_CONFIG); } } function saveConfig(c) { GM_setValue(CONFIG_KEY, c); } let cfg = loadConfig(); // ============================================================ // 二、多域名白名单 // ============================================================ function parseDomains(str) { return (str || '').split(/[\s,;,;]+/) .map(s => s.trim().toLowerCase().replace(/^(\*\.)?/, '').replace(/:\d+$/, '')) .filter(Boolean); } function hostAllowed() { const list = parseDomains(cfg.domains); if (!list.length) return true; const host = location.hostname.toLowerCase(); return list.some(d => host === d || host.endsWith('.' + d)); } // ============================================================ // 三、请求封装(GM_xmlhttpRequest 解决跨域 + 调试日志) // token 传入:墨墨用 cfg.token,DeepSeek 用 cfg.deepseekApiKey // ============================================================ function request(method, url, body, token) { return new Promise((resolve) => { const tok = token || cfg.token; const headers = { 'Accept': 'application/json' }; if (tok) headers['Authorization'] = 'Bearer ' + tok.trim(); if (body) headers['Content-Type'] = 'application/json'; GM_xmlhttpRequest({ method: method, url: url, headers: headers, data: body ? JSON.stringify(body) : undefined, timeout: 30000, onload: (res) => { if (cfg.debug) { console.log('[墨墨] 请求', method, url); if (body) console.log('[墨墨] 请求体', JSON.stringify(body)); console.log('[墨墨] 状态码', res.status); console.log('[墨墨] 返回', res.responseText); } let json = null; try { json = JSON.parse(res.responseText); } catch (e) { /* not json */ } resolve({ status: res.status, json: json, raw: res.responseText }); }, onerror: (err) => { if (cfg.debug) console.error('[墨墨] 网络请求失败', err); resolve({ status: 0, json: null, raw: '' }); }, ontimeout: () => { if (cfg.debug) console.error('[墨墨] 请求超时', method, url); resolve({ status: 0, json: null, raw: '' }); } }); }); } // 从响应中提取词汇(兼容 data.voc / data / voc 三种结构) function pickVoc(json) { if (!json) return null; const d = json.data !== undefined ? json.data : json; if (d && d.voc !== undefined) return d.voc; return d || null; } // 1) 按拼写查词(官方 demo 用法:GET /vocabulary?spelling=) async function queryVocab(words) { const base = (cfg.apiBase || DEFAULT_CONFIG.apiBase).replace(/\/+$/, ''); const found = {}; const missing = []; for (const w of words) { const res = await request('GET', base + '/api/v1/memo/vocabulary?spelling=' + encodeURIComponent(w), null, cfg.token); if (res.status === 401 || res.status === 403) { return { err: 'TOKEN', found: {}, missing: words }; } if (res.status !== 200 || !res.json) { missing.push(w); continue; } const voc = pickVoc(res.json); if (voc && voc.id && voc.spelling) { found[voc.spelling] = voc; } else { missing.push(w); } } if (cfg.debug) console.log('[墨墨] 查词命中:', Object.keys(found), '未命中:', missing); return { err: null, found: found, missing: missing }; } // 2) 把单词 id 加入学习规划(待背);服务端自动去重,added_count 表示实际新增数 async function addWords(ids) { const base = (cfg.apiBase || DEFAULT_CONFIG.apiBase).replace(/\/+$/, ''); const body = { words: ids.map(id => ({ id: id })), advance: !!cfg.advance }; const res = await request('POST', base + '/api/v1/memo/study/add_words', body, cfg.token); if (res.status === 401 || res.status === 403) return { err: 'TOKEN', added: 0 }; if (res.status !== 200 || !res.json) return { err: 'ADD', added: 0 }; const d = res.json.data !== undefined ? res.json.data : res.json; return { err: null, added: (d && d.added_count) || 0 }; } // 3) 拉取今日学习单词列表(GET /study/get_today_items) // 返回结构有差异,这里做防御式解析:兼容 data.items / data.list / data / 元素内含 voc / spelling / voc_spelling function pickSpelling(it) { if (!it || typeof it !== 'object') return ''; const c = (v) => (typeof v === 'string' ? v : (v && v.spelling)); for (const key of ['voc_spelling', 'spelling', 'word', 'voc', 'vocabulary']) { const v = c(it[key]); if (v) return String(v).toLowerCase(); } return ''; } async function getTodayItems() { const base = (cfg.apiBase || DEFAULT_CONFIG.apiBase).replace(/\/+$/, ''); // const res = await request('GET', base + '/api/v1/memo/study/get_today_items', null, cfg.token); // ✅ POST,body 空对象 const res = await request('POST', base + '/api/v1/memo/study/get_today_items', {}); if (res.status === 401 || res.status === 403) return { err: 'TOKEN', words: [] }; if (res.status !== 200 || !res.json) return { err: 'FETCH', words: [] }; const d = res.json.data !== undefined ? res.json.data : res.json; let items = []; if (Array.isArray(d)) items = d; else if (d && Array.isArray(d.items)) items = d.items; else if (d && Array.isArray(d.list)) items = d.list; else if (d && Array.isArray(d.today_items)) items = d.today_items; const seen = new Set(), words = []; for (const it of items) { const sp = pickSpelling(it); if (sp && !seen.has(sp)) { seen.add(sp); words.push(sp); } } return { err: null, words: words }; } // ============================================================ // 四、浮动 toast(同原版) // ============================================================ let toastWrap = null; function toast(msg, type, ms) { type = type || 'info'; if (!toastWrap) { toastWrap = document.createElement('div'); toastWrap.style.cssText = 'position:fixed;top:18px;left:50%;transform:translateX(-50%);' + 'z-index:2147483647;display:flex;flex-direction:column;align-items:center;' + 'gap:8px;pointer-events:none;'; document.body.appendChild(toastWrap); } const style = ({ success: { bg: '#ecfdf5', color: '#065f46', border: '#34d399', icon: '✓' }, error: { bg: '#fef2f2', color: '#991b1b', border: '#f87171', icon: '✕' }, info: { bg: '#eff6ff', color: '#1e40af', border: '#93c5fd', icon: 'ℹ' }, warn: { bg: '#fffbeb', color: '#92400e', border: '#fcd34d', icon: '!' } })[type] || { bg: '#eff6ff', color: '#1e40af', border: '#93c5fd', icon: 'ℹ' }; const el = document.createElement('div'); el.style.cssText = 'display:flex;align-items:flex-start;gap:8px;max-width:520px;padding:10px 14px;' + 'border-radius:10px;background:' + style.bg + ';color:' + style.color + ';' + 'border:1px solid ' + style.border + ';box-shadow:0 8px 24px rgba(0,0,0,.12);' + 'font:13px/1.55 sans-serif;white-space:pre-wrap;word-break:break-word;' + 'opacity:0;transform:translateY(-14px);transition:all .28s ease;'; const icon = document.createElement('span'); icon.textContent = style.icon; icon.style.cssText = 'font-weight:700;flex-shrink:0;'; const span = document.createElement('span'); span.textContent = msg; el.appendChild(icon); el.appendChild(span); toastWrap.appendChild(el); requestAnimationFrame(() => { el.style.opacity = '1'; el.style.transform = 'translateY(0)'; }); const t = ms || 2800; setTimeout(() => { el.style.opacity = '0'; el.style.transform = 'translateY(-14px)'; setTimeout(() => el.remove(), 300); }, t); } // ============================================================ // 五、右键菜单(加入墨墨 / 阅读理解 / 配置) // ============================================================ let ctxEl = null; function removeCtx() { if (ctxEl) { ctxEl.remove(); ctxEl = null; } } function wordAtPoint(x, y) { try { let range = null; if (document.caretRangeFromPoint) range = document.caretRangeFromPoint(x, y); else if (document.caretPositionFromPoint) { const pos = document.caretPositionFromPoint(x, y); if (pos && pos.offsetNode) { range = document.createRange(); range.setStart(pos.offsetNode, pos.offset); } } if (!range || !range.startContainer) return ''; const node = range.startContainer; if (node.nodeType !== 3) return ''; const offset = range.startOffset; const text = node.textContent || ''; const left = text.slice(0, offset), right = text.slice(offset); const mL = left.match(/([A-Za-z]+(?:['’-][A-Za-z]+)*)$/); const mR = right.match(/^([A-Za-z]+(?:['’-][A-Za-z]+)*)/); return ((mL ? mL[1] : '') + (mR ? mR[1] : '')).toLowerCase().replace(/['’]/g, "'"); } catch (e) { return ''; } } function menuItem(text, onHover, onOut) { const it = document.createElement('div'); it.textContent = text; it.style.cssText = 'padding:10px 12px;cursor:pointer;display:flex;align-items:center;gap:6px;'; it.onmouseover = () => { it.style.background = onHover; }; it.onmouseout = () => { it.style.background = onOut; }; it.onmousedown = (e) => e.preventDefault(); return it; } function showCtx(x, y, word) { removeCtx(); ctxEl = document.createElement('div'); ctxEl.style.cssText = 'position:fixed;z-index:2147483646;min-width:200px;background:#fff;color:#1f2937;' + 'border:1px solid #e5e7eb;border-radius:12px;box-shadow:0 10px 30px rgba(0,0,0,.16);' + 'font:13px/1.4 sans-serif;overflow:hidden;user-select:none;'; const title = document.createElement('div'); title.textContent = '墨墨背单词'; title.style.cssText = 'padding:8px 12px;font-size:11px;color:#6b7280;background:#f9fafb;' + 'border-bottom:1px solid #f3f4f6;'; ctxEl.appendChild(title); const addItem = menuItem(word ? '📖 加入墨墨:' + word : '📖 加入墨墨(选中)', '#eff6ff', '#fff'); addItem.onclick = (e) => { e.stopPropagation(); removeCtx(); if (word) handleAdd([word]); else { const sel = window.getSelection() ? window.getSelection().toString() : ''; const ws = extractWords(sel).slice(0, cfg.maxWords); if (ws.length) handleAdd(ws); else toast('未识别到英文单词', 'warn'); } }; const readItem = menuItem('📝 阅读理解(今日词汇)', '#eff6ff', '#fff'); readItem.onclick = (e) => { e.stopPropagation(); removeCtx(); openReadingPanel(); }; const cfgItem = menuItem('⚙️ 配置', '#eff6ff', '#fff'); cfgItem.style.cssText = cfgItem.style.cssText + 'border-top:1px solid #f3f4f6;'; cfgItem.onclick = (e) => { e.stopPropagation(); removeCtx(); openPanel(); }; ctxEl.appendChild(addItem); ctxEl.appendChild(readItem); ctxEl.appendChild(cfgItem); const vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight; let px = x, py = y; if (px + 200 > vw) px = vw - 210; if (py + 150 > vh) py = vh - 160; if (px < 4) px = 4; if (py < 4) py = 4; ctxEl.style.left = px + 'px'; ctxEl.style.top = py + 'px'; document.body.appendChild(ctxEl); } function extractWords(text) { const raw = (text.match(/[A-Za-z]+(?:['’-][A-Za-z]+)*/g) || []); const seen = new Set(), out = []; for (const w of raw) { const k = w.toLowerCase().replace(/['’]/g, "'"); if (!seen.has(k)) { seen.add(k); out.push(k); } } return out; } async function handleAdd(words) { if (!cfg.token) { toast('未配置墨墨 Token,正在打开配置面板…', 'warn'); openPanel(); return; } if (!words.length) return; toast('正在查询 ' + words.slice(0, 5).join('、') + (words.length > 5 ? ' …' : ''), 'info'); const q = await queryVocab(words); if (q.err === 'TOKEN') { toast('❌ Token 无效或已过期,请到配置面板更新', 'error'); openPanel(); return; } const ids = Object.keys(q.found).map(sp => q.found[sp].id); const missing = q.missing; if (!ids.length) { toast('墨墨词库未收录:\n' + missing.join('、'), 'warn'); return; } const r = await addWords(ids); if (r.err === 'TOKEN') { toast('❌ Token 无效或已过期,请到配置面板更新', 'error'); openPanel(); return; } if (r.err === 'ADD') { toast('⚠️ 添加接口异常,请检查 API 地址或稍后重试', 'error'); return; } const added = r.added, existed = ids.length - added; const lines = []; lines.push('✅ 已新增 ' + added + ' 个待背单词'); if (existed > 0) lines.push('⏭️ ' + existed + ' 个已存在,自动跳过'); if (missing.length > 0) lines.push('❓ 词库未收录:' + missing.slice(0, 5).join('、') + (missing.length > 5 ? ' …' : '')); toast(lines.join('\n'), added > 0 ? 'success' : 'info', 3600); } // ============================================================ // 六、DeepSeek 对话(非流式 JSON,解析最稳) // base: https://api.deepseek.com POST /chat/completions // ============================================================ async function chatCompletion(messages, temperature) { if (!cfg.deepseekApiKey) return { err: 'NO_KEY', content: '' }; const base = (cfg.deepseekApiBase || 'https://api.deepseek.com').replace(/\/+$/, ''); const body = { model: cfg.deepseekModel || 'deepseek-chat', messages: messages, temperature: (temperature === undefined ? 0.7 : temperature), stream: false }; const res = await request('POST', base + '/chat/completions', body, cfg.deepseekApiKey); if (res.status === 401 || res.status === 403) return { err: 'KEY', content: '' }; if (res.status !== 200 || !res.json) { const msg = (res.json && res.json.error && res.json.error.message) || ('HTTP ' + res.status); return { err: msg, content: '' }; } const content = res.json.choices && res.json.choices[0] && res.json.choices[0].message ? (res.json.choices[0].message.content || '') : ''; return { err: null, content: content }; } // 难度 → 提示词 function difficultyDesc(d) { return ({ '易': '文章用词相对简单、句子结构清晰,主题贴近日常生活或科普常识,尽量让今天学的词自然出现。', '中': '标准考研英语(二)难度:词汇有一定学术性、含少量长难句,题材接近社科/科技/经济类。', '难': '接近考研英语(二)偏难篇章:长难句较多、逻辑复杂,用词更书面化、有一定抽象度。' })[d] || '标准考研英语(二)难度。'; } function passageLen(n) { if (n <= 20) return '约250-300词'; if (n <= 50) return '约400-500词'; return '约650-800词'; } // 构造生成提示词 function buildReadingPrompt(words, difficulty, count) { const wordStr = words.slice(0, count).join(', '); return [ '你是资深考研英语命题人。请根据下面给定的英文词汇,创作一篇考研英语(二)阅读理解风格的英文文章,并出4道阅读理解题。', '', '【词汇清单】', wordStr, '', '【要求】', '1. 文章长度:' + passageLen(count) + ',主题自选,语言地道自然。', '2. 尽量把清单里的高频词汇自然融入文章(不必逐一硬用,优先覆盖有实际表达价值的词,可作词性/单复数/时态变形)。', '3. 难度:' + difficultyDesc(difficulty), '4. 出4道题,题型尽量多样(主旨大意、事实细节、推理判断、词义猜测、作者态度等),每道题给出A/B/C/D四个选项,并附正确答案与中文简要解析。', '5. 同时给出整篇文章的中文翻译(translation字段),供学习者参考(学习者可自行对照翻译)。', '', '【输出】只输出一个合法JSON对象,不要任何额外文字、不要markdown代码块。结构如下:', '{', ' "title": "中文标题",', ' "passage": "英文正文(用\\n分段)",', ' "translation": "中文全文翻译",', ' "questions": [', ' {', ' "question": "题干",', ' "options": {"A":"...","B":"...","C":"...","D":"..."},', ' "answer": "A",', ' "explanation": "中文解析"', ' }', ' ]', '}' ].join('\n'); } // 解析 AI 返回的 JSON(容忍 ``` 包裹) function parseReadingJson(text) { if (!text) return null; let t = text.trim(); const fence = t.match(/```(?:json)?([\s\S]*?)```/); if (fence) t = fence[1].trim(); // 若开头不是 { ,尝试截取第一个 { 到最后一个 } if (t[0] !== '{') { const s = t.indexOf('{'), e = t.lastIndexOf('}'); if (s >= 0 && e > s) t = t.slice(s, e + 1); } try { const obj = JSON.parse(t); if (obj && (obj.passage || obj.questions)) return obj; return null; } catch (e) { if (cfg.debug) console.error('[墨墨] 解析AI返回失败', e, text); return null; } } // ============================================================ // 七、历史记录存储(带条数上限,本地开销可忽略) // ============================================================ function loadHistory() { try { const h = GM_getValue(HISTORY_KEY, []); return Array.isArray(h) ? h : []; } catch (e) { return []; } } function saveHistory(rec) { if (!cfg.storeHistory) return; let h = loadHistory(); h.unshift(rec); const cap = Math.max(1, parseInt(cfg.historyCap, 10) || 50); if (h.length > cap) h = h.slice(0, cap); GM_setValue(HISTORY_KEY, h); } // ============================================================ // 八、阅读理解面板 // ============================================================ let readPanel = null, readMask = null; let todayWords = []; // 今日词汇(含释义占位) let readingMode = 'random'; // random / self let curResult = null; // 当前显示的结果 let readCountInput = null, diffBtns = {}; function closeReadingPanel() { if (readPanel) { readPanel.remove(); readPanel = null; } if (readMask) { readMask.remove(); readMask = null; } } function randPick(arr, n) { const a = arr.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a.slice(0, n); } function openReadingPanel() { closeReadingPanel(); readMask = document.createElement('div'); readMask.style.cssText = 'position:fixed;inset:0;z-index:2147483646;background:rgba(17,24,39,.45);' + 'backdrop-filter:blur(2px);display:flex;align-items:center;justify-content:center;'; readMask.onmousedown = (e) => { if (e.target === readMask) closeReadingPanel(); }; readPanel = document.createElement('div'); readPanel.style.cssText = 'width:780px;max-width:94vw;max-height:92vh;overflow:auto;background:#fff;' + 'border-radius:16px;box-shadow:0 24px 60px rgba(0,0,0,.28);font:14px/1.6 sans-serif;color:#1f2937;'; // 头部 const head = document.createElement('div'); head.style.cssText = 'padding:16px 18px;border-bottom:1px solid #f3f4f6;display:flex;justify-content:space-between;' + 'align-items:center;background:linear-gradient(135deg,#10b981,#059669);border-radius:16px 16px 0 0;color:#fff;'; const hTitle = document.createElement('div'); hTitle.textContent = '📝 阅读理解生成(今日词汇 × AI)'; hTitle.style.cssText = 'font-weight:600;font-size:15px;'; const hClose = document.createElement('span'); hClose.textContent = '✕'; hClose.style.cssText = 'cursor:pointer;font-size:16px;opacity:.85;padding:2px 6px;'; hClose.onclick = closeReadingPanel; head.appendChild(hTitle); head.appendChild(hClose); const body = document.createElement('div'); body.style.cssText = 'padding:16px 18px;'; // ---------- 词汇选择区 ---------- const selCard = document.createElement('div'); selCard.style.cssText = 'border:1px solid #e5e7eb;border-radius:12px;padding:14px;margin-bottom:14px;background:#fafafa;'; const selTitle = document.createElement('div'); selTitle.textContent = '① 选词(今日待学词汇)'; selTitle.style.cssText = 'font-size:12px;font-weight:600;color:#374151;margin-bottom:8px;'; const loadRow = document.createElement('div'); loadRow.style.cssText = 'display:flex;align-items:center;gap:10px;margin-bottom:8px;'; const loadBtn = document.createElement('button'); loadBtn.textContent = '加载今日词汇'; loadBtn.style.cssText = 'padding:7px 14px;border:0;border-radius:8px;background:linear-gradient(135deg,#10b981,#059669);color:#fff;font-size:13px;cursor:pointer;'; const loadStatus = document.createElement('span'); loadStatus.textContent = '未加载'; loadStatus.style.cssText = 'font-size:12px;color:#6b7280;'; loadRow.appendChild(loadBtn); loadRow.appendChild(loadStatus); // 选词模式 const modeRow = document.createElement('div'); modeRow.style.cssText = 'display:flex;align-items:center;gap:14px;margin:6px 0 8px;flex-wrap:wrap;'; function modeLabel(text, key) { const lab = document.createElement('label'); lab.style.cssText = 'display:flex;align-items:center;gap:5px;font-size:13px;color:#374151;cursor:pointer;'; const rb = document.createElement('input'); rb.type = 'radio'; rb.name = 'mm_mode'; rb.value = key; rb.style.cssText = 'accent-color:#10b981;cursor:pointer;'; lab.appendChild(rb); lab.appendChild(document.createTextNode(text)); modeRow.appendChild(lab); return rb; } const rbRandom = modeLabel('随机抽取', 'random'); const rbSelf = modeLabel('自选今日词汇', 'self'); rbRandom.checked = true; // 数量 const countLabel = document.createElement('span'); countLabel.textContent = '词数:'; countLabel.style.cssText = 'font-size:13px;color:#374151;margin-left:6px;'; readCountInput = document.createElement('input'); readCountInput.type = 'number'; readCountInput.min = '1'; readCountInput.max = '200'; readCountInput.value = cfg.readingCount || 20; readCountInput.style.cssText = 'width:70px;padding:5px 8px;border:1px solid #d1d5db;border-radius:7px;font-size:13px;'; const quick = document.createElement('span'); quick.style.cssText = 'display:inline-flex;gap:5px;margin-left:8px;'; [20, 50, 100].forEach(n => { const b = document.createElement('button'); b.textContent = n; b.style.cssText = 'padding:3px 9px;border:1px solid #d1d5db;border-radius:6px;background:#fff;font-size:12px;cursor:pointer;'; b.onclick = () => { readCountInput.value = n; }; quick.appendChild(b); }); countLabel.style.cssText = 'font-size:13px;color:#374151;margin-left:6px;'; modeRow.appendChild(countLabel); modeRow.appendChild(readCountInput); modeRow.appendChild(quick); // 难度 const diffWrap = document.createElement('div'); diffWrap.style.cssText = 'display:flex;align-items:center;gap:6px;margin:8px 0;flex-wrap:wrap;'; const diffLab = document.createElement('span'); diffLab.textContent = '难度:'; diffLab.style.cssText = 'font-size:13px;color:#374151;'; diffWrap.appendChild(diffLab); ['易', '中', '难'].forEach(d => { const b = document.createElement('button'); b.textContent = d; b.style.cssText = 'padding:4px 12px;border:1px solid #d1d5db;border-radius:7px;background:#fff;font-size:13px;cursor:pointer;'; const sel = d === (cfg.readingDifficulty || '中'); if (sel) { b.style.background = '#10b981'; b.style.color = '#fff'; b.style.borderColor = '#10b981'; } b.onclick = () => { Object.values(diffBtns).forEach(x => { x.style.background = '#fff'; x.style.color = '#374151'; x.style.borderColor = '#d1d5db'; }); b.style.background = '#10b981'; b.style.color = '#fff'; b.style.borderColor = '#10b981'; cfg.readingDifficulty = d; }; diffBtns[d] = b; diffWrap.appendChild(b); }); // 自选词汇列表(加载后显示) const wordListWrap = document.createElement('div'); wordListWrap.style.cssText = 'display:none;max-height:180px;overflow:auto;border:1px solid #e5e7eb;border-radius:8px;background:#fff;padding:8px;margin-top:6px;'; // 生成按钮 const genRow = document.createElement('div'); genRow.style.cssText = 'display:flex;gap:10px;margin-top:12px;'; const genBtn = document.createElement('button'); genBtn.textContent = '🚀 生成阅读理解'; genBtn.style.cssText = 'flex:1;padding:10px 0;border:0;border-radius:9px;background:linear-gradient(135deg,#10b981,#059669);color:#fff;font-size:14px;font-weight:600;cursor:pointer;'; const clearBtn = document.createElement('button'); clearBtn.textContent = '清空结果'; clearBtn.style.cssText = 'padding:10px 18px;border:1px solid #d1d5db;border-radius:9px;background:#fff;color:#374151;font-size:13px;cursor:pointer;'; genRow.appendChild(genBtn); genRow.appendChild(clearBtn); selCard.appendChild(selTitle); selCard.appendChild(loadRow); selCard.appendChild(modeRow); selCard.appendChild(diffWrap); selCard.appendChild(wordListWrap); selCard.appendChild(genRow); // ---------- 结果区 ---------- const resultCard = document.createElement('div'); resultCard.style.cssText = 'border:1px solid #e5e7eb;border-radius:12px;padding:14px;margin-bottom:14px;background:#fff;'; const resHead = document.createElement('div'); resHead.style.cssText = 'display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;flex-wrap:wrap;gap:8px;'; const resTitle = document.createElement('div'); resTitle.textContent = '② 生成结果'; resTitle.style.cssText = 'font-size:12px;font-weight:600;color:#374151;'; // 译文开关 const transToggle = document.createElement('label'); transToggle.style.cssText = 'display:flex;align-items:center;gap:6px;font-size:12px;color:#374151;cursor:pointer;'; const transCb = document.createElement('input'); transCb.type = 'checkbox'; transCb.style.cssText = 'accent-color:#10b981;cursor:pointer;'; const transText = document.createTextNode('显示中文译文'); transToggle.appendChild(transCb); transToggle.appendChild(transText); transCb.addEventListener('change', () => { const tr = readPanel.querySelector('.mm-translation'); if (tr) tr.style.display = transCb.checked ? 'block' : 'none'; }); resHead.appendChild(resTitle); resHead.appendChild(transToggle); const resContent = document.createElement('div'); resContent.style.cssText = 'font-size:14px;line-height:1.75;color:#1f2937;'; resContent.innerHTML = '
生成结果将显示在这里。
'; resultCard.appendChild(resHead); resultCard.appendChild(resContent); // ---------- 历史记录 ---------- const histCard = document.createElement('div'); histCard.style.cssText = 'border:1px solid #e5e7eb;border-radius:12px;padding:14px;background:#fafafa;'; const histHead = document.createElement('div'); histHead.style.cssText = 'display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;'; const histTitle = document.createElement('div'); histTitle.textContent = '③ 历史记录(' + (cfg.storeHistory ? '最多' + (cfg.historyCap||50) + '条' : '已关闭') + ')'; histTitle.style.cssText = 'font-size:12px;font-weight:600;color:#374151;'; const histClear = document.createElement('button'); histClear.textContent = '清空历史'; histClear.style.cssText = 'padding:4px 10px;border:1px solid #d1d5db;border-radius:7px;background:#fff;font-size:12px;cursor:pointer;'; histHead.appendChild(histTitle); histHead.appendChild(histClear); const histList = document.createElement('div'); histList.style.cssText = 'max-height:220px;overflow:auto;'; histCard.appendChild(histHead); histCard.appendChild(histList); body.appendChild(selCard); body.appendChild(resultCard); body.appendChild(histCard); readPanel.appendChild(head); readPanel.appendChild(body); readMask.appendChild(readPanel); document.body.appendChild(readMask); // ---------- 事件 ---------- loadBtn.onclick = async () => { if (!cfg.token) { toast('未配置墨墨 Token,请先到配置面板填写', 'warn'); openPanel(); return; } loadBtn.disabled = true; loadStatus.textContent = '加载中…'; const r = await getTodayItems(); if (r.err === 'TOKEN') { loadStatus.textContent = 'Token 无效'; toast('❌ 墨墨 Token 无效或已过期', 'error'); openPanel(); loadBtn.disabled = false; return; } if (r.err === 'FETCH') { loadStatus.textContent = '加载失败'; toast('⚠️ 拉取今日词汇失败', 'error'); loadBtn.disabled = false; return; } todayWords = r.words; loadStatus.textContent = '今日 ' + todayWords.length + ' 个单词'; loadBtn.disabled = false; // 渲染自选列表 wordListWrap.innerHTML = ''; if (todayWords.length) { wordListWrap.style.display = 'block'; todayWords.forEach(w => { const lab = document.createElement('label'); lab.style.cssText = 'display:flex;align-items:center;gap:6px;font-size:13px;color:#374151;cursor:pointer;padding:2px 4px;'; const cb = document.createElement('input'); cb.type = 'checkbox'; cb.value = w; cb.style.cssText = 'accent-color:#10b981;cursor:pointer;'; lab.appendChild(cb); lab.appendChild(document.createTextNode(w)); wordListWrap.appendChild(lab); }); } else { wordListWrap.style.display = 'block'; wordListWrap.innerHTML = '
今日没有待学词汇,可先到墨墨 App 添加,或改用右键加词。
'; } }; rbRandom.onchange = () => { readingMode = 'random'; wordListWrap.style.display = todayWords.length ? 'none' : 'none'; }; rbSelf.onchange = () => { readingMode = 'self'; wordListWrap.style.display = todayWords.length ? 'block' : 'block'; }; genBtn.onclick = async () => { if (!cfg.deepseekApiKey) { toast('未配置 DeepSeek API Key,请先到配置面板填写', 'warn'); openPanel(); return; } if (!todayWords.length) { toast('请先加载今日词汇', 'warn'); return; } const count = Math.max(1, Math.min(200, parseInt(readCountInput.value, 10) || 20)); let picked = []; if (readingMode === 'self') { const cbs = wordListWrap.querySelectorAll('input[type=checkbox]:checked'); picked = Array.from(cbs).map(c => c.value); } else { picked = randPick(todayWords, count); } if (!picked.length) { toast('未选择任何词汇', 'warn'); return; } genBtn.disabled = true; const oldTxt = genBtn.textContent; genBtn.textContent = '生成中…'; resContent.innerHTML = '
⏳ 正在调用 DeepSeek 生成,请稍候(约 10~40 秒)…
'; const prompt = buildReadingPrompt(picked, cfg.readingDifficulty || '中', count); const ai = await chatCompletion([ { role: 'system', content: '你是一名资深考研英语命题专家,只输出 JSON。' }, { role: 'user', content: prompt } ], 0.8); genBtn.disabled = false; genBtn.textContent = oldTxt; if (ai.err) { resContent.innerHTML = '
❌ 生成失败:' + ai.err + '(请检查 DeepSeek API Key / 余额 / 模型名)
'; return; } const parsed = parseReadingJson(ai.content); if (!parsed) { resContent.innerHTML = '
❌ AI 返回内容无法解析为 JSON,请重试或调整模型。
'; if (cfg.debug) console.log('[墨墨] AI原文:', ai.content); return; } curResult = parsed; parsed._count = picked.length; if (cfg.storeHistory) saveHistory(parsed); renderReading(resContent, parsed); renderHistory(histList, resContent, transCb); readPanel.scrollTop = readPanel.scrollHeight; }; clearBtn.onclick = () => { curResult = null; resContent.innerHTML = '
生成结果将显示在这里。
'; transCb.checked = false; }; histClear.onclick = () => { if (!cfg.storeHistory) { toast('历史记录未开启(配置面板可开启)', 'info'); return; } GM_setValue(HISTORY_KEY, []); renderHistory(histList, resContent, transCb); toast('已清空历史记录', 'success'); }; renderHistory(histList, resContent, transCb); } // 渲染阅读理解结果 function renderReading(container, obj) { const d = document.createElement('div'); // 标题 if (obj.title) { const t = document.createElement('div'); t.textContent = '📌 ' + obj.title; t.style.cssText = 'font-weight:600;font-size:15px;color:#065f46;margin-bottom:8px;'; d.appendChild(t); } // 英文正文 if (obj.passage) { const p = document.createElement('div'); p.style.cssText = 'white-space:pre-wrap;line-height:1.8;margin-bottom:6px;'; p.textContent = obj.passage; d.appendChild(p); } // 译文(默认隐藏) if (obj.translation) { const tr = document.createElement('div'); tr.className = 'mm-translation'; tr.style.cssText = 'display:none;margin-top:10px;padding-top:10px;border-top:1px dashed #d1d5db;color:#374151;white-space:pre-wrap;line-height:1.8;background:#f9fafb;border-radius:8px;padding:10px;'; tr.textContent = '【中文译文】\n' + obj.translation; d.appendChild(tr); } // 题目 if (Array.isArray(obj.questions) && obj.questions.length) { const qh = document.createElement('div'); qh.textContent = '—— 阅读理解题 ——'; qh.style.cssText = 'margin:14px 0 6px;font-weight:600;color:#065f46;'; d.appendChild(qh); obj.questions.forEach((q, i) => { const card = document.createElement('div'); card.style.cssText = 'border:1px solid #e5e7eb;border-radius:10px;padding:12px;margin-bottom:10px;background:#fff;'; const qtext = document.createElement('div'); qtext.textContent = (i + 1) + '. ' + (q.question || ''); qtext.style.cssText = 'font-weight:600;margin-bottom:8px;'; card.appendChild(qtext); const opts = q.options || {}; const optKeys = ['A', 'B', 'C', 'D']; const optEls = {}; optKeys.forEach(k => { if (opts[k] === undefined) return; const row = document.createElement('div'); row.style.cssText = 'padding:5px 8px;margin:3px 0;border-radius:6px;font-size:13.5px;cursor:pointer;'; row.textContent = k + '. ' + opts[k]; row.dataset.answer = String(q.answer || '').toUpperCase(); row.dataset.key = k; row.onclick = () => { const correct = row.dataset.answer === k; // 先清除高亮 Object.keys(optEls).forEach(kk => { optEls[kk].style.background = '#fff'; optEls[kk].style.color = '#1f2937'; }); row.style.background = correct ? '#d1fae5' : '#fee2e2'; row.style.color = correct ? '#065f46' : '#991b1b'; // 高亮正确项 optKeys.forEach(kk => { if (optEls[kk] && optEls[kk].dataset.answer === row.dataset.answer && optEls[kk] !== row) { optEls[kk].style.background = '#d1fae5'; } }); }; optEls[k] = row; card.appendChild(row); }); const ansBtn = document.createElement('button'); ansBtn.textContent = '显示答案'; ansBtn.style.cssText = 'margin-top:8px;padding:4px 10px;border:1px solid #10b981;border-radius:7px;background:#fff;color:#059669;font-size:12px;cursor:pointer;'; ansBtn.onclick = () => { optKeys.forEach(k => { if (optEls[k]) { const correct = optEls[k].dataset.answer === k; optEls[k].style.background = correct ? '#d1fae5' : (optEls[k].style.background === 'rgb(254, 226, 226)' ? '#fee2e2' : '#fff'); if (correct) optEls[k].style.color = '#065f46'; } }); // 显示解析 let expl = card.querySelector('.mm-expl'); if (!expl && q.explanation) { expl = document.createElement('div'); expl.className = 'mm-expl'; expl.textContent = '✅ 答案:' + (q.answer || '') + ' 解析:' + q.explanation; expl.style.cssText = 'margin-top:8px;font-size:12.5px;color:#065f46;background:#ecfdf5;padding:8px;border-radius:6px;line-height:1.6;'; card.appendChild(expl); ansBtn.textContent = '隐藏答案'; } else if (expl) { expl.remove(); ansBtn.textContent = '显示答案'; optKeys.forEach(k => { if (optEls[k]) { optEls[k].style.background = '#fff'; optEls[k].style.color = '#1f2937'; } }); } }; card.appendChild(ansBtn); d.appendChild(card); }); } container.innerHTML = ''; container.appendChild(d); } // 渲染历史列表(listEl=历史容器,resContent=结果容器,transCb=译文开关) function renderHistory(listEl, resContent, transCb) { const h = loadHistory(); listEl.innerHTML = ''; if (!h.length) { listEl.innerHTML = '
暂无历史记录。
'; return; } h.forEach((rec, idx) => { const row = document.createElement('div'); row.style.cssText = 'padding:8px 10px;border:1px solid #e5e7eb;border-radius:8px;margin-bottom:6px;background:#fff;cursor:pointer;display:flex;justify-content:space-between;align-items:center;gap:8px;'; const info = document.createElement('div'); const title = rec.title || ('阅读理解 ' + (idx + 1)); const words = rec._count ? ' · 词数' + rec._count : ''; info.textContent = '📄 ' + title + words; info.style.cssText = 'font-size:13px;color:#374151;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'; const del = document.createElement('span'); del.textContent = '删'; del.style.cssText = 'font-size:11px;color:#9ca3af;cursor:pointer;flex-shrink:0;'; del.onclick = (e) => { e.stopPropagation(); const nh = loadHistory(); nh.splice(idx, 1); GM_setValue(HISTORY_KEY, nh); renderHistory(listEl, resContent, transCb); }; row.appendChild(info); row.appendChild(del); row.onclick = () => { curResult = rec; if (transCb) transCb.checked = false; renderReading(resContent, rec); }; listEl.appendChild(row); }); } // ============================================================ // 九、配置面板(在原版基础上新增 DeepSeek / 历史 配置) // ============================================================ let panel = null; let mask = null; function closePanel() { if (panel) { panel.remove(); panel = null; } if (mask) { mask.remove(); mask = null; } } function openPanel() { closePanel(); mask = document.createElement('div'); mask.style.cssText = 'position:fixed;inset:0;z-index:2147483646;background:rgba(17,24,39,.45);' + 'backdrop-filter:blur(2px);display:flex;align-items:center;justify-content:center;'; mask.onmousedown = (e) => { if (e.target === mask) closePanel(); }; panel = document.createElement('div'); panel.style.cssText = 'width:440px;max-width:94vw;max-height:90vh;overflow:auto;background:#fff;' + 'border-radius:16px;box-shadow:0 24px 60px rgba(0,0,0,.28);font:14px/1.5 sans-serif;' + 'color:#1f2937;'; const head = document.createElement('div'); head.style.cssText = 'padding:16px 18px;border-bottom:1px solid #f3f4f6;display:flex;' + 'justify-content:space-between;align-items:center;background:linear-gradient(135deg,#3b82f6,#2563eb);' + 'border-radius:16px 16px 0 0;color:#fff;'; const hTitle = document.createElement('div'); hTitle.textContent = '⚙️ 墨墨背单词 · 配置'; hTitle.style.cssText = 'font-weight:600;font-size:15px;'; const hClose = document.createElement('span'); hClose.textContent = '✕'; hClose.style.cssText = 'cursor:pointer;font-size:16px;opacity:.85;padding:2px 6px;'; hClose.onclick = closePanel; head.appendChild(hTitle); head.appendChild(hClose); const body = document.createElement('div'); body.style.cssText = 'padding:16px 18px;'; function section(text) { const s = document.createElement('div'); s.textContent = text; s.style.cssText = 'font-size:12px;font-weight:700;color:#065f46;margin:16px 0 8px;padding-bottom:4px;border-bottom:1px solid #e5e7eb;'; return s; } function field(label, hint) { const wrap = document.createElement('div'); wrap.style.cssText = 'margin-bottom:12px;'; const lab = document.createElement('div'); lab.textContent = label; lab.style.cssText = 'font-size:12px;font-weight:600;color:#374151;margin-bottom:4px;'; wrap.appendChild(lab); return { wrap: wrap, addHint: function (t) { if (!t) return; const h = document.createElement('div'); h.textContent = t; h.style.cssText = 'font-size:11px;color:#9ca3af;margin-top:4px;line-height:1.5;'; wrap.appendChild(h); } }; } function inputCss(multiline) { return 'width:100%;box-sizing:border-box;padding:8px 10px;border:1px solid #d1d5db;' + 'border-radius:8px;font-size:13px;color:#111827;outline:none;transition:border-color .15s, box-shadow .15s;' + 'resize:vertical;background:#fafafa;' + (multiline ? 'min-height:70px;font-family:inherit;' : ''); } function focusStyle(inp) { inp.onfocus = () => { inp.style.borderColor = '#3b82f6'; inp.style.boxShadow = '0 0 0 3px rgba(59,130,246,.15)'; inp.style.background = '#fff'; }; inp.onblur = () => { inp.style.borderColor = '#d1d5db'; inp.style.boxShadow = 'none'; inp.style.background = '#fafafa'; }; } // ---- 基础 ---- body.appendChild(section('基础设置')); let fToken = field('墨墨 AccessToken *'); const inToken = document.createElement('input'); inToken.type = 'text'; inToken.value = cfg.token; inToken.placeholder = 'App 实验功能-开放API 复制'; inToken.style.cssText = inputCss(); focusStyle(inToken); fToken.wrap.appendChild(inToken); fToken.addHint('墨墨App:我的 → 更多设置 → 实验功能 → 开放 API'); body.appendChild(fToken.wrap); let fDomains = field('生效域名(白名单)'); const taDomains = document.createElement('textarea'); taDomains.value = cfg.domains; taDomains.placeholder = '每行一个域名,或用逗号/分号/空格分隔。\n示例:\nwww.yingyuzhenti.com\n*.economist.com\n\n留空 = 所有网站生效'; taDomains.style.cssText = inputCss(true); focusStyle(taDomains); fDomains.wrap.appendChild(taDomains); body.appendChild(fDomains.wrap); let fApi = field('墨墨 API 请求地址'); const inApi = document.createElement('input'); inApi.type = 'text'; inApi.value = cfg.apiBase; inApi.style.cssText = inputCss(); focusStyle(inApi); fApi.wrap.appendChild(inApi); body.appendChild(fApi.wrap); let fMax = field('单次最多添加词数'); const inMax = document.createElement('input'); inMax.type = 'number'; inMax.value = cfg.maxWords; inMax.min = '1'; inMax.max = '50'; inMax.style.cssText = inputCss() + 'width:120px;'; focusStyle(inMax); fMax.wrap.appendChild(inMax); fMax.addHint('受墨墨频控限制(10秒20次),建议 ≤20'); body.appendChild(fMax.wrap); // ---- AI / 阅读理解 ---- body.appendChild(section('阅读理解 · AI 生成')); let fDs = field('DeepSeek API Key'); const inDs = document.createElement('input'); inDs.type = 'password'; inDs.value = cfg.deepseekApiKey; inDs.placeholder = 'sk-...(platform.deepseek.com 获取)'; inDs.style.cssText = inputCss(); focusStyle(inDs); fDs.wrap.appendChild(inDs); fDs.addHint('仅保存在脚本本地(GM 存储),不会外传;请勿把含 Key 的脚本分享给别人。'); body.appendChild(fDs.wrap); let fDb = field('DeepSeek 请求地址'); const inDb = document.createElement('input'); inDb.type = 'text'; inDb.value = cfg.deepseekApiBase; inDb.placeholder = 'https://api.deepseek.com'; inDb.style.cssText = inputCss(); focusStyle(inDb); fDb.wrap.appendChild(inDb); fDb.addHint('默认 https://api.deepseek.com;若换网关地址,需在脚本头部 @connect 加对应域名。'); body.appendChild(fDb.wrap); let fDm = field('模型名'); const inDm = document.createElement('input'); inDm.type = 'text'; inDm.value = cfg.deepseekModel; inDm.placeholder = 'deepseek-chat / deepseek-reasoner'; inDm.style.cssText = inputCss(); focusStyle(inDm); fDm.wrap.appendChild(inDm); fDm.addHint('常用:deepseek-chat(快) / deepseek-reasoner(更强但慢)。'); body.appendChild(fDm.wrap); // ---- 历史 ---- body.appendChild(section('历史记录')); const checkVals = {}; const checks = [ { key: 'storeHistory', label: '保存生成历史(建议开启,带条数上限,本地开销很小)' }, { key: 'enabled', label: '启用脚本' }, { key: 'advance', label: '添加后一并提前复习' }, { key: 'showFab', label: '显示右下角悬浮按钮' }, { key: 'debug', label: '调试日志(控制台打印请求详情)' } ]; const checkWrap = document.createElement('div'); checkWrap.style.cssText = 'margin:4px 0 6px;'; checks.forEach(c => { const row = document.createElement('label'); row.style.cssText = 'display:flex;align-items:center;gap:8px;margin:7px 0;font-size:13px;color:#374151;cursor:pointer;'; const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !!cfg[c.key]; cb.style.cssText = 'width:15px;height:15px;accent-color:#3b82f6;cursor:pointer;'; const span = document.createElement('span'); span.textContent = c.label; row.appendChild(cb); row.appendChild(span); checkWrap.appendChild(row); checkVals[c.key] = cb; }); body.appendChild(checkWrap); let fCap = field('历史最大条数'); const inCap = document.createElement('input'); inCap.type = 'number'; inCap.value = cfg.historyCap; inCap.min = '1'; inCap.max = '500'; inCap.style.cssText = inputCss() + 'width:120px;'; focusStyle(inCap); fCap.wrap.appendChild(inCap); fCap.addHint('每条约3-5KB,50条≈250KB,存储开销可忽略;超限自动删除最旧记录。'); body.appendChild(fCap.wrap); // 按钮 const btnRow = document.createElement('div'); btnRow.style.cssText = 'display:flex;gap:10px;margin-top:16px;'; const saveBtn = document.createElement('button'); saveBtn.textContent = '保存'; saveBtn.style.cssText = 'flex:1;padding:10px 0;border:0;border-radius:9px;background:linear-gradient(135deg,#3b82f6,#2563eb);color:#fff;font-size:14px;font-weight:600;cursor:pointer;'; saveBtn.onmouseover = () => { saveBtn.style.opacity = '.88'; }; saveBtn.onmouseout = () => { saveBtn.style.opacity = '1'; }; saveBtn.onclick = () => { if (!inToken.value.trim()) { toast('请先填写墨墨 AccessToken', 'error'); return; } cfg.token = inToken.value.trim(); cfg.domains = taDomains.value.trim(); cfg.apiBase = inApi.value.trim() || DEFAULT_CONFIG.apiBase; cfg.maxWords = Math.max(1, Math.min(50, parseInt(inMax.value, 10) || 20)); cfg.deepseekApiKey = inDs.value.trim(); cfg.deepseekApiBase = inDb.value.trim() || DEFAULT_CONFIG.deepseekApiBase; cfg.deepseekModel = inDm.value.trim() || DEFAULT_CONFIG.deepseekModel; checks.forEach(c => { cfg[c.key] = checkVals[c.key].checked; }); cfg.historyCap = Math.max(1, Math.min(500, parseInt(inCap.value, 10) || 50)); saveConfig(cfg); closePanel(); toast('配置已保存 ✓\n已存在的单词会自动跳过,不会重复添加', 'success', 3200); }; const resetBtn = document.createElement('button'); resetBtn.textContent = '重置'; resetBtn.style.cssText = 'flex:1;padding:10px 0;border:1px solid #d1d5db;border-radius:9px;background:#fff;color:#374151;font-size:14px;cursor:pointer;'; resetBtn.onmouseover = () => { resetBtn.style.background = '#f3f4f6'; }; resetBtn.onmouseout = () => { resetBtn.style.background = '#fff'; }; resetBtn.onclick = () => { cfg = Object.assign({}, DEFAULT_CONFIG); saveConfig(cfg); closePanel(); toast('已恢复默认配置', 'info'); }; btnRow.appendChild(saveBtn); btnRow.appendChild(resetBtn); body.appendChild(btnRow); const tip = document.createElement('div'); tip.textContent = '提示:墨墨 Token 与 DeepSeek Key 仅保存在脚本本地。请勿把含凭证的脚本分享给别人。'; tip.style.cssText = 'margin-top:12px;font-size:11px;color:#9ca3af;line-height:1.6;'; body.appendChild(tip); panel.appendChild(head); panel.appendChild(body); mask.appendChild(panel); document.body.appendChild(mask); } // ============================================================ // 十、右下角悬浮按钮(同原版,点击开配置) // ============================================================ let fab = null; function syncFab() { if (cfg.showFab && !fab) { fab = document.createElement('div'); fab.textContent = '📖'; fab.title = '右键加入墨墨 - 配置'; fab.style.cssText = 'position:fixed;right:18px;bottom:18px;z-index:2147483645;width:42px;height:42px;' + 'background:linear-gradient(135deg,#3b82f6,#2563eb);color:#fff;border-radius:50%;' + 'display:flex;align-items:center;justify-content:center;font-size:18px;cursor:pointer;' + 'box-shadow:0 4px 14px rgba(37,99,235,.4);user-select:none;'; fab.onmousedown = (e) => e.stopPropagation(); fab.onclick = (e) => { e.stopPropagation(); openPanel(); }; document.body.appendChild(fab); } else if (!cfg.showFab && fab) { fab.remove(); fab = null; } } // ============================================================ // 十一、事件绑定 // ============================================================ document.addEventListener('contextmenu', (e) => { if (!cfg.enabled) return; if (!hostAllowed()) return; if (mask && (e.target === mask || mask.contains(e.target))) return; if (readMask && (e.target === readMask || readMask.contains(e.target))) return; if (fab && (e.target === fab || fab.contains(e.target))) return; const word = wordAtPoint(e.clientX, e.clientY); e.preventDefault(); showCtx(e.clientX, e.clientY, word); }, true); document.addEventListener('mousedown', (e) => { if (ctxEl && e.button === 0 && e.target !== ctxEl && !ctxEl.contains(e.target)) removeCtx(); }); if (typeof GM_registerMenuCommand === 'function') { GM_registerMenuCommand('⚙️ 打开墨墨配置面板', openPanel); GM_registerMenuCommand('📝 阅读理解生成(今日词汇)', openReadingPanel); } syncFab(); })();