// ==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}
累计正确
📚 错题本 (错误率排行)
${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();
})();