// ==UserScript== // @name 单词默写学习助手 // @namespace https://github.com/yourname // @version 1.0.0 // @description 选中文字后按自定义快捷键默写,含错题本、标签、历史统计图表、学习目标、数据导入导出、主题色、倒计时,全局数据共享 // @author 拾壹有点呆,本工具由Chat-GPT,DeepSeek,Gemini,Dobao完成 // @match *://*/* // @grant GM_setValue // @grant GM_getValue // ==/UserScript== (function() { 'use strict'; // ==================== 默认配置 ==================== const DEFAULTS = { IGNORE_CASE: true, IGNORE_PUNCTUATION: true, TRIM_WHITESPACE: true, AUTO_CLOSE_ON_CORRECT: true, BLUR_AMOUNT: '8px', OVERLAY_COLOR: 'rgba(0,0,0,0.7)', CUSTOM_HOTKEY: 'Ctrl+Shift', DAILY_GOAL: 10, ENABLE_TIMER: false, TIMER_SECONDS: 30, THEME_PRIMARY: '#007bff', HISTORY_DAYS: 7 }; let CONFIG = { ...DEFAULTS }; function loadConfig() { try { const saved = GM_getValue('dictation_config'); if (saved) { const parsed = JSON.parse(saved); CONFIG = { ...DEFAULTS, ...parsed }; } } catch(e) {} } function saveConfig() { try { GM_setValue('dictation_config', JSON.stringify(CONFIG)); } catch(e) {} } loadConfig(); // ==================== 统计与错题本存储(全局) ==================== let stats = { totalCorrect: 0, totalWrong: 0, todayCorrect: 0, todayWrong: 0, lastDate: new Date().toDateString(), dailyHistory: {}, wrongItems: {} }; // 扁平标签列表(字符串数组) let allTags = []; function loadStats() { try { const saved = GM_getValue('dictation_stats'); if (saved) { const parsed = JSON.parse(saved); stats = { ...stats, ...parsed }; const today = new Date().toDateString(); if (stats.lastDate !== today) { const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); const yyyymmdd = yesterday.toISOString().slice(0,10); if (!stats.dailyHistory[yyyymmdd]) { stats.dailyHistory[yyyymmdd] = { correct: stats.todayCorrect, wrong: stats.todayWrong }; } stats.todayCorrect = 0; stats.todayWrong = 0; stats.lastDate = today; saveStats(); } } const tagData = GM_getValue('dictation_tags'); if (tagData) { const parsed = JSON.parse(tagData); if (Array.isArray(parsed)) { allTags = parsed; } else if (parsed && typeof parsed === 'object') { // 兼容旧版本(标签组)→ 提取所有标签 const tagsSet = new Set(); if (parsed.tagGroups) { for (let group of Object.values(parsed.tagGroups)) { if (group.tags) group.tags.forEach(t => tagsSet.add(t)); } } allTags = Array.from(tagsSet); } else { allTags = []; } } } catch(e) {} } function saveStats() { try { GM_setValue('dictation_stats', JSON.stringify(stats)); } catch(e) {} } function saveTagData() { try { GM_setValue('dictation_tags', JSON.stringify(allTags)); } catch(e) {} } loadStats(); function updateStatistics(isCorrect, originalText) { const today = new Date(); const todayStr = today.toISOString().slice(0,10); if (stats.lastDate !== today.toDateString()) { const yesterday = new Date(today); yesterday.setDate(today.getDate() - 1); const yyyymmdd = yesterday.toISOString().slice(0,10); if (!stats.dailyHistory[yyyymmdd]) { stats.dailyHistory[yyyymmdd] = { correct: stats.todayCorrect, wrong: stats.todayWrong }; } stats.todayCorrect = 0; stats.todayWrong = 0; stats.lastDate = today.toDateString(); } if (isCorrect) { stats.totalCorrect++; stats.todayCorrect++; } else { stats.totalWrong++; stats.todayWrong++; } if (!stats.dailyHistory[todayStr]) stats.dailyHistory[todayStr] = { correct: 0, wrong: 0 }; if (isCorrect) stats.dailyHistory[todayStr].correct++; else stats.dailyHistory[todayStr].wrong++; if (!stats.wrongItems[originalText]) { stats.wrongItems[originalText] = { errorCount: 0, totalAttempts: 0, tags: [] }; } stats.wrongItems[originalText].totalAttempts++; if (!isCorrect) { stats.wrongItems[originalText].errorCount++; } else { if (stats.wrongItems[originalText].errorCount > 0) { delete stats.wrongItems[originalText]; } else { if (stats.wrongItems[originalText].totalAttempts > 0 && stats.wrongItems[originalText].errorCount === 0) { delete stats.wrongItems[originalText]; } } } for (let key in stats.wrongItems) { if (stats.wrongItems[key].errorCount === 0) delete stats.wrongItems[key]; } saveStats(); if (mainPanel && mainPanel.style.display === 'block') { if (currentTab === 'my') renderMyPanel(); if (currentTab === 'stats') renderStatsPanel(); } if (stats.todayCorrect >= CONFIG.DAILY_GOAL && CONFIG.DAILY_GOAL > 0) { if (!window._goalNotifiedToday) { window._goalNotifiedToday = true; showTip(`🎉 恭喜!今日已完成 ${CONFIG.DAILY_GOAL} 次正确默写,目标达成!`, '#28a745', '#fff'); } } else { if (stats.todayCorrect < CONFIG.DAILY_GOAL) window._goalNotifiedToday = false; } } function getWrongList(filterTag = null, searchKeyword = '') { const list = []; for (let text in stats.wrongItems) { if (searchKeyword && !text.toLowerCase().includes(searchKeyword.toLowerCase())) continue; const item = stats.wrongItems[text]; const errorRate = item.totalAttempts > 0 ? (item.errorCount / item.totalAttempts) : 0; if (filterTag && !item.tags.includes(filterTag)) continue; list.push({ text, errorCount: item.errorCount, totalAttempts: item.totalAttempts, errorRate, tags: item.tags || [] }); } list.sort((a, b) => b.errorRate - a.errorRate); return list; } function addTagToWrongItem(text, tag) { if (!stats.wrongItems[text]) return; if (!stats.wrongItems[text].tags) stats.wrongItems[text].tags = []; if (!stats.wrongItems[text].tags.includes(tag)) { stats.wrongItems[text].tags.push(tag); saveStats(); if (myPanelContainer && myPanelContainer.style.display !== 'none') renderMyPanel(); } } function removeTagFromWrongItem(text, tag) { if (!stats.wrongItems[text]) return; if (stats.wrongItems[text].tags) { stats.wrongItems[text].tags = stats.wrongItems[text].tags.filter(t => t !== tag); saveStats(); if (myPanelContainer && myPanelContainer.style.display !== 'none') renderMyPanel(); } } // ==================== 辅助函数 ==================== function normalizeText(text) { let normalized = text; if (CONFIG.TRIM_WHITESPACE) normalized = normalized.trim(); if (CONFIG.IGNORE_CASE) normalized = normalized.toLowerCase(); if (CONFIG.IGNORE_PUNCTUATION) normalized = normalized.replace(/[^\p{L}\p{N}\s]/gu, ''); return normalized; } function isMatch(userInput, original) { return normalizeText(userInput) === normalizeText(original); } // 独立对话框(带倒计时) function showIndependentDictation(originalText, onSuccess, onCancel) { const container = document.createElement('div'); container.style.cssText = 'position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); background:#fff; border-radius:12px; padding:20px; width:400px; max-width:90%; box-shadow:0 4px 20px rgba(0,0,0,0.3); z-index:10000001; font-family:system-ui,sans-serif;'; const title = document.createElement('div'); title.innerText = '📝 默写练习'; title.style.fontWeight = 'bold'; title.style.fontSize = '18px'; title.style.marginBottom = '12px'; const input = document.createElement('textarea'); input.placeholder = '在此输入您记住的内容...'; input.style.cssText = 'width:100%; height:100px; padding:8px; border:1px solid #ccc; border-radius:6px; font-size:14px; resize:vertical; box-sizing:border-box;'; const btnGroup = document.createElement('div'); btnGroup.style.cssText = 'display:flex; gap:10px; margin-top:15px; justify-content:flex-end;'; const submitBtn = document.createElement('button'); submitBtn.innerText = '✅ 提交'; submitBtn.style.cssText = 'background:#28a745; color:white; border:none; border-radius:6px; padding:8px 16px; cursor:pointer;'; const cancelBtn = document.createElement('button'); cancelBtn.innerText = '❌ 取消'; cancelBtn.style.cssText = 'background:#6c757d; color:white; border:none; border-radius:6px; padding:8px 16px; cursor:pointer;'; btnGroup.appendChild(submitBtn); btnGroup.appendChild(cancelBtn); container.appendChild(title); container.appendChild(input); container.appendChild(btnGroup); const feedback = document.createElement('div'); feedback.style.cssText = 'margin-top:10px; font-size:12px; text-align:center;'; container.appendChild(feedback); let timerDisplay = null; let timerInterval = null; let remainingSeconds = CONFIG.TIMER_SECONDS; if (CONFIG.ENABLE_TIMER && CONFIG.TIMER_SECONDS > 0) { timerDisplay = document.createElement('div'); timerDisplay.style.cssText = 'margin-top:10px; font-size:14px; font-weight:bold; text-align:center; color:#dc3545;'; container.appendChild(timerDisplay); function updateTimer() { timerDisplay.innerText = `⏱️ 剩余时间: ${remainingSeconds} 秒`; if (remainingSeconds <= 0) { if (timerInterval) clearInterval(timerInterval); submitBtn.click(); } remainingSeconds--; } updateTimer(); timerInterval = setInterval(updateTimer, 1000); } document.body.appendChild(container); let isClosed = false; function close() { if (isClosed) return; isClosed = true; if (timerInterval) clearInterval(timerInterval); container.remove(); } submitBtn.addEventListener('click', () => { if (timerInterval) clearInterval(timerInterval); const userAnswer = input.value; const correct = isMatch(userAnswer, originalText); if (correct) { feedback.innerHTML = '✅ 完全正确!'; feedback.style.color = '#28a745'; if (onSuccess) onSuccess(originalText); if (CONFIG.AUTO_CLOSE_ON_CORRECT) setTimeout(() => close(), 1000); else setTimeout(() => close(), 1500); } else { updateStatistics(false, originalText); feedback.innerHTML = '❌ 不正确,请再试试!'; feedback.style.color = '#dc3545'; input.focus(); if (CONFIG.ENABLE_TIMER && CONFIG.TIMER_SECONDS > 0) { if (timerInterval) clearInterval(timerInterval); remainingSeconds = CONFIG.TIMER_SECONDS; timerInterval = setInterval(() => { if (remainingSeconds <= 0) { clearInterval(timerInterval); submitBtn.click(); } else { timerDisplay.innerText = `⏱️ 剩余时间: ${remainingSeconds} 秒`; remainingSeconds--; } }, 1000); } } }); cancelBtn.addEventListener('click', () => { if (timerInterval) clearInterval(timerInterval); if (onCancel) onCancel(); close(); }); input.focus(); } function startDictationWithMask(text, range) { const rect = getRangeClientRect(range); if (!rect) { alert('无法定位选中文字区域'); return; } const overlayMask = document.createElement('div'); overlayMask.style.cssText = `position:fixed; left:${rect.left}px; top:${rect.top}px; width:${rect.width}px; height:${rect.height}px; background-color:${CONFIG.OVERLAY_COLOR}; backdrop-filter:blur(${CONFIG.BLUR_AMOUNT}); z-index:9999998; pointer-events:none; border-radius:4px; box-shadow:0 0 0 2px rgba(255,255,0,0.3);`; document.body.appendChild(overlayMask); showIndependentDictation(text, (original) => { updateStatistics(true, original); overlayMask.remove(); }, () => { overlayMask.remove(); } ); } function getSelectedTextAndRange() { const sel = window.getSelection(); if (!sel.isCollapsed && sel.toString().trim().length > 0) { return { text: sel.toString(), range: sel.getRangeAt(0) }; } return null; } function getRangeClientRect(range) { const rects = range.getClientRects(); if (!rects.length) return null; let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity; for (let rect of rects) { left = Math.min(left, rect.left); top = Math.min(top, rect.top); right = Math.max(right, rect.right); bottom = Math.max(bottom, rect.bottom); } return { left, top, right, bottom, width: right - left, height: bottom - top }; } // ==================== 快捷键 ==================== let hotkeyMatcher = (function() { const parts = CONFIG.CUSTOM_HOTKEY.split('+'); const required = { ctrl: false, shift: false, alt: false, meta: false, key: null }; for (let part of parts) { const p = part.toLowerCase(); if (p === 'ctrl') required.ctrl = true; else if (p === 'shift') required.shift = true; else if (p === 'alt') required.alt = true; else if (p === 'meta') required.meta = true; else required.key = part; } return (e) => { if (e.ctrlKey !== required.ctrl) return false; if (e.shiftKey !== required.shift) return false; if (e.altKey !== required.alt) return false; if (e.metaKey !== required.meta) return false; if (required.key) { const pressedKey = e.key.length === 1 ? e.key : e.key.toLowerCase(); const targetKey = required.key.length === 1 ? required.key : required.key.toLowerCase(); if (pressedKey !== targetKey) return false; } return true; }; })(); function onKeyDown(e) { const activeEl = document.activeElement; if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.isContentEditable)) return; if (hotkeyMatcher(e)) { e.preventDefault(); e.stopPropagation(); const selection = getSelectedTextAndRange(); if (selection) startDictationWithMask(selection.text, selection.range); else showTip('⚠️ 请先选中要默写的文字', '#ffc107', '#333'); } } function showTip(msg, bgColor, textColor) { const tip = document.createElement('div'); tip.textContent = msg; tip.style.cssText = `position:fixed; bottom:20px; left:20px; background:${bgColor}; color:${textColor}; padding:6px 12px; border-radius:20px; font-size:12px; z-index:10000000; font-family:sans-serif; box-shadow:0 2px 5px rgba(0,0,0,0.2);`; document.body.appendChild(tip); setTimeout(() => tip.remove(), 2000); } // ==================== UI 组件 ==================== let mainButton = null, mainPanel = null; let currentTab = 'my'; let isInSubDictation = false, subDictationText = ''; let currentFilterTag = null, currentSearchKeyword = ''; let contentContainer, myPanelContainer, statsPanelContainer, settingsPanelContainer, subDictationContainer; function drawTrendChart() { const canvas = document.getElementById('trend-canvas'); if (!canvas) return; const ctx = canvas.getContext('2d'); const width = canvas.clientWidth, height = canvas.clientHeight; canvas.width = width; canvas.height = height; const days = CONFIG.HISTORY_DAYS; const dates = [], correctData = [], wrongData = []; const today = new Date(); for (let i = days-1; i >= 0; i--) { const d = new Date(today); d.setDate(today.getDate() - i); const dateStr = d.toISOString().slice(0,10); dates.push(dateStr.slice(5)); const dayData = stats.dailyHistory[dateStr] || { correct: 0, wrong: 0 }; correctData.push(dayData.correct); wrongData.push(dayData.wrong); } const maxVal = Math.max(...correctData, ...wrongData, 1); const stepX = width / (days - 1); const stepY = height / maxVal; ctx.clearRect(0, 0, width, height); ctx.strokeStyle = '#ddd'; ctx.lineWidth = 0.5; for (let i = 0; i <= 5; i++) { const y = height - i * (height/5); ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke(); } ctx.beginPath(); ctx.strokeStyle = '#28a745'; ctx.lineWidth = 2; for (let i = 0; i < days; i++) { const x = i * stepX; const y = height - correctData[i] * stepY; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); ctx.beginPath(); ctx.strokeStyle = '#dc3545'; for (let i = 0; i < days; i++) { const x = i * stepX; const y = height - wrongData[i] * stepY; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); for (let i = 0; i < days; i++) { const x = i * stepX; let y = height - correctData[i] * stepY; ctx.fillStyle = '#28a745'; ctx.beginPath(); ctx.arc(x, y, 3, 0, 2*Math.PI); ctx.fill(); y = height - wrongData[i] * stepY; ctx.fillStyle = '#dc3545'; ctx.beginPath(); ctx.arc(x, y, 3, 0, 2*Math.PI); ctx.fill(); } ctx.fillStyle = '#333'; ctx.font = '10px sans-serif'; for (let i = 0; i < days; i++) { const x = i * stepX - 15; ctx.fillText(dates[i], x, height - 5); } } function renderMyPanel() { if (!myPanelContainer) return; const totalAttempts = stats.totalCorrect + stats.totalWrong; const totalRate = totalAttempts ? (stats.totalCorrect / totalAttempts * 100).toFixed(1) : 0; const todayAttempts = stats.todayCorrect + stats.todayWrong; const todayRate = todayAttempts ? (stats.todayCorrect / todayAttempts * 100).toFixed(1) : 0; const progressPercent = CONFIG.DAILY_GOAL > 0 ? Math.min(100, (stats.todayCorrect / CONFIG.DAILY_GOAL * 100)) : 0; // 标签下拉框从 allTags 生成 let tagOptions = ''; allTags.forEach(t => { tagOptions += ``; }); const wrongList = getWrongList(currentFilterTag, currentSearchKeyword); myPanelContainer.innerHTML = `
📅 今日学习计划 (目标: ${CONFIG.DAILY_GOAL} 正确)
${Math.round(progressPercent)}%
今日正确: ${stats.todayCorrect} / ${CONFIG.DAILY_GOAL}
${stats.totalCorrect}
累计正确
${stats.totalWrong}
累计错误
${totalRate}%
总正确率
📚 错题本 (错误率排行)
${wrongList.length === 0 ? '
暂无错题,继续加油!
' : wrongList.map(item => `
${escapeHtml(item.text.length > 40 ? item.text.substring(0,40)+'...' : item.text)}
❌ 错误 ${item.errorCount} 次 | 尝试 ${item.totalAttempts} 次 | 错误率 ${(item.errorRate*100).toFixed(1)}% 🏷️ 标签: ${(item.tags || []).map(t => `${escapeHtml(t)}`).join('') || '无'}
`).join('') }
`; const searchInput = myPanelContainer.querySelector('#search-wrong-input'); const tagSelect = myPanelContainer.querySelector('#tag-filter-select'); searchInput.addEventListener('input', (e) => { currentSearchKeyword = e.target.value; renderMyPanel(); }); tagSelect.addEventListener('change', (e) => { currentFilterTag = e.target.value || null; renderMyPanel(); }); myPanelContainer.querySelectorAll('.wrong-text').forEach(el => { el.addEventListener('click', (e) => { const wrongDiv = el.closest('.wrong-item'); const text = wrongDiv.getAttribute('data-text'); if (text) enterSubDictation(text); }); }); myPanelContainer.querySelectorAll('.add-tag-btn').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const text = btn.getAttribute('data-text'); if (text) showAddTagDialog(text); }); }); const manageBtn = myPanelContainer.querySelector('#manage-tags-btn'); if (manageBtn) manageBtn.addEventListener('click', () => showTagManagerDialog()); const exportBtn = myPanelContainer.querySelector('#export-data-btn'); if (exportBtn) exportBtn.addEventListener('click', () => exportAllData()); const importBtn = myPanelContainer.querySelector('#import-data-btn'); if (importBtn) importBtn.addEventListener('click', () => importDataFromFile()); } // 添加标签对话框(从全局 allTags 中选择或新增) function showAddTagDialog(wrongText) { const dialog = document.createElement('div'); dialog.style.cssText = 'position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.5); display:flex; align-items:center; justify-content:center; z-index:10000002;'; const box = document.createElement('div'); box.style.cssText = 'background:#fff; border-radius:12px; padding:20px; width:300px;'; box.innerHTML = `

添加标签

`; dialog.appendChild(box); document.body.appendChild(dialog); const confirmBtn = box.querySelector('#add-tag-confirm'); const cancelBtn = box.querySelector('#add-tag-cancel'); const select = box.querySelector('#tag-select'); const newInput = box.querySelector('#new-tag-input'); confirmBtn.addEventListener('click', () => { let tag = select.value; if (!tag && newInput.value.trim()) tag = newInput.value.trim(); if (tag) { if (!allTags.includes(tag)) { allTags.push(tag); saveTagData(); } addTagToWrongItem(wrongText, tag); dialog.remove(); } else { alert('请选择或输入标签'); } }); cancelBtn.addEventListener('click', () => dialog.remove()); } // 管理标签对话框(扁平列表,可添加/删除标签) function showTagManagerDialog() { const dialog = document.createElement('div'); dialog.style.cssText = 'position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.5); display:flex; align-items:center; justify-content:center; z-index:10000002;'; const box = document.createElement('div'); box.style.cssText = 'background:#fff; border-radius:12px; padding:20px; width:400px; max-width:90%; max-height:80%; overflow-y:auto;'; box.innerHTML = `

🏷️ 管理标签

`; dialog.appendChild(box); document.body.appendChild(dialog); function refreshTagsList() { const container = box.querySelector('#tags-list'); container.innerHTML = ''; if (allTags.length === 0) { container.innerHTML = '
暂无标签,请添加
'; return; } allTags.forEach(tag => { const tagDiv = document.createElement('div'); tagDiv.style.cssText = 'display:flex; justify-content:space-between; align-items:center; background:#f8f9fa; padding:6px 10px; border-radius:6px; margin-bottom:6px;'; tagDiv.innerHTML = `🏷️ ${escapeHtml(tag)}`; container.appendChild(tagDiv); }); container.querySelectorAll('.delete-tag').forEach(btn => { btn.addEventListener('click', () => { const tag = btn.getAttribute('data-tag'); const index = allTags.indexOf(tag); if (index !== -1) allTags.splice(index, 1); for (let text in stats.wrongItems) { if (stats.wrongItems[text].tags) { stats.wrongItems[text].tags = stats.wrongItems[text].tags.filter(t => t !== tag); } } saveTagData(); saveStats(); refreshTagsList(); if (myPanelContainer && myPanelContainer.style.display !== 'none') renderMyPanel(); }); }); } refreshTagsList(); const addBtn = box.querySelector('#add-tag-btn'); addBtn.addEventListener('click', () => { const newTag = box.querySelector('#new-tag-name').value.trim(); if (!newTag) { alert('请输入标签名称'); return; } if (!allTags.includes(newTag)) { allTags.push(newTag); saveTagData(); refreshTagsList(); box.querySelector('#new-tag-name').value = ''; if (myPanelContainer && myPanelContainer.style.display !== 'none') renderMyPanel(); } else { alert('标签已存在'); } }); box.querySelector('#close-tag-dialog').addEventListener('click', () => dialog.remove()); } function renderStatsPanel() { if (!statsPanelContainer) return; statsPanelContainer.innerHTML = `
● 正确数   ● 错误数
`; const daysSelect = statsPanelContainer.querySelector('#trend-days'); daysSelect.addEventListener('change', (e) => { CONFIG.HISTORY_DAYS = parseInt(e.target.value); saveConfig(); drawTrendChart(); }); drawTrendChart(); } function enterSubDictation(originalText) { isInSubDictation = true; subDictationText = originalText; if (!subDictationContainer) { subDictationContainer = document.createElement('div'); contentContainer.appendChild(subDictationContainer); } renderSubDictationPage(); if (myPanelContainer) myPanelContainer.style.display = 'none'; if (statsPanelContainer) statsPanelContainer.style.display = 'none'; if (settingsPanelContainer) settingsPanelContainer.style.display = 'none'; subDictationContainer.style.display = 'block'; } function exitSubDictation() { isInSubDictation = false; subDictationText = ''; if (subDictationContainer) subDictationContainer.style.display = 'none'; if (currentTab === 'my' && myPanelContainer) { myPanelContainer.style.display = 'block'; renderMyPanel(); } else if (currentTab === 'stats' && statsPanelContainer) statsPanelContainer.style.display = 'block'; else if (currentTab === 'settings' && settingsPanelContainer) settingsPanelContainer.style.display = 'block'; } function renderSubDictationPage() { if (!subDictationContainer) return; subDictationContainer.innerHTML = `
📝 默写错题
原文已隐藏,请凭记忆默写
`; const backBtn = subDictationContainer.querySelector('#sub-back-btn'); const submitBtn = subDictationContainer.querySelector('#sub-submit-btn'); const cancelBtn = subDictationContainer.querySelector('#sub-cancel-btn'); const input = subDictationContainer.querySelector('#sub-dictation-input'); const feedback = subDictationContainer.querySelector('#sub-feedback'); const timerDiv = subDictationContainer.querySelector('#sub-timer'); let timerInterval = null, remainingSeconds = CONFIG.TIMER_SECONDS; if (CONFIG.ENABLE_TIMER && CONFIG.TIMER_SECONDS > 0) { const updateTimer = () => { timerDiv.innerText = `⏱️ 剩余时间: ${remainingSeconds} 秒`; if (remainingSeconds <= 0) { if (timerInterval) clearInterval(timerInterval); submitBtn.click(); } remainingSeconds--; }; updateTimer(); timerInterval = setInterval(updateTimer, 1000); } backBtn.addEventListener('click', () => { if (timerInterval) clearInterval(timerInterval); exitSubDictation(); }); cancelBtn.addEventListener('click', () => { if (timerInterval) clearInterval(timerInterval); exitSubDictation(); }); submitBtn.addEventListener('click', () => { if (timerInterval) clearInterval(timerInterval); const userAnswer = input.value; const correct = isMatch(userAnswer, subDictationText); if (correct) { feedback.innerHTML = '✅ 完全正确!已从错题本移除。'; feedback.style.color = '#28a745'; updateStatistics(true, subDictationText); setTimeout(() => exitSubDictation(), 1500); } else { feedback.innerHTML = '❌ 不正确,请再试试!'; feedback.style.color = '#dc3545'; updateStatistics(false, subDictationText); input.focus(); if (CONFIG.ENABLE_TIMER && CONFIG.TIMER_SECONDS > 0) { if (timerInterval) clearInterval(timerInterval); remainingSeconds = CONFIG.TIMER_SECONDS; timerInterval = setInterval(() => { if (remainingSeconds <= 0) { clearInterval(timerInterval); submitBtn.click(); } else { timerDiv.innerText = `⏱️ 剩余时间: ${remainingSeconds} 秒`; remainingSeconds--; } }, 1000); } } }); input.focus(); } function exportAllData() { const exportObj = { version: '1.0', stats: { totalCorrect: stats.totalCorrect, totalWrong: stats.totalWrong, dailyHistory: stats.dailyHistory, wrongItems: stats.wrongItems }, allTags: allTags, config: { DAILY_GOAL: CONFIG.DAILY_GOAL, ENABLE_TIMER: CONFIG.ENABLE_TIMER, TIMER_SECONDS: CONFIG.TIMER_SECONDS, THEME_PRIMARY: CONFIG.THEME_PRIMARY } }; const dataStr = JSON.stringify(exportObj, null, 2); const blob = new Blob([dataStr], {type: 'text/plain'}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `dictation_backup_${new Date().toISOString().slice(0,19)}.txt`; a.click(); URL.revokeObjectURL(url); showTip('数据已导出', '#28a745', '#fff'); } function importDataFromFile() { const input = document.createElement('input'); input.type = 'file'; input.accept = '.txt'; input.onchange = (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => { try { const imported = JSON.parse(ev.target.result); if (imported.stats) { stats.totalCorrect = imported.stats.totalCorrect || 0; stats.totalWrong = imported.stats.totalWrong || 0; stats.dailyHistory = imported.stats.dailyHistory || {}; stats.wrongItems = imported.stats.wrongItems || {}; saveStats(); } if (imported.allTags && Array.isArray(imported.allTags)) { allTags = imported.allTags; saveTagData(); } else if (imported.tagGroups) { // 兼容旧版本 const tagsSet = new Set(); for (let group of Object.values(imported.tagGroups)) { if (group.tags) group.tags.forEach(t => tagsSet.add(t)); } allTags = Array.from(tagsSet); saveTagData(); } if (imported.config) { CONFIG.DAILY_GOAL = imported.config.DAILY_GOAL; CONFIG.ENABLE_TIMER = imported.config.ENABLE_TIMER; CONFIG.TIMER_SECONDS = imported.config.TIMER_SECONDS; CONFIG.THEME_PRIMARY = imported.config.THEME_PRIMARY; saveConfig(); applyTheme(); } showTip('数据导入成功', '#28a745', '#fff'); if (mainPanel && mainPanel.style.display === 'block') { renderMyPanel(); renderStatsPanel(); } } catch(err) { alert('文件解析失败'); } }; reader.readAsText(file); }; input.click(); } function createSettingsContent() { const container = document.createElement('div'); container.style.padding = '12px'; container.innerHTML = `
⌨️ 快捷键设置
当前快捷键:${CONFIG.CUSTOM_HOTKEY}
秒数:
🎨 遮罩模糊强度: ${CONFIG.BLUR_AMOUNT}
🎨 遮罩背景色: 透明度
`; function rgbToHex(rgba) { const match = rgba.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); if (match) return '#' + ((1 << 24) + (parseInt(match[1]) << 16) + (parseInt(match[2]) << 8) + parseInt(match[3])).toString(16).slice(1); return '#000000'; } function getAlphaFromRgba(rgba) { const match = rgba.match(/[\d\.]+\)$/); if (match) { let alpha = parseFloat(match[0].slice(0,-1)); return isNaN(alpha)?0.7:alpha; } return 0.7; } function updateConfigAndUI() { CONFIG.IGNORE_CASE = container.querySelector('#cfg-ignore-case').checked; CONFIG.IGNORE_PUNCTUATION = container.querySelector('#cfg-ignore-punct').checked; CONFIG.TRIM_WHITESPACE = container.querySelector('#cfg-trim-space').checked; CONFIG.AUTO_CLOSE_ON_CORRECT = container.querySelector('#cfg-auto-close').checked; CONFIG.DAILY_GOAL = parseInt(container.querySelector('#cfg-daily-goal').value) || 0; CONFIG.ENABLE_TIMER = container.querySelector('#cfg-enable-timer').checked; CONFIG.TIMER_SECONDS = parseInt(container.querySelector('#cfg-timer-seconds').value) || 30; CONFIG.THEME_PRIMARY = container.querySelector('#cfg-theme-primary').value; const blurVal = container.querySelector('#cfg-blur').value; CONFIG.BLUR_AMOUNT = blurVal + 'px'; container.querySelector('#blur-value').innerText = CONFIG.BLUR_AMOUNT; const color = container.querySelector('#cfg-color').value; const alpha = container.querySelector('#cfg-alpha').value; CONFIG.OVERLAY_COLOR = `rgba(${parseInt(color.slice(1,3),16)}, ${parseInt(color.slice(3,5),16)}, ${parseInt(color.slice(5,7),16)}, ${alpha})`; saveConfig(); applyTheme(); if (myPanelContainer && myPanelContainer.style.display !== 'none') renderMyPanel(); } container.querySelector('#cfg-ignore-case').addEventListener('change', updateConfigAndUI); container.querySelector('#cfg-ignore-punct').addEventListener('change', updateConfigAndUI); container.querySelector('#cfg-trim-space').addEventListener('change', updateConfigAndUI); container.querySelector('#cfg-auto-close').addEventListener('change', updateConfigAndUI); container.querySelector('#cfg-daily-goal').addEventListener('change', updateConfigAndUI); container.querySelector('#cfg-enable-timer').addEventListener('change', updateConfigAndUI); container.querySelector('#cfg-timer-seconds').addEventListener('change', updateConfigAndUI); container.querySelector('#cfg-theme-primary').addEventListener('input', updateConfigAndUI); container.querySelector('#cfg-blur').addEventListener('input', updateConfigAndUI); container.querySelector('#cfg-color').addEventListener('input', updateConfigAndUI); container.querySelector('#cfg-alpha').addEventListener('input', updateConfigAndUI); // 快捷键录制 let isRecording = false, timeoutId; const recordBtn = container.querySelector('#record-hotkey-btn'); const resetBtn = container.querySelector('#reset-hotkey-btn'); const hotkeyDisplay = container.querySelector('#current-hotkey-display'); const recordingStatus = container.querySelector('#recording-status'); function startRecording() { if (isRecording) return; isRecording = true; recordingStatus.textContent = '🎤 正在录制,请按下组合键... (Esc取消)'; recordingStatus.style.color = '#dc3545'; recordBtn.disabled = true; if (timeoutId) clearTimeout(timeoutId); timeoutId = setTimeout(() => { if (isRecording) cancelRecording(); showTip('录制超时','#dc3545','#fff'); }, 10000); const handler = (e) => { if (!isRecording) return; e.preventDefault(); e.stopPropagation(); if (e.key === 'Escape') { cancelRecording(); recordingStatus.textContent = '已取消'; setTimeout(()=>recordingStatus.textContent='',1000); return; } const parts = []; if (e.ctrlKey) parts.push('Ctrl'); if (e.altKey) parts.push('Alt'); if (e.shiftKey) parts.push('Shift'); if (e.metaKey) parts.push('Meta'); let key = e.key; if (key === ' ') key = 'Space'; if (parts.length === 0 && (key === 'Control' || key === 'Alt' || key === 'Shift' || key === 'Meta')) return; if (key === 'Control' || key === 'Alt' || key === 'Shift' || key === 'Meta') return; parts.push(key); const hotkeyStr = parts.join('+'); CONFIG.CUSTOM_HOTKEY = hotkeyStr; saveConfig(); hotkeyDisplay.textContent = hotkeyStr; hotkeyMatcher = parseHotkey(hotkeyStr); cancelRecording(); recordingStatus.textContent = `✅ 快捷键已设置为 ${hotkeyStr}`; recordingStatus.style.color = '#28a745'; setTimeout(()=>recordingStatus.textContent='',2000); }; document.addEventListener('keydown', handler, { capture: true }); window.__tempHotkeyHandler = handler; } function cancelRecording() { if (!isRecording) return; isRecording = false; if (timeoutId) clearTimeout(timeoutId); if (window.__tempHotkeyHandler) { document.removeEventListener('keydown', window.__tempHotkeyHandler, { capture: true }); delete window.__tempHotkeyHandler; } recordBtn.disabled = false; recordingStatus.textContent = ''; } recordBtn.addEventListener('click', startRecording); resetBtn.addEventListener('click', () => { CONFIG.CUSTOM_HOTKEY = DEFAULTS.CUSTOM_HOTKEY; saveConfig(); hotkeyDisplay.textContent = CONFIG.CUSTOM_HOTKEY; hotkeyMatcher = parseHotkey(CONFIG.CUSTOM_HOTKEY); showTip('快捷键已重置为 Ctrl+Shift', '#28a745', '#fff'); }); container.querySelector('#reset-defaults').addEventListener('click', () => { CONFIG = { ...DEFAULTS }; saveConfig(); container.querySelector('#cfg-ignore-case').checked = CONFIG.IGNORE_CASE; container.querySelector('#cfg-ignore-punct').checked = CONFIG.IGNORE_PUNCTUATION; container.querySelector('#cfg-trim-space').checked = CONFIG.TRIM_WHITESPACE; container.querySelector('#cfg-auto-close').checked = CONFIG.AUTO_CLOSE_ON_CORRECT; container.querySelector('#cfg-daily-goal').value = CONFIG.DAILY_GOAL; container.querySelector('#cfg-enable-timer').checked = CONFIG.ENABLE_TIMER; container.querySelector('#cfg-timer-seconds').value = CONFIG.TIMER_SECONDS; container.querySelector('#cfg-theme-primary').value = CONFIG.THEME_PRIMARY; container.querySelector('#cfg-blur').value = parseInt(CONFIG.BLUR_AMOUNT); container.querySelector('#blur-value').innerText = CONFIG.BLUR_AMOUNT; const defaultRgb = DEFAULTS.OVERLAY_COLOR.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); if (defaultRgb) { container.querySelector('#cfg-color').value = '#' + ((1 << 24) + (parseInt(defaultRgb[1]) << 16) + (parseInt(defaultRgb[2]) << 8) + parseInt(defaultRgb[3])).toString(16).slice(1); container.querySelector('#cfg-alpha').value = parseFloat(DEFAULTS.OVERLAY_COLOR.match(/[\d\.]+\)$/)[0].slice(0,-1)); } updateConfigAndUI(); hotkeyDisplay.textContent = CONFIG.CUSTOM_HOTKEY; hotkeyMatcher = parseHotkey(CONFIG.CUSTOM_HOTKEY); }); return container; } function parseHotkey(hotkeyStr) { const parts = hotkeyStr.split('+'); const required = { ctrl: false, shift: false, alt: false, meta: false, key: null }; for (let part of parts) { const p = part.toLowerCase(); if (p === 'ctrl') required.ctrl = true; else if (p === 'shift') required.shift = true; else if (p === 'alt') required.alt = true; else if (p === 'meta') required.meta = true; else required.key = part; } return (e) => { if (e.ctrlKey !== required.ctrl) return false; if (e.shiftKey !== required.shift) return false; if (e.altKey !== required.alt) return false; if (e.metaKey !== required.meta) return false; if (required.key) { const pressedKey = e.key.length === 1 ? e.key : e.key.toLowerCase(); const targetKey = required.key.length === 1 ? required.key : required.key.toLowerCase(); if (pressedKey !== targetKey) return false; } return true; }; } function createMainPanel() { const panel = document.createElement('div'); panel.style.cssText = 'position:fixed; width:420px; background:#fff; border-radius:12px; box-shadow:0 4px 20px rgba(0,0,0,0.3); z-index:10000001; display:none; flex-direction:column; overflow:hidden; font-family:system-ui,sans-serif;'; const header = document.createElement('div'); header.style.cssText = 'display:flex; border-bottom:1px solid #ddd; background:#f8f9fa;'; const myTab = createTab('📊 我的'); const statsTab = createTab('📈 统计'); const settingsTab = createTab('⚙️ 设置'); header.appendChild(myTab); header.appendChild(statsTab); header.appendChild(settingsTab); panel.appendChild(header); contentContainer = document.createElement('div'); contentContainer.style.minHeight = '350px'; panel.appendChild(contentContainer); myPanelContainer = document.createElement('div'); statsPanelContainer = document.createElement('div'); settingsPanelContainer = document.createElement('div'); subDictationContainer = document.createElement('div'); contentContainer.appendChild(myPanelContainer); contentContainer.appendChild(statsPanelContainer); contentContainer.appendChild(settingsPanelContainer); contentContainer.appendChild(subDictationContainer); statsPanelContainer.style.display = 'none'; settingsPanelContainer.style.display = 'none'; subDictationContainer.style.display = 'none'; const closeBtn = document.createElement('div'); closeBtn.textContent = '✖'; closeBtn.style.cssText = 'position:absolute; top:8px; right:12px; cursor:pointer; font-size:18px; color:#888; z-index:2;'; closeBtn.addEventListener('click', () => panel.style.display = 'none'); panel.appendChild(closeBtn); function createTab(text) { const tab = document.createElement('div'); tab.textContent = text; tab.style.cssText = 'flex:1; text-align:center; padding:10px; cursor:pointer; font-weight:bold;'; return tab; } function switchTab(tab) { currentTab = tab; myPanelContainer.style.display = 'none'; statsPanelContainer.style.display = 'none'; settingsPanelContainer.style.display = 'none'; subDictationContainer.style.display = 'none'; if (tab === 'my') { myPanelContainer.style.display = 'block'; if (!isInSubDictation) renderMyPanel(); else subDictationContainer.style.display = 'block'; myTab.style.backgroundColor = '#e9ecef'; statsTab.style.backgroundColor = 'transparent'; settingsTab.style.backgroundColor = 'transparent'; } else if (tab === 'stats') { statsPanelContainer.style.display = 'block'; renderStatsPanel(); statsTab.style.backgroundColor = '#e9ecef'; myTab.style.backgroundColor = 'transparent'; settingsTab.style.backgroundColor = 'transparent'; } else { settingsPanelContainer.style.display = 'block'; if (!settingsPanelContainer.children.length) settingsPanelContainer.appendChild(createSettingsContent()); settingsTab.style.backgroundColor = '#e9ecef'; myTab.style.backgroundColor = 'transparent'; statsTab.style.backgroundColor = 'transparent'; } } myTab.addEventListener('click', () => switchTab('my')); statsTab.addEventListener('click', () => switchTab('stats')); settingsTab.addEventListener('click', () => switchTab('settings')); switchTab('my'); return panel; } function repositionMainPanel() { if (!mainPanel || !mainButton) return; const btnRect = mainButton.getBoundingClientRect(); const panelRect = mainPanel.getBoundingClientRect(); let left = btnRect.right + 10; let top = btnRect.top; if (left + panelRect.width > window.innerWidth) { left = btnRect.left - panelRect.width - 10; if (left < 10) left = 10; } if (top + panelRect.height > window.innerHeight) top = window.innerHeight - panelRect.height - 10; if (top < 10) top = 10; mainPanel.style.left = left + 'px'; mainPanel.style.top = top + 'px'; mainPanel.style.right = 'auto'; mainPanel.style.bottom = 'auto'; } function addMainButton() { if (mainButton) return; mainButton = document.createElement('div'); mainButton.innerText = '🏠'; mainButton.style.cssText = `position:fixed; bottom:20px; right:20px; width:44px; height:44px; background:${CONFIG.THEME_PRIMARY}; color:white; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:24px; cursor:grab; z-index:9999998; box-shadow:0 2px 10px rgba(0,0,0,0.2); opacity:0.8; user-select:none;`; mainButton.addEventListener('mouseenter', () => mainButton.style.opacity = '1'); mainButton.addEventListener('mouseleave', () => mainButton.style.opacity = '0.8'); let dragActive = false, startX, startY, startLeft, startTop; mainButton.addEventListener('mousedown', (e) => { if (e.button !== 0) return; e.preventDefault(); dragActive = true; startX = e.clientX; startY = e.clientY; const rect = mainButton.getBoundingClientRect(); startLeft = rect.left; startTop = rect.top; mainButton.style.cursor = 'grabbing'; }); document.addEventListener('mousemove', (e) => { if (!dragActive) return; let dx = e.clientX - startX, dy = e.clientY - startY; let newLeft = startLeft + dx, newTop = startTop + dy; newLeft = Math.min(Math.max(newLeft, 0), window.innerWidth - mainButton.offsetWidth); newTop = Math.min(Math.max(newTop, 0), window.innerHeight - mainButton.offsetHeight); mainButton.style.left = newLeft + 'px'; mainButton.style.top = newTop + 'px'; mainButton.style.right = 'auto'; mainButton.style.bottom = 'auto'; if (mainPanel && mainPanel.style.display === 'block') repositionMainPanel(); }); document.addEventListener('mouseup', () => { dragActive = false; mainButton.style.cursor = 'grab'; }); mainButton.addEventListener('click', (e) => { if (dragActive) return; if (!mainPanel) { mainPanel = createMainPanel(); document.body.appendChild(mainPanel); } if (mainPanel.style.display === 'block') mainPanel.style.display = 'none'; else { mainPanel.style.display = 'block'; repositionMainPanel(); if (currentTab === 'my') renderMyPanel(); if (currentTab === 'stats') renderStatsPanel(); } }); document.body.appendChild(mainButton); } function applyTheme() { if (mainButton) mainButton.style.backgroundColor = CONFIG.THEME_PRIMARY; } function escapeHtml(str) { return str.replace(/[&<>]/g, m => ({ '&':'&', '<':'<', '>':'>' }[m])); } function init() { addMainButton(); document.addEventListener('keydown', onKeyDown); showTip('✅ 默写助手已启动 | 选中文字后按 ' + CONFIG.CUSTOM_HOTKEY + ' 开始默写', '#28a745', '#fff'); applyTheme(); } init(); })();