This commit is contained in:
eddy
2026-07-19 21:09:58 +08:00
commit 5558482479
63 changed files with 41626 additions and 0 deletions
+103
View File
@@ -0,0 +1,103 @@
import { state } from '../core/state.js';
import { extractEmlBody } from '../services/mime.js';
import { escapeHtml } from '../ui/dom.js';
import { showToast } from '../ui/toast.js';
export let batchUploadedTexts = [];
let batchFileReadGeneration = 0;
function extractHtmlText(html) {
const documentFragment = new DOMParser().parseFromString(html, 'text/html');
documentFragment.querySelectorAll('script, style, template, noscript').forEach(element => element.remove());
return documentFragment.body?.textContent || '';
}
export function handleBatchFiles(input) {
const generation = ++batchFileReadGeneration;
const files = Array.from(input.files);
if (!files.length) {
batchUploadedTexts = [];
renderBatchFileList();
return;
}
const listElement = document.getElementById('batch-file-list');
if (listElement) {
listElement.innerHTML = '<span style="font-size:12px;color:var(--text-muted)"><i class="fas fa-spinner fa-spin"></i> 读取文件中...</span>';
}
batchUploadedTexts = [];
const loadedTexts = new Array(files.length);
let loaded = 0;
function finishFile() {
if (generation !== batchFileReadGeneration) return;
loaded++;
if (loaded === files.length) {
batchUploadedTexts = loadedTexts.filter(Boolean);
renderBatchFileList();
}
}
files.forEach((file, index) => {
const reader = new FileReader();
const name = file.name.toLowerCase();
reader.onload = event => {
if (generation !== batchFileReadGeneration) return;
let text = event.target.result;
if (name.endsWith('.eml')) {
text = extractEmlBody(text);
if (!text.trim()) showToast(`邮件「${file.name}」未解析到可读正文`, 'warning');
} else if (name.endsWith('.html') || name.endsWith('.htm')) {
text = extractHtmlText(text);
}
loadedTexts[index] = { name: file.name, content: text };
finishFile();
};
reader.onerror = () => {
if (generation !== batchFileReadGeneration) return;
showToast(`文件「${file.name}」读取失败,已跳过`, 'warning');
finishFile();
};
if (name.endsWith('.eml')) reader.readAsArrayBuffer(file);
else reader.readAsText(file);
});
}
export function renderBatchFileList() {
const listElement = document.getElementById('batch-file-list');
if (!listElement) return;
if (batchUploadedTexts.length === 0) {
listElement.innerHTML = '';
return;
}
listElement.innerHTML = `
<div style="padding:10px 12px;background:var(--bg);border-radius:var(--radius-sm);border:1px solid var(--border)">
<div style="font-size:12px;color:var(--text-muted);margin-bottom:6px">已加载 ${batchUploadedTexts.length} 个文件:</div>
${batchUploadedTexts.map((item, index) => `<div style="display:flex;align-items:center;gap:8px;font-size:13px;padding:3px 0">
<i class="fas fa-file-alt" style="color:var(--primary)"></i>
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml(item.name)}</span>
<span style="font-size:11px;color:var(--text-muted)">${(item.content.length / 1024).toFixed(1)}KB</span>
<button class="btn btn-sm btn-ghost" data-action="extract.removeBatchFile" data-arg0="${index}" title="移除" style="padding:2px 6px;color:var(--error)"><i class="fas fa-times"></i></button>
</div>`).join('')}
</div>`;
updateBatchSourceInfo();
}
export function removeBatchFile(index) {
batchUploadedTexts.splice(index, 1);
renderBatchFileList();
}
export function updateBatchSourceInfo() {
const infoElement = document.getElementById('batch-source-info');
if (!infoElement) return;
const uploadCount = batchUploadedTexts.length;
const savedCount = state.mailEmails.length;
const parts = [];
if (uploadCount > 0) parts.push(`${uploadCount} 个上传文件`);
if (savedCount > 0) parts.push(`${savedCount} 封已保存邮件`);
infoElement.textContent = parts.length > 0 ? parts.join(' + ') : '暂无来源';
}
+408
View File
@@ -0,0 +1,408 @@
import { WORD_TRANSLATE_BATCH_SIZE } from '../constants.js';
import { COMMON_NAMES } from '../data/stopwords.js';
import { state } from '../core/state.js';
import { saveEmails, saveJsonSetting, saveSchedule, saveWords, STORAGE_KEYS } from '../core/storage.js';
import { renderPage } from '../core/router.js';
import { showToast } from '../ui/toast.js';
import { deduplicateBasic, deduplicateWithAI, filterNamesWithAI, translateWords } from '../services/ai.js';
import { getToday, initWordSchedule } from '../services/ebbinghaus.js';
import { autoResizeTextarea, escapeHtml } from '../ui/dom.js';
import { detectProperNouns, extractEnglishWords, extractWithFrequency, findWordContext } from '../services/extractor.js';
import { getNextId } from './words.js';
import { batchUploadedTexts } from './extract-batch-files.js';
export { batchUploadedTexts, handleBatchFiles, removeBatchFile, renderBatchFileList, updateBatchSourceInfo } from './extract-batch-files.js';
export let batchMode = false;
export let batchResults = [];
let extractGeneration = 0;
function beginExtractGeneration() {
return ++extractGeneration;
}
function isCurrentExtractGeneration(generation, ...elements) {
return generation === extractGeneration
&& state.currentPage === 'extract'
&& elements.every(element => element?.isConnected);
}
// ==================== Page: Extract (邮件提词) ====================
export function renderExtract(el) {
beginExtractGeneration();
const batchSection = state.batchExtractEnabled ? `
<div class="card" style="margin-top:20px">
<h3><i class="fas fa-layer-group"></i> 批量提词</h3>
<p style="font-size:13px;color:var(--text-secondary);margin-bottom:12px">上传邮件文件或使用已保存邮件,批量提取单词并统计出现频次。</p>
<div style="margin-bottom:12px">
<label style="font-size:13px;font-weight:600;color:var(--text);display:block;margin-bottom:6px"><i class="fas fa-file-upload"></i> 上传邮件文件</label>
<input type="file" id="batch-file-input" accept=".md,.txt,.eml,.html,.htm" multiple data-action="extract.handleBatchFiles" data-action-event="change" data-element-arg="0" style="font-size:13px">
<p class="hint" style="margin-top:4px">支持 .txt / .md / .eml / .html / .htm 格式,可多选</p>
</div>
<div id="batch-file-list" style="margin-bottom:12px"></div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<button class="btn btn-primary" id="batch-extract-btn" data-action="extract.doBatchExtract"><i class="fas fa-search-plus"></i> 批量提取</button>
<span style="font-size:12px;color:var(--text-muted)" id="batch-source-info">已保存 ${state.mailEmails.length} 封邮件</span>
<span id="batch-result"></span>
</div>
</div>` : '';
el.innerHTML = `
<div class="page-header">
<div>
<h1>邮件提词</h1>
<p class="page-desc">粘贴英文邮件,智能提取有学习价值的单词</p>
</div>
</div>
<div class="extract-layout">
<div>
<div class="card">
<h3><i class="fas fa-envelope"></i> 粘贴邮件内容</h3>
<textarea id="email-input" placeholder="将英文邮件内容粘贴到此处...&#10;&#10;系统会自动提取英文单词(过滤常见停用词),&#10;您可以选择需要的单词加入学习列表。" style="min-height:200px"></textarea>
<div style="display:flex;gap:8px;margin-top:16px;flex-wrap:wrap">
<button class="btn btn-primary" data-action="extract.doExtractFromEmail"><i class="fas fa-search"></i> 提取单词</button>
<button class="btn btn-secondary" data-action="extract.clearEmailExtract"><i class="fas fa-trash-alt"></i> 清空</button>
<button class="btn btn-secondary" data-action="extract.saveEmailForLearn"><i class="fas fa-save"></i> 保存邮件到学习</button>
</div>
<p style="font-size:12px;color:var(--text-muted);margin-top:8px">自动过滤停用词和人名/专有名词${state.settings.apiKey ? '(已启用 AI 识别)' : '(配置 AI 可增强识别)'},只保留有学习价值的单词</p>
</div>
${batchSection}
</div>
<div>
<div class="card">
<h3><i class="fas fa-tags"></i> 选择要添加的单词</h3>
<div id="word-chips" class="word-chips"></div>
<div id="extract-info" style="font-size:13px;color:var(--text-muted);margin-top:12px"></div>
<div id="extract-actions" class="extract-actions" style="display:none">
<button class="btn btn-sm btn-secondary" data-action="extract.selectAllChips">全选</button>
<button class="btn btn-sm btn-secondary" data-action="extract.deselectAllChips">取消全选</button>
<button class="btn btn-sm btn-primary" id="add-selected-btn" data-action="extract.addSelectedWords"><i class="fas fa-plus"></i> 翻译并添加选中 (0)</button>
</div>
<div id="extract-progress" style="margin-top:12px;font-size:13px;color:var(--text-muted);display:none"></div>
<div style="margin-top:16px;padding:16px;background:var(--bg);border-radius:var(--radius-sm);font-size:13px;color:var(--text-secondary);line-height:1.6">
<strong>使用说明:</strong><br>
1. 在左侧粘贴邮件原文<br>
2. 点击「提取单词」<br>
3. 点击单词选中/取消(灰色划线表示已在词库中)<br>
4. 点击「翻译并添加选中」批量加入词库
</div>
</div>
</div>
</div>
`;
const ta = document.getElementById('email-input');
ta.addEventListener('input', () => autoResizeTextarea(ta));
ta.addEventListener('paste', () => setTimeout(() => autoResizeTextarea(ta), 0));
}
export async function doExtractFromEmail() {
const generation = beginExtractGeneration();
const inputEl = document.getElementById('email-input');
const chipsContainer = document.getElementById('word-chips');
const extractBtn = document.querySelector('[data-action="extract.doExtractFromEmail"]');
const text = inputEl?.value.trim() || '';
if (!text) { showToast('请粘贴邮件内容', 'error'); return; }
batchMode = false;
if (extractBtn) { extractBtn.disabled = true; extractBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 提取中...'; }
const extracted = extractEnglishWords(text);
if (!extracted.length) {
if (extractBtn?.isConnected) { extractBtn.disabled = false; extractBtn.innerHTML = '<i class="fas fa-search"></i> 提取单词'; }
showToast('未提取到有效英文单词', 'info');
return;
}
const properNouns = detectProperNouns(text);
let filtered = extracted.filter(w => !COMMON_NAMES.has(w) && !properNouns.has(w));
let nameCount = extracted.length - filtered.length;
const hasAI = state.settings.apiKey && state.settings.apiUrl && state.settings.model;
if (hasAI && filtered.length > 0) {
showToast('正在使用 AI 识别人名...', 'info');
const aiNames = await filterNamesWithAI(filtered);
if (!isCurrentExtractGeneration(generation, inputEl, chipsContainer)) return;
if (aiNames.length > 0) {
const aiNameSet = new Set(aiNames);
filtered = filtered.filter(w => !aiNameSet.has(w));
nameCount += aiNames.length;
}
}
let dedupCount = 0;
if (filtered.length > 0) {
if (hasAI) showToast('正在使用 AI 去重词形变化...', 'info');
const dedupMapping = hasAI ? await deduplicateWithAI(filtered) : deduplicateBasic(filtered);
if (!isCurrentExtractGeneration(generation, inputEl, chipsContainer)) return;
dedupCount = Object.keys(dedupMapping).length;
if (dedupCount > 0) filtered = filtered.filter(w => !dedupMapping[w]);
}
if (!isCurrentExtractGeneration(generation, inputEl, chipsContainer)) return;
if (extractBtn?.isConnected) { extractBtn.disabled = false; extractBtn.innerHTML = '<i class="fas fa-search"></i> 提取单词'; }
renderWordChips(filtered);
const nameMsg = nameCount > 0 ? `,过滤了 ${nameCount} 个人名/专有名词` : '';
const dedupMsg = dedupCount > 0 ? `,合并了 ${dedupCount} 个词形变化` : '';
showToast(`提取到 ${filtered.length} 个单词${nameMsg}${dedupMsg}`, 'success');
}
export function renderWordChips(wordList) {
const container = document.getElementById('word-chips');
const existingWords = new Set(state.words.map(w => w.english.toLowerCase()));
container.innerHTML = wordList.map(w => {
const isExist = existingWords.has(w);
return `<span class="word-chip ${isExist ? 'exists' : ''}" data-word="${escapeHtml(w)}"
${isExist ? '' : 'data-action="extract.toggleChip" data-pass-element="true"'}
title="${isExist ? '已在单词列表中' : '点击选中'}">${escapeHtml(w)}${isExist ? ' \u2713' : ''}</span>`;
}).join('');
const selectable = wordList.filter(w => !existingWords.has(w)).length;
const existing = wordList.length - selectable;
document.getElementById('extract-info').textContent = `${wordList.length} 个单词,${existing} 个已存在,${selectable} 个可添加`;
document.getElementById('extract-actions').style.display = selectable > 0 ? 'flex' : 'none';
}
export function toggleChip(el) { el.classList.toggle('selected'); updateSelectedCount(); }
export function selectAllChips() { document.querySelectorAll('.word-chip:not(.exists)').forEach(c => c.classList.add('selected')); updateSelectedCount(); }
export function deselectAllChips() { document.querySelectorAll('.word-chip.selected').forEach(c => c.classList.remove('selected')); updateSelectedCount(); }
export function updateSelectedCount() {
const count = document.querySelectorAll('.word-chip.selected').length;
const btn = document.getElementById('add-selected-btn');
if (btn) btn.innerHTML = `<i class="fas fa-plus"></i> 翻译并添加选中 (${count})`;
}
export async function addSelectedWords() {
const generation = extractGeneration;
const libraryId = state.activeWordLibrary;
const chipsContainer = document.getElementById('word-chips');
const chips = [...document.querySelectorAll('.word-chip.selected')];
const selected = chips.map(c => ({ word: c.dataset.word, freq: parseInt(c.dataset.freq) || 0 }));
if (!selected.length) { showToast('请先选择要添加的单词', 'error'); return; }
const isBatch = !!batchMode;
let emailText, emailTitle;
if (isBatch) {
const uploadedTexts = (batchUploadedTexts || []).map(t => t.content);
const savedTexts = state.mailEmails.map(e => e.content);
emailText = [...uploadedTexts, ...savedTexts].join('\n\n');
emailTitle = '批量提取';
} else {
emailText = document.getElementById('email-input').value.trim();
emailTitle = emailText.slice(0, 40).replace(/\n/g, ' ') + '...';
}
const progress = document.getElementById('extract-progress');
progress.style.display = 'block';
document.getElementById('add-selected-btn').disabled = true;
let success = 0, fail = 0, skipped = 0;
const addedWordIds = [];
const isCurrentOperation = () => state.activeWordLibrary === libraryId
&& isCurrentExtractGeneration(generation, chipsContainer, progress);
const discardAddedWords = () => {
if (state.activeWordLibrary !== libraryId || addedWordIds.length === 0) return;
const addedIdSet = new Set(addedWordIds);
state.words = state.words.filter(word => !addedIdSet.has(word.id));
state.schedule = { ...state.schedule };
addedWordIds.forEach(id => delete state.schedule[id]);
saveWords();
saveSchedule();
};
const existing = new Set(state.words.map(w => String(w.english || '').trim().toLowerCase()).filter(Boolean));
const handled = new Set();
let nextId = getNextId(); // 循环外取一次基准 id,循环内自增,避免逐词全量重扫
for (let i = 0; i < selected.length; i += WORD_TRANSLATE_BATCH_SIZE) {
const batch = selected.slice(i, i + WORD_TRANSLATE_BATCH_SIZE);
const end = Math.min(i + batch.length, selected.length);
progress.textContent = `翻译中 ${i + 1}-${end}/${selected.length},一次请求 ${batch.length} 个单词...`;
try {
const results = await translateWords(batch.map(item => item.word));
if (!isCurrentOperation()) { discardAddedWords(); return; }
// 翻译等待期间用户可能在别处手动加词,刷新基准防止 id 冲突
nextId = Math.max(nextId, getNextId());
const resultMap = new Map(results.map(item => [String(item.english || '').trim().toLowerCase(), item]));
batch.forEach(item => {
const key = item.word.trim().toLowerCase();
if (!key || handled.has(key)) return;
handled.add(key);
if (existing.has(key)) {
skipped++;
return;
}
const base = resultMap.get(key);
if (!base || !String(base.chinese || '').trim()) {
// 缺少中文释义的脏数据不入库,否则本地出题会因缺字段崩溃
fail++;
return;
}
const contextSentence = findWordContext(item.word, emailText);
const frequency = item.freq > 0 ? Math.max(base.frequency || 0, item.freq) : base.frequency;
state.words.push({
id: nextId++,
...base,
frequency,
sourceContext: contextSentence ? { emailTitle, sentence: contextSentence } : null
});
const addedWordId = state.words[state.words.length - 1].id;
initWordSchedule(addedWordId, false);
addedWordIds.push(addedWordId);
existing.add(key);
success++;
});
} catch (err) {
if (!isCurrentOperation()) { discardAddedWords(); return; }
batch.forEach(item => {
const key = item.word.trim().toLowerCase();
if (!key || handled.has(key)) return;
handled.add(key);
if (existing.has(key)) { skipped++; return; }
fail++;
});
}
}
if (success > 0 && !(saveWords() && saveSchedule())) {
discardAddedWords();
fail += success;
success = 0;
}
document.getElementById('add-selected-btn').disabled = false;
const skippedText = skipped > 0 ? `,跳过 ${skipped} 个(已在词库)` : '';
progress.textContent = `完成!成功 ${success} 个,失败 ${fail}${skippedText}`;
showToast(`批量添加完成:${success} 成功,${fail} 失败${skippedText}`, success > 0 ? 'success' : 'error');
if (success > 0) {
if (isBatch) {
doBatchExtract();
} else {
const proper = detectProperNouns(emailText);
const refreshed = extractEnglishWords(emailText).filter(w => !COMMON_NAMES.has(w) && !proper.has(w));
renderWordChips(refreshed);
}
}
}
export function clearEmailExtract() {
beginExtractGeneration();
batchMode = false;
batchResults = [];
const ta = document.getElementById('email-input');
ta.value = '';
ta.style.height = '200px';
document.getElementById('word-chips').innerHTML = '';
document.getElementById('extract-info').textContent = '';
document.getElementById('extract-actions').style.display = 'none';
document.getElementById('extract-progress').style.display = 'none';
const statusEl = document.getElementById('batch-result');
if (statusEl) statusEl.innerHTML = '';
}
export function saveEmailForLearn() {
const text = document.getElementById('email-input').value.trim();
if (!text) { showToast('请先粘贴邮件内容', 'error'); return; }
const email = { id: Date.now(), content: text, date: getToday(), title: text.slice(0, 50) + '...' };
state.mailEmails.push(email);
if (!saveEmails()) {
const index = state.mailEmails.indexOf(email);
if (index >= 0) state.mailEmails.splice(index, 1);
return;
}
showToast('邮件已保存,可在「邮件学习」中查看', 'success');
}
export async function doBatchExtract() {
const generation = beginExtractGeneration();
const uploadedTexts = (batchUploadedTexts || []).map(t => t.content);
const savedTexts = state.mailEmails.map(e => e.content);
const allEmails = [...uploadedTexts, ...savedTexts];
if (allEmails.length === 0) { showToast('请上传文件或先保存邮件', 'warning'); return; }
const batchBtn = document.getElementById('batch-extract-btn');
const chipsContainer = document.getElementById('word-chips');
if (batchBtn) { batchBtn.disabled = true; batchBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 提取中...'; }
let results = extractWithFrequency(allEmails);
if (results.length === 0) {
if (batchBtn) { batchBtn.disabled = false; batchBtn.innerHTML = '<i class="fas fa-search-plus"></i> 批量提取'; }
showToast('未提取到有效单词', 'info');
return;
}
const words = results.map(r => r.word);
const hasAI = state.settings.apiKey && state.settings.apiUrl && state.settings.model;
let dedupCount = 0;
if (words.length > 0) {
if (hasAI) showToast('正在使用 AI 去重词形变化...', 'info');
const dedupMapping = hasAI ? await deduplicateWithAI(words) : deduplicateBasic(words);
if (!isCurrentExtractGeneration(generation, chipsContainer)) return;
dedupCount = Object.keys(dedupMapping).length;
if (dedupCount > 0) {
const resultMap = {};
for (const r of results) resultMap[r.word] = r;
for (const [variant, base] of Object.entries(dedupMapping)) {
if (resultMap[variant] && resultMap[base]) {
resultMap[base].emailCount = Math.max(resultMap[base].emailCount, resultMap[variant].emailCount);
resultMap[base].totalCount += resultMap[variant].totalCount;
}
}
results = results.filter(r => !dedupMapping[r.word]);
}
}
if (!isCurrentExtractGeneration(generation, chipsContainer)) return;
if (batchBtn?.isConnected) { batchBtn.disabled = false; batchBtn.innerHTML = '<i class="fas fa-search-plus"></i> 批量提取'; }
batchResults = results;
batchMode = true;
const existingWords = new Set(state.words.map(w => w.english.toLowerCase()));
const infoEl = document.getElementById('extract-info');
const actionsEl = document.getElementById('extract-actions');
if (!chipsContainer) return;
chipsContainer.innerHTML = results.map(r => {
const isExist = existingWords.has(r.word);
return `<span class="word-chip ${isExist ? 'exists' : ''}" data-word="${escapeHtml(r.word)}" data-freq="${r.emailCount}"
${isExist ? '' : 'data-action="extract.toggleChip" data-pass-element="true"'}
title="${isExist ? '已在词库中' : '点击选中'} | 出现在 ${r.emailCount} 封邮件中,共 ${r.totalCount} 次">${escapeHtml(r.word)} <sup style="font-size:10px;color:var(--primary);font-weight:700">${r.emailCount}</sup>${isExist ? ' \u2713' : ''}</span>`;
}).join('');
const selectable = results.filter(r => !existingWords.has(r.word)).length;
const existing = results.length - selectable;
const dedupMsg = dedupCount > 0 ? `,合并 ${dedupCount} 个词形变化` : '';
if (infoEl) infoEl.textContent = `${allEmails.length} 个来源中提取到 ${results.length} 个单词,${existing} 个已存在,${selectable} 个可添加${dedupMsg}(频次排序)`;
if (actionsEl) actionsEl.style.display = selectable > 0 ? 'flex' : 'none';
const statusEl = document.getElementById('batch-result');
if (statusEl) statusEl.innerHTML = `<span style="font-size:12px;color:var(--success)"><i class="fas fa-check-circle"></i> 已提取到右侧面板</span>`;
updateSelectedCount();
showToast(`批量提取完成:${results.length} 个单词${dedupMsg}`, 'success');
}
export function toggleBatchExtract(enabled) {
const previousValue = state.batchExtractEnabled;
state.batchExtractEnabled = !!enabled;
if (!saveJsonSetting(STORAGE_KEYS.batchExtractEnabled, state.batchExtractEnabled)) {
state.batchExtractEnabled = previousValue;
return false;
}
showToast(enabled ? '已开启批量提词模式' : '已关闭批量提词模式', 'info');
if (state.currentPage === 'settings') renderPage('settings');
return true;
}
+149
View File
@@ -0,0 +1,149 @@
import { COMMON_NAMES, STOP_WORDS } from '../data/stopwords.js';
import { loadJsonSetting, saveJsonSetting, STORAGE_KEYS } from '../core/storage.js';
import { showToast } from '../ui/toast.js';
import { closeModal, showModal } from '../ui/modal.js';
import { escapeHtml } from '../ui/dom.js';
// ==================== Filter Words Management ====================
function normalizeCustomFilterWords(value) {
const data = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
const normalizeList = list => Array.isArray(list)
? [...new Set(list.filter(word => typeof word === 'string').map(word => word.trim().toLowerCase()).filter(Boolean))]
: [];
return {
addedStopWords: normalizeList(data.addedStopWords),
removedStopWords: normalizeList(data.removedStopWords),
addedCommonNames: normalizeList(data.addedCommonNames),
removedCommonNames: normalizeList(data.removedCommonNames)
};
}
export function loadCustomFilterWords() {
const data = normalizeCustomFilterWords(loadJsonSetting(STORAGE_KEYS.customFilterWords, {}));
data.addedStopWords.forEach(w => STOP_WORDS.add(w));
data.removedStopWords.forEach(w => STOP_WORDS.delete(w));
data.addedCommonNames.forEach(w => COMMON_NAMES.add(w));
data.removedCommonNames.forEach(w => COMMON_NAMES.delete(w));
}
export function saveCustomFilterWords(action, type, word) {
const data = normalizeCustomFilterWords(loadJsonSetting(STORAGE_KEYS.customFilterWords, {}));
if (type === 'stop') {
if (action === 'add') {
data.addedStopWords = [...new Set([...data.addedStopWords, word])];
data.removedStopWords = data.removedStopWords.filter(w => w !== word);
} else {
data.removedStopWords = [...new Set([...data.removedStopWords, word])];
data.addedStopWords = data.addedStopWords.filter(w => w !== word);
}
} else if (type === 'names') {
if (action === 'add') {
data.addedCommonNames = [...new Set([...data.addedCommonNames, word])];
data.removedCommonNames = data.removedCommonNames.filter(w => w !== word);
} else {
data.removedCommonNames = [...new Set([...data.removedCommonNames, word])];
data.addedCommonNames = data.addedCommonNames.filter(w => w !== word);
}
} else {
return false;
}
if (!saveJsonSetting(STORAGE_KEYS.customFilterWords, data)) return false;
const targetSet = type === 'stop' ? STOP_WORDS : COMMON_NAMES;
if (action === 'add') targetSet.add(word);
else targetSet.delete(word);
return true;
}
export function showFilterWordsModal(type) {
const isStop = type === 'stop';
const title = isStop ? '<i class="fas fa-ban" style="color:var(--error)"></i> 管理停用词' : '<i class="fas fa-user-slash" style="color:var(--primary)"></i> 管理常见人名';
const targetSet = isStop ? STOP_WORDS : COMMON_NAMES;
const words = [...targetSet].sort();
showModal(title, `
<div style="margin-bottom:12px">
<div style="display:flex;gap:8px;align-items:center">
<input type="text" id="filter-word-input" placeholder="输入要添加的${isStop ? '停用词' : '人名'}..." style="flex:1;padding:8px 12px;border:1px solid var(--border);border-radius:var(--radius-sm);font-size:14px;background:var(--card);color:var(--text)">
<button class="btn btn-primary btn-sm" data-action="filterWords.addFilterWord" data-arg0="${type}"><i class="fas fa-plus"></i> 添加</button>
</div>
<p class="hint" style="margin-top:4px">多个词用空格或逗号分隔</p>
</div>
<div style="display:flex;gap:8px;margin-bottom:12px">
<input type="text" id="filter-word-search" placeholder="搜索..." data-action="filterWords.filterWordSearch" data-action-event="input" data-arg0="${type}" style="flex:1;padding:6px 10px;border:1px solid var(--border);border-radius:var(--radius-sm);font-size:13px;background:var(--card);color:var(--text)">
<button class="btn btn-sm btn-secondary" data-action="filterWords.copyFilterWords" data-arg0="${type}"><i class="fas fa-copy"></i> 复制全部</button>
</div>
<div style="font-size:12px;color:var(--text-muted);margin-bottom:8px">共 ${words.length} 个词,点击词语可删除</div>
<div id="filter-words-container" style="max-height:360px;overflow-y:auto;display:flex;flex-wrap:wrap;gap:4px;padding:8px;background:var(--bg);border-radius:var(--radius-sm);border:1px solid var(--border)">
${words.map(w => `<span class="word-chip" data-action="filterWords.removeFilterWord" data-arg0="${escapeHtml(type)}" data-arg1="${escapeHtml(w)}" title="点击删除" style="cursor:pointer;font-size:12px;padding:3px 8px">${escapeHtml(w)}</span>`).join('')}
</div>
`);
const input = document.getElementById('filter-word-input');
if (input) {
input.addEventListener('keydown', e => {
if (e.key === 'Enter') addFilterWord(type);
});
}
}
export function addFilterWord(type) {
const input = document.getElementById('filter-word-input');
if (!input) return;
const raw = input.value.trim().toLowerCase();
if (!raw) return;
const newWords = raw.split(/[\s,]+/).filter(w => w.length > 0);
if (newWords.length === 0) return;
let added = 0;
const targetSet = type === 'stop' ? STOP_WORDS : COMMON_NAMES;
for (const w of newWords) {
if (!targetSet.has(w) && saveCustomFilterWords('add', type, w)) added++;
}
input.value = '';
showToast(`已添加 ${added} 个词`, 'success');
showFilterWordsModal(type);
}
export function removeFilterWord(type, word) {
if (!confirm(`确定要删除「${word}」吗?`)) return false;
if (!saveCustomFilterWords('remove', type, word)) return false;
showToast(`已删除「${word}`, 'info');
showFilterWordsModal(type);
return true;
}
export function filterWordSearch(type) {
const q = (document.getElementById('filter-word-search')?.value || '').toLowerCase();
const container = document.getElementById('filter-words-container');
if (!container) return;
container.querySelectorAll('.word-chip').forEach(chip => {
chip.style.display = chip.textContent.toLowerCase().includes(q) ? '' : 'none';
});
}
export function copyFilterWords(type) {
const targetSet = type === 'stop' ? STOP_WORDS : COMMON_NAMES;
const name = type === 'stop' ? 'STOP_WORDS' : 'COMMON_NAMES';
const words = [...targetSet].sort();
const lines = [];
for (let i = 0; i < words.length; i += 10) {
const chunk = words.slice(i, i + 10).map(w => `'${w}'`).join(',');
lines.push(' ' + chunk + (i + 10 < words.length ? ',' : ''));
}
const code = `const ${name} = new Set([\n${lines.join('\n')}\n]);`;
navigator.clipboard.writeText(code).then(() => {
showToast('已复制到剪贴板,可直接用来修改代码', 'success');
}).catch(() => {
showModal('复制内容', `<textarea style="width:100%;height:300px;font-family:monospace;font-size:12px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:var(--radius-sm);padding:12px" readonly>${escapeHtml(code)}</textarea>`, `
<button class="btn btn-secondary" data-action="filterWords.closeModal">关闭</button>
`);
});
}
+91
View File
@@ -0,0 +1,91 @@
import { state } from '../core/state.js';
import { navigate } from '../core/router.js';
import { getQuizErrorWords, getStats, getStreak } from '../services/stats.js';
import { escapeHtml } from '../ui/dom.js';
// ==================== Page: Home ====================
export function renderHome(el) {
const s = getStats();
const streak = getStreak();
const errorWords = getQuizErrorWords();
const hour = new Date().getHours();
const greeting = hour < 6 ? '夜深了,注意休息' : hour < 12 ? '早上好,元气满满' : hour < 18 ? '下午好,继续加油' : '晚上好,坚持学习';
el.innerHTML = `
<div class="page-header">
<div>
<h1>仪表盘</h1>
<p class="page-desc">${greeting}!今天也要加油学习 💪</p>
</div>
${streak > 0 ? `<div class="streak-display"><i class="fas fa-fire"></i> <span class="streak-num">${streak}</span> 天连续学习</div>` : ''}
</div>
<div class="stat-grid">
<div class="stat-card" style="border-left:3px solid var(--primary)">
<div class="stat-icon" style="background:var(--primary-bg);color:var(--primary)"><i class="fas fa-database"></i></div>
<div class="stat-info">
<div class="stat-value">${s.total}</div>
<div class="stat-label">总单词</div>
</div>
</div>
<div class="stat-card" style="border-left:3px solid var(--success)">
<div class="stat-icon" style="background:var(--success-light);color:var(--success)"><i class="fas fa-check-circle"></i></div>
<div class="stat-info">
<div class="stat-value">${s.mastered}</div>
<div class="stat-label">已掌握</div>
</div>
</div>
<div class="stat-card" style="border-left:3px solid var(--warning)">
<div class="stat-icon" style="background:var(--warning-light);color:var(--warning)"><i class="fas fa-spinner"></i></div>
<div class="stat-info">
<div class="stat-value">${s.learning}</div>
<div class="stat-label">学习中</div>
</div>
</div>
<div class="stat-card" style="border-left:3px solid var(--error)">
<div class="stat-icon" style="background:var(--error-light);color:var(--error)"><i class="fas fa-redo"></i></div>
<div class="stat-info">
<div class="stat-value">${s.due}</div>
<div class="stat-label">错题数</div>
</div>
</div>
</div>
<div class="home-actions">
<div class="card action-card" data-action="home.navigate" data-arg0="extract">
<div class="action-icon" style="background:rgba(59,130,246,0.1);color:#3b82f6"><i class="fas fa-envelope"></i></div>
<h3>邮件提词</h3>
<p class="action-count">粘贴邮件,智能提取英文单词</p>
<button class="btn btn-primary btn-lg">开始提取</button>
</div>
<div class="card action-card" data-action="home.navigate" data-arg0="quiz">
<div class="action-icon" style="background:rgba(168,85,247,0.1);color:#a855f7"><i class="fas fa-robot"></i></div>
<h3>AI 测试</h3>
<p class="action-count">智能出题,检测你的掌握程度</p>
<button class="btn btn-primary btn-lg">开始测试</button>
</div>
<div class="card action-card" data-action="home.navigate" data-arg0="mail-learn">
<div class="action-icon" style="background:rgba(34,197,94,0.1);color:#22c55e"><i class="fas fa-graduation-cap"></i></div>
<h3>邮件学习</h3>
<p class="action-count">${state.mailEmails.length > 0 ? state.mailEmails.length + ' 封已保存邮件' : 'AI 分析邮件,全文学习'}</p>
<button class="btn btn-primary btn-lg">开始学习</button>
</div>
</div>
${errorWords.length > 0 ? `
<div class="card">
<h3><i class="fas fa-exclamation-circle"></i> 错题单词预览</h3>
<div class="review-list">
${errorWords.slice(0, 12).map(w => `
<div class="review-word-item">
<div class="rw-en">${escapeHtml(w.english)}</div>
<div class="rw-zh">${escapeHtml(w.chinese)}</div>
<div class="rw-stage">正确率 ${w.correctRate}%</div>
</div>`).join('')}
</div>
${errorWords.length > 12 ? `<p style="text-align:center;color:var(--text-muted);margin-top:12px">还有 ${errorWords.length - 12} 个...</p>` : ''}
</div>` : ''}
`;
}
+350
View File
@@ -0,0 +1,350 @@
import { state } from '../core/state.js';
import { saveJsonSetting, STORAGE_KEYS } from '../core/storage.js';
import { isFavorite, toggleFavorite } from '../services/favorites.js';
import { renderPage } from '../core/router.js';
import { prefetchAudio, speak, speakSlow } from '../services/tts.js';
import { recordAndUpdateSchedule } from '../services/ebbinghaus.js';
import { getMastery } from '../services/stats.js';
import { escapeHtml } from '../ui/dom.js';
import { closeModal } from '../ui/modal.js';
import { closeSidebar } from '../ui/sidebar.js';
import { startReviewSession } from './review.js';
// ==================== Page: Learn Session (shared by Review) ====================
export function startLearnSession(cards) {
if (!Array.isArray(cards) || cards.length === 0) return false;
state.learnSession = {
active: true,
isReview: true,
cards,
index: 0,
flipped: false,
autoDisplay: state.learnAutoDisplay,
results: { know: 0, fuzzy: 0, unknown: 0 },
rated: []
};
renderPage('review');
setTimeout(() => {
const word = state.learnSession?.cards[0];
if (word) speak(word.english);
}, 300);
return true;
}
export function getWeakWords() {
return state.words.filter(w => {
const sch = state.schedule[w.id];
if (!sch) return false;
const total = sch.correctCount + sch.incorrectCount;
return total > 0 && (sch.incorrectCount / total) > 0.4;
});
}
export function renderLearnSession(el) {
const s = state.learnSession;
// 目前仅 startReviewSession 创建会话(均为 review)。非 review 会话不渲染会白屏,此处兜底结束会话
if (!s || !s.isReview || !Array.isArray(s.cards)) {
console.warn('renderLearnSession: 收到非 review 的 learnSession,已结束该会话');
endLearnSession();
return;
}
if (s.index >= s.cards.length) {
renderLearnComplete(el);
return;
}
const w = s.cards[s.index];
const progress = ((s.index + 1) / s.cards.length * 100).toFixed(1);
const catMap = { high_frequency: 'HIGH FREQUENCY', medium_frequency: 'MEDIUM FREQUENCY', low_frequency: 'LOW FREQUENCY' };
const catText = catMap[w.category] || (w.category || '').toUpperCase().replace(/_/g, ' ');
const catClass = w.category === 'high_frequency' ? 'high' : w.category === 'medium_frequency' ? 'mid' : 'low';
const autoShow = s.autoDisplay !== false;
const isRevealed = autoShow || s.flipped;
const isReview = !!s.isReview;
const alreadyRated = isReview && s.rated && s.rated[s.index];
let formsHtml = '';
if (w.forms) {
formsHtml = Object.entries(w.forms).map(([key, val]) => {
const v = Array.isArray(val) ? val.join(', ') : val;
return `<div class="lv2-gcard-form-item"><span class="lv2-gcard-form-key">${escapeHtml(key)}:</span> ${escapeHtml(v)}</div>`;
}).join('');
}
const mastery = getMastery(w.id);
const masteryMap = { mastered: '已掌握', learning: '学习中', new: '新词' };
const safeWord = escapeHtml(w.english);
const cardHtml = `
<div class="lv2-gcard" ${!autoShow ? 'data-action="learn.flipLearnCard"' : ''} style="${!autoShow ? 'cursor:pointer' : 'cursor:default'}">
<div class="lv2-gcard-inner">
<div class="lv2-gcard-word">${escapeHtml(w.english)}</div>
<div class="lv2-gcard-prow">
<span class="lv2-gcard-ph">${escapeHtml(w.phonetic || '')}</span>
${w.frequency != null ? `<span class="lv2-gcard-freq">频次: ${w.frequency}</span>` : ''}
</div>
${catText ? `<span class="lv2-gcard-cat lv2-gcard-cat-${catClass}">${escapeHtml(catText)}</span>` : ''}
${isRevealed ? `
<div class="lv2-gcard-divider"></div>
<div class="lv2-gcard-zh">${escapeHtml(w.chinese)}</div>
${formsHtml ? `<div class="lv2-gcard-forms">${formsHtml}</div>` : ''}
<div class="lv2-gcard-speak-row">
<button class="lv2-gcard-speak" data-action="learn.speak" data-arg0="${safeWord}" title="正常语速"><i class="fas fa-volume-up"></i></button>
<button class="lv2-gcard-speak lv2-gcard-speak-slow" data-action="learn.speakSlow" data-arg0="${safeWord}" title="慢速"><i class="fas fa-walking"></i></button>
<button class="lv2-gcard-speak" id="learn-fav-btn" data-action="learn.toggleLearnFavorite" data-arg0="${w.id}" title="${isFavorite(w.id) ? '取消收藏' : '收藏'}" style="color:${isFavorite(w.id) ? 'var(--warning)' : 'var(--text-muted)'}"><i class="fas fa-star"></i></button>
</div>
${w.example ? `
<div class="lv2-gcard-ex">
<div class="lv2-gcard-ex-en">${escapeHtml(w.example.en)}</div>
<div class="lv2-gcard-ex-cn">${escapeHtml(w.example.cn)}</div>
</div>` : ''}
` : `
<div class="lv2-gcard-speak-row" style="margin-top:20px">
<button class="lv2-gcard-speak" data-action="learn.speak" data-arg0="${safeWord}" title="正常语速"><i class="fas fa-volume-up"></i></button>
<button class="lv2-gcard-speak lv2-gcard-speak-slow" data-action="learn.speakSlow" data-arg0="${safeWord}" title="慢速"><i class="fas fa-walking"></i></button>
<button class="lv2-gcard-speak" id="learn-fav-btn" data-action="learn.toggleLearnFavorite" data-arg0="${w.id}" title="${isFavorite(w.id) ? '取消收藏' : '收藏'}" style="color:${isFavorite(w.id) ? 'var(--warning)' : 'var(--text-muted)'}"><i class="fas fa-star"></i></button>
</div>
<div class="lv2-gcard-hint"><i class="fas fa-hand-pointer"></i> 点击卡片查看释义</div>
`}
</div>
<div class="lv2-gcard-badge">${masteryMap[mastery] || '新词'}</div>
</div>`;
const rateHtml = isReview ? `
<div class="review-rate-card">
${isRevealed ? `
<div class="lv2-rate-row">
<button class="lv2-rate lv2-rate-know ${alreadyRated === 'know' ? 'lv2-rate-selected' : ''}" data-action="learn.rateLearnCard" data-arg0="know"><i class="fas fa-check"></i> 认识</button>
<button class="lv2-rate lv2-rate-fuzzy ${alreadyRated === 'fuzzy' ? 'lv2-rate-selected' : ''}" data-action="learn.rateLearnCard" data-arg0="fuzzy"><i class="fas fa-question"></i> 模糊</button>
<button class="lv2-rate lv2-rate-unknown ${alreadyRated === 'unknown' ? 'lv2-rate-selected' : ''}" data-action="learn.rateLearnCard" data-arg0="unknown"><i class="fas fa-times"></i> 不认识</button>
</div>` : `<div class="review-rate-placeholder"><i class="fas fa-eye-slash" style="margin-right:6px"></i>查看释义后评价</div>`}
</div>` : '';
if (isReview) {
const statusItems = s.cards.map((c, idx) => {
const rated = s.rated && s.rated[idx];
const isCurrent = idx === s.index;
let icon = '<i class="far fa-circle" style="color:var(--text-muted)"></i>';
let labelText = '';
if (rated === 'know') { icon = '<i class="fas fa-check-circle" style="color:var(--success)"></i>'; labelText = '认识'; }
else if (rated === 'fuzzy') { icon = '<i class="fas fa-question-circle" style="color:var(--warning)"></i>'; labelText = '模糊'; }
else if (rated === 'unknown') { icon = '<i class="fas fa-times-circle" style="color:var(--error)"></i>'; labelText = '不认识'; }
const activeClass = isCurrent ? 'review-status-active' : '';
return `<div class="review-status-item ${activeClass}" data-action="learn.jumpToReviewCard" data-arg0="${idx}">
<span class="review-status-icon">${icon}</span>
<span class="review-status-word">${escapeHtml(c.english)}</span>
${labelText ? `<span class="review-status-label">${labelText}</span>` : ''}
</div>`;
}).join('');
const cardIndexChanged = state.lastReviewIndex !== s.index;
state.lastReviewIndex = s.index;
el.innerHTML = `
<div class="page-header" style="margin-bottom:12px">
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<h1>错题复习</h1>
<span class="badge" style="background:var(--success-light);color:var(--success)">${s.results.know} 认识</span>
<span class="badge" style="background:var(--warning-light);color:var(--warning)">${s.results.fuzzy + s.results.unknown} 待加强</span>
</div>
<div style="display:flex;align-items:center;gap:8px">
<button class="lv2-tb-btn ${autoShow ? 'lv2-tb-active' : ''}" data-action="learn.toggleLearnAutoDisplay"><i class="fas ${autoShow ? 'fa-book-open' : 'fa-sync-alt'}"></i> ${autoShow ? '自动显示' : '翻转模式'}</button>
<button class="btn btn-ghost" data-action="learn.endLearnSession">结束复习</button>
</div>
</div>
<div class="lv2-pbar"><div class="lv2-pfill" style="width:${progress}%"></div></div>
<div class="review-session-grid">
<div class="review-grid-footer">
${rateHtml}
</div>
<div class="review-grid-card">
<div class="lv2-card-area">${cardHtml}</div>
</div>
<div class="review-grid-panel">
<div class="review-status-panel">
<div class="review-status-header">
<h4><i class="fas fa-list-ol" style="margin-right:6px"></i>错题进度 <span style="font-weight:400;color:var(--text-muted);font-size:12px">${s.index + 1} / ${s.cards.length}</span></h4>
</div>
<div class="review-status-list" id="review-status-list">${statusItems}</div>
</div>
</div>
</div>
`;
if (cardIndexChanged) {
const card = el.querySelector('.lv2-gcard');
if (card) { card.classList.add('lv2-gcard-anim'); }
}
const list = document.getElementById('review-status-list');
const activeEl = list?.querySelector('.review-status-active');
if (activeEl && list) {
const top = activeEl.offsetTop - list.offsetTop;
if (top < list.scrollTop || top + activeEl.offsetHeight > list.scrollTop + list.clientHeight) {
list.scrollTop = top - list.clientHeight / 2 + activeEl.offsetHeight / 2;
}
}
}
prefetchAudio(w.english);
const nextW = s.cards[s.index + 1];
if (nextW) prefetchAudio(nextW.english);
}
export function jumpToReviewCard(idx) {
if (!state.learnSession) return;
state.learnSession.index = idx;
state.learnSession.flipped = false;
rerenderLearnSession();
const s = state.learnSession;
if (s.cards[idx]) {
speak(s.cards[idx].english);
}
}
export function toggleLearnFavorite(wordId) {
if (!toggleFavorite(wordId)) return false;
const faved = isFavorite(wordId);
const btn = document.getElementById('learn-fav-btn');
if (btn) {
btn.style.color = faved ? 'var(--warning)' : 'var(--text-muted)';
btn.title = faved ? '取消收藏' : '收藏';
}
}
export function rerenderLearnSession() {
const el = document.getElementById('page-content');
if (el && state.learnSession && state.learnSession.active) {
renderLearnSession(el);
} else {
renderPage(state.currentPage);
}
}
export function flipLearnCard() {
if (!state.learnSession) return;
state.learnSession.flipped = !state.learnSession.flipped;
rerenderLearnSession();
}
export function rateLearnCard(rating) {
const s = state.learnSession;
if (!s) return;
if (!s.rated) s.rated = [];
const oldRating = s.rated[s.index];
if (oldRating || !['know', 'fuzzy', 'unknown'].includes(rating)) return;
const w = s.cards[s.index];
const isCorrect = rating === 'know';
if (!recordAndUpdateSchedule(w.id, isCorrect, 'review', rating)) return;
s.results[rating]++;
s.rated[s.index] = rating;
s.index++;
s.flipped = false;
rerenderLearnSession();
if (s.index < s.cards.length) {
speak(s.cards[s.index].english);
}
}
export function renderLearnComplete(el) {
const s = state.learnSession;
const total = s.cards.length;
el.innerHTML = `
<div class="lv2-complete">
<div class="lv2-complete-icon"><i class="fas fa-trophy"></i></div>
<h2 class="lv2-complete-title">复习完成!</h2>
<p class="lv2-complete-desc">本组共 ${total} 个单词</p>
<div class="lv2-complete-stats">
<div class="lv2-cs-item">
<div class="lv2-cs-val" style="color:var(--success)">${s.results.know}</div>
<div class="lv2-cs-lbl">认识</div>
</div>
<div class="lv2-cs-item">
<div class="lv2-cs-val" style="color:var(--warning)">${s.results.fuzzy}</div>
<div class="lv2-cs-lbl">模糊</div>
</div>
<div class="lv2-cs-item">
<div class="lv2-cs-val" style="color:var(--error)">${s.results.unknown}</div>
<div class="lv2-cs-lbl">不认识</div>
</div>
</div>
<div class="lv2-complete-actions">
<button class="lv2-nav-btn" data-action="learn.startLearnAgain">再复习一组</button>
<button class="lv2-nav-btn lv2-nav-center" data-action="learn.endLearnSession">返回</button>
</div>
</div>
`;
}
export function startLearnAgain() {
state.learnSession = null;
startReviewSession();
}
export function endLearnSession() {
state.learnSession = null;
renderPage(state.currentPage);
}
export function prevLearnCard() {
if (!state.learnSession || state.learnSession.index <= 0) return;
state.learnSession.index--;
state.learnSession.flipped = false;
rerenderLearnSession();
const w = state.learnSession.cards[state.learnSession.index];
if (w) speak(w.english);
}
export function nextLearnCardDirect() {
const s = state.learnSession;
if (!s || !s.rated?.[s.index]) return;
s.index++;
s.flipped = false;
rerenderLearnSession();
const w = s.cards[s.index];
if (w) speak(w.english);
}
export function toggleLearnAutoDisplay() {
if (!state.learnSession) return false;
const newVal = state.learnSession.autoDisplay === false;
if (!saveJsonSetting(STORAGE_KEYS.learnAutoDisplay, newVal)) return false;
state.learnSession.autoDisplay = newVal;
state.learnAutoDisplay = newVal;
state.learnSession.flipped = false;
rerenderLearnSession();
return true;
}
export function bindLearnKeyboard() {
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
closeModal();
closeSidebar();
return;
}
if (e.target instanceof HTMLElement && (e.target.isContentEditable || /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(e.target.tagName))) return;
const s = state.learnSession;
if (!s?.active || s.index >= s.cards.length || document.querySelector('.modal-overlay') || state.currentPage !== 'review') return;
const revealed = s.autoDisplay !== false || s.flipped;
if (e.key === 'ArrowLeft') {
e.preventDefault();
prevLearnCard();
} else if (e.key === 'ArrowRight') {
e.preventDefault();
nextLearnCardDirect();
} else if (e.key === ' ') {
e.preventDefault();
if (s.autoDisplay === false && !s.flipped) flipLearnCard();
else nextLearnCardDirect();
} else if (e.key === '1' && revealed) {
rateLearnCard('know');
} else if (e.key === '2' && revealed) {
rateLearnCard('fuzzy');
} else if (e.key === '3' && revealed) {
rateLearnCard('unknown');
}
});
}
+192
View File
@@ -0,0 +1,192 @@
import { state } from '../core/state.js';
import { saveEmails } from '../core/storage.js';
import { renderPage } from '../core/router.js';
import { showToast } from '../ui/toast.js';
import { speak } from '../services/tts.js';
import { callAI } from '../services/ai.js';
import { autoResizeTextarea, escapeHtml, formatMarkdown } from '../ui/dom.js';
let mailLearnRequestSeq = 0;
export function beginMailLearnRequest(resultEl) {
return { seq: ++mailLearnRequestSeq, resultEl };
}
export function isCurrentMailLearnRequest(request) {
return request.seq === mailLearnRequestSeq
&& request.resultEl.isConnected
&& document.getElementById('mail-learn-result') === request.resultEl;
}
// ==================== Page: Mail Learn (邮件学习) ====================
export function renderMailLearn(el) {
el.innerHTML = `
<div class="page-header">
<div>
<h1>邮件学习</h1>
<p class="page-desc">学习完单词后,进行整封邮件的学习,借助 AI 分析邮件内容</p>
</div>
</div>
<div class="card" style="margin-bottom:24px">
<h3><i class="fas fa-paste"></i> 粘贴邮件或选择已保存邮件</h3>
<textarea id="mail-learn-input" placeholder="粘贴英文邮件内容,AI 将帮你分析邮件中的重点词汇、句型和语法..." style="min-height:160px"></textarea>
<div style="display:flex;gap:8px;margin-top:16px;flex-wrap:wrap">
<button class="btn btn-primary" data-action="mailLearn.startFullReading"><i class="fas fa-book-reader"></i> 全文阅读</button>
<button class="btn btn-secondary" data-action="mailLearn.analyzeEmail"><i class="fas fa-magic"></i> AI 分析</button>
<button class="btn btn-secondary" data-action="mailLearn.translateFullEmail"><i class="fas fa-language"></i> 全文翻译</button>
<button class="btn btn-secondary" data-action="mailLearn.highlightWordsInMail"><i class="fas fa-highlighter"></i> 高亮词库单词</button>
</div>
</div>
${state.mailEmails.length > 0 ? `
<div class="card" style="margin-bottom:24px">
<h3><i class="fas fa-history"></i> 已保存的邮件 (${state.mailEmails.length})</h3>
<div style="display:flex;flex-direction:column;gap:8px">
${state.mailEmails.map((m, i) => `
<div style="display:flex;align-items:center;gap:12px;padding:12px 16px;background:var(--bg);border-radius:var(--radius-sm);border:1px solid var(--border);cursor:pointer;transition:all 0.2s" data-action="mailLearn.loadSavedEmail" data-arg0="${i}" class="saved-email-item">
<i class="fas fa-envelope" style="color:var(--primary)"></i>
<div style="flex:1;min-width:0">
<div style="font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml(m.title)}</div>
<div style="font-size:12px;color:var(--text-muted)">${escapeHtml(m.date)}</div>
</div>
<button class="btn btn-sm btn-ghost" data-action="mailLearn.deleteSavedEmail" data-arg0="${i}" title="删除"><i class="fas fa-trash-alt" style="color:var(--error)"></i></button>
</div>`).join('')}
</div>
</div>` : ''}
<div id="mail-learn-result"></div>
`;
const ta = document.getElementById('mail-learn-input');
ta.addEventListener('input', () => autoResizeTextarea(ta));
ta.addEventListener('paste', () => setTimeout(() => autoResizeTextarea(ta), 0));
}
export function loadSavedEmail(idx) {
const email = state.mailEmails[idx];
if (email) {
document.getElementById('mail-learn-input').value = email.content;
autoResizeTextarea(document.getElementById('mail-learn-input'));
}
}
export function deleteSavedEmail(idx) {
if (!confirm('确定删除这封保存的邮件?')) return false;
if (!Number.isInteger(idx) || idx < 0 || idx >= state.mailEmails.length) return false;
const [removedEmail] = state.mailEmails.splice(idx, 1);
if (!saveEmails()) {
state.mailEmails.splice(idx, 0, removedEmail);
return false;
}
renderPage('mail-learn');
return true;
}
export async function analyzeEmail() {
const text = document.getElementById('mail-learn-input').value.trim();
if (!text) { showToast('请粘贴邮件内容', 'error'); return; }
const resultEl = document.getElementById('mail-learn-result');
const request = beginMailLearnRequest(resultEl);
resultEl.innerHTML = '<div class="loading"><div class="spinner"></div> AI 正在分析邮件...</div>';
try {
const knownWords = state.words.map(w => w.english).join(', ');
const prompt = `你是英语邮件学习助手。请分析以下英文邮件,帮助学生学习:
邮件内容:
"""
${text}
"""
${knownWords ? `学生已学过的单词:${knownWords}` : ''}
请从以下方面进行分析(用中文回答):
1. **邮件主题概述**:简要说明邮件的主要内容和目的(2-3句话)
2. **重点词汇**:列出邮件中 5-8 个重要/高级的词汇,包含音标、词性、中文释义和在邮件中的含义
3. **关键句型**:找出 3-5 个值得学习的句型/表达方式,给出中文翻译和用法说明
4. **语法要点**:指出邮件中涉及的 2-3 个语法点
5. **商务/邮件礼仪**:如果是商务邮件,说明邮件中的礼貌用语和写作技巧
6. **全文翻译**:给出邮件的完整中文翻译`;
const content = await callAI([
{ role: 'system', content: '你是专业的英语邮件教学助手,分析详细且易于理解。' },
{ role: 'user', content: prompt }
]);
if (!isCurrentMailLearnRequest(request)) return;
resultEl.innerHTML = `
<div class="card">
<h3><i class="fas fa-brain"></i> AI 分析结果</h3>
<div class="ai-analysis-content">${formatMarkdown(content)}</div>
</div>
`;
} catch (err) {
if (!isCurrentMailLearnRequest(request)) return;
resultEl.innerHTML = `<div class="card"><div style="color:var(--error);padding:20px"><i class="fas fa-exclamation-circle"></i> 分析失败:${escapeHtml(err.message)}</div></div>`;
}
}
export async function translateFullEmail() {
const text = document.getElementById('mail-learn-input').value.trim();
if (!text) { showToast('请粘贴邮件内容', 'error'); return; }
const resultEl = document.getElementById('mail-learn-result');
const request = beginMailLearnRequest(resultEl);
resultEl.innerHTML = '<div class="loading"><div class="spinner"></div> AI 正在翻译...</div>';
try {
const content = await callAI([
{ role: 'system', content: '你是翻译助手,翻译准确自然。' },
{ role: 'user', content: `请将以下英文邮件翻译为中文,保持格式:\n\n${text}` }
]);
if (!isCurrentMailLearnRequest(request)) return;
resultEl.innerHTML = `
<div class="mail-learn-layout">
<div class="card"><h3><i class="fas fa-file-alt"></i> 原文</h3><div class="mail-original" style="white-space:pre-wrap">${escapeHtml(text)}</div></div>
<div class="card"><h3><i class="fas fa-language"></i> 翻译</h3><div class="ai-analysis-content" style="white-space:pre-wrap">${escapeHtml(content)}</div></div>
</div>
`;
} catch (err) {
if (!isCurrentMailLearnRequest(request)) return;
resultEl.innerHTML = `<div class="card"><div style="color:var(--error);padding:20px"><i class="fas fa-exclamation-circle"></i> 翻译失败:${escapeHtml(err.message)}</div></div>`;
}
}
export function highlightWordsInMail() {
const text = document.getElementById('mail-learn-input').value.trim();
if (!text) { showToast('请粘贴邮件内容', 'error'); return; }
const knownSet = new Set(state.words.map(w => w.english.toLowerCase()));
const words = text.split(/(\s+|[,.;:!?'"()\[\]{}<>\/\\—–-])/);
let highlighted = '';
let matchCount = 0;
words.forEach(w => {
const clean = w.toLowerCase().replace(/[^a-z]/g, '');
if (knownSet.has(clean) && clean.length >= 2) {
highlighted += `<span class="highlight-word" data-action="mailLearn.speak" data-arg0="${escapeHtml(clean)}" title="点击发音">${escapeHtml(w)}</span>`;
matchCount++;
} else {
highlighted += escapeHtml(w);
}
});
const resultEl = document.getElementById('mail-learn-result');
resultEl.innerHTML = `
<div class="card">
<h3><i class="fas fa-highlighter"></i> 高亮结果 <span class="badge badge-primary">${matchCount} 个词库单词</span></h3>
<div class="mail-original" style="white-space:pre-wrap">${highlighted}</div>
<p style="font-size:12px;color:var(--text-muted);margin-top:12px">紫色高亮的单词来自你的词库,点击可以发音</p>
</div>
`;
}
+64
View File
@@ -0,0 +1,64 @@
import { state } from '../core/state.js';
import {
QUIZ_AUDIO_PREFETCH_NEXT_THRESHOLD,
QUIZ_AUDIO_PREFETCH_WINDOW_SIZE,
logTts,
prefetchAudio
} from '../services/tts.js';
export function getQuizAudioTexts(questions) {
if (!Array.isArray(questions)) return [];
return questions.map(getQuizAudioText).filter(Boolean);
}
export function getQuizAudioText(question) {
if (!question) return '';
if (question.displayWord) return question.displayWord;
if (question.word?.english) return question.word.english;
if (question.wordId) {
const word = state.words.find(w => w.id === question.wordId);
if (word?.english) return word.english;
}
return question.question || '';
}
export function createQuizAudioPrefetchState(questions) {
const texts = getQuizAudioTexts(questions);
return {
texts,
nextStart: 0,
prefetchingStarts: new Set()
};
}
export function prefetchQuizAudioWindow(session, startIndex = 0) {
if (!session || session.mode !== 'en2zh' || !session.audioPrefetch) return;
const prefetch = session.audioPrefetch;
const start = Math.max(0, startIndex);
if (start >= prefetch.texts.length || prefetch.prefetchingStarts.has(start)) return;
const end = Math.min(start + QUIZ_AUDIO_PREFETCH_WINDOW_SIZE, prefetch.texts.length);
const windowTexts = prefetch.texts.slice(start, end);
prefetch.prefetchingStarts.add(start);
void prefetchAudio(windowTexts, 0.92, { allowCloud: true })
.then(() => {
prefetch.nextStart = Math.max(prefetch.nextStart, end);
})
.catch(err => {
logTts('quiz audio prefetch failed', err);
prefetch.nextStart = Math.max(prefetch.nextStart, end);
})
.finally(() => {
prefetch.prefetchingStarts.delete(start);
});
}
export function maybePrefetchNextQuizAudioWindow(session) {
if (!session || session.mode !== 'en2zh' || !session.audioPrefetch) return;
const prefetch = session.audioPrefetch;
if (prefetch.nextStart >= prefetch.texts.length) return;
if (prefetch.nextStart - session.index <= QUIZ_AUDIO_PREFETCH_NEXT_THRESHOLD) {
prefetchQuizAudioWindow(session, prefetch.nextStart);
}
}
+259
View File
@@ -0,0 +1,259 @@
import { LETTERS } from '../constants.js';
import { state } from '../core/state.js';
import { renderPage } from '../core/router.js';
import { isFavorite, toggleFavorite } from '../services/favorites.js';
import { recordAndUpdateSchedule } from '../services/ebbinghaus.js';
import { speak } from '../services/tts.js';
import { escapeHtml } from '../ui/dom.js';
import { getQuizAudioText, maybePrefetchNextQuizAudioWindow } from './quiz-audio.js';
export function renderQuizQuestion(el) {
const s = state.quizSession;
const q = s.questions[s.index];
const answered = s.answers[s.index] !== undefined;
const word = q.word || state.words.find(w => w.id === q.wordId);
const catMap = { high_frequency: 'HIGH FREQUENCY', medium_frequency: 'MEDIUM FREQUENCY', low_frequency: 'LOW FREQUENCY' };
const catText = word ? (catMap[word.category] || (word.category || '').toUpperCase().replace(/_/g, ' ')) : '';
const catClass = word ? (word.category === 'high_frequency' ? 'high' : word.category === 'medium_frequency' ? 'mid' : 'low') : '';
let formsHtml = '';
if (word && word.forms) {
formsHtml = Object.entries(word.forms).map(([key, val]) => {
const v = Array.isArray(val) ? val.join(', ') : val;
return `<div class="lv2-gcard-form-item"><span class="lv2-gcard-form-key">${escapeHtml(key)}:</span> ${escapeHtml(v)}</div>`;
}).join('');
}
const wordId = q.wordId || (word ? word.id : null);
const faved = wordId ? isFavorite(wordId) : false;
const safeQuizAudioText = escapeHtml(getQuizAudioText(q));
const safeWord = escapeHtml(word?.english || '');
el.innerHTML = `
<div class="page-header quiz-session-header">
<div class="quiz-session-meta">
<h1>AI 测试</h1>
<span class="badge badge-primary">${escapeHtml(s.sourceLabel)}</span>
<span class="badge" style="background:var(--bg);border:1px solid var(--border);color:var(--text-secondary)">${escapeHtml(s.modeLabel)}</span>
${s.isAI
? '<span class="badge" style="background:var(--success);color:#fff"><i class="fas fa-robot" style="margin-right:4px"></i>AI 生成</span>'
: '<span class="badge" style="background:var(--bg);border:1px solid var(--border);color:var(--text-muted)"><i class="fas fa-database" style="margin-right:4px"></i>本地题库</span>'}
</div>
<button class="btn btn-ghost quiz-end-btn" type="button" data-action="quiz.endQuiz" aria-label="结束测试" title="结束测试"><i class="fas fa-flag-checkered" aria-hidden="true"></i><span>结束测试</span></button>
</div>
<div class="quiz-area">
<div class="quiz-header">
<div class="quiz-counter">第 ${s.index + 1} / ${s.questions.length} 题</div>
<div class="learn-progress" style="flex:1;margin:0 20px">
<div class="progress-bar"><div class="progress-fill" style="width:${(s.index / s.questions.length * 100)}%"></div></div>
</div>
<div class="quiz-counter" style="color:var(--success)"><i class="fas fa-check-circle"></i> ${s.correct}</div>
${wordId ? `<button class="btn btn-ghost" id="quiz-fav-btn" data-action="quiz.toggleQuizFavorite" data-arg0="${wordId}" title="${faved ? '取消收藏' : '收藏'}" style="margin-left:4px;color:${faved ? 'var(--warning)' : 'var(--text-muted)'}"><i class="fas fa-star"></i></button>` : ''}
</div>
<div class="quiz-layout">
<div class="quiz-left">
<div class="quiz-question-card">
<div class="quiz-prompt">${q.displayWord && q.displayWord !== q.question ? escapeHtml(q.displayWord) : escapeHtml(q.question)}</div>
${q.phonetic ? `<div class="quiz-prompt-hint">${escapeHtml(q.phonetic)} <button class="btn-ghost btn-sm" data-action="quiz.speak" data-arg0="${safeQuizAudioText}" style="border:none;background:none;cursor:pointer"><i class="fas fa-volume-up"></i></button></div>` : ''}
</div>
<div class="quiz-options">
${q.options.map((opt, i) => {
let cls = '';
if (answered) {
cls = 'disabled';
if (i === q.answer) cls += ' correct';
else if (i === s.answers[s.index] && i !== q.answer) cls += ' wrong';
}
return `
<button class="quiz-option ${cls}" data-action="quiz.answerQuiz" data-arg0="${i}">
<span class="option-letter">${LETTERS[i]}</span>
<span>${escapeHtml(opt)}</span>
</button>`;
}).join('')}
</div>
${answered ? `
<div class="quiz-nav-row">
<button class="btn btn-secondary" data-action="quiz.prevQuizQuestion" ${s.index === 0 ? 'disabled' : ''}><i class="fas fa-arrow-left"></i> 上一题</button>
<button class="btn btn-primary" data-action="quiz.nextQuizQuestion">
${s.index < s.questions.length - 1 ? '下一题 <i class="fas fa-arrow-right"></i>' : '<i class="fas fa-chart-bar"></i> 查看结果'}
</button>
</div>` : ''}
</div>
<div class="quiz-right">
<div class="quiz-word-panel ${answered ? 'revealed' : 'locked'}">
${!answered ? `<div class="quiz-word-cover"><i class="fas fa-lock"></i><span>回答后显示</span></div>` : ''}
${word ? `
<div class="quiz-word-detail">
<div class="lv2-gcard-inner">
<div class="lv2-gcard-word">${escapeHtml(word.english)}</div>
<div class="lv2-gcard-prow">
<span class="lv2-gcard-ph">${escapeHtml(word.phonetic || '')}</span>
${word.frequency != null ? `<span class="lv2-gcard-freq">频次: ${word.frequency}</span>` : ''}
</div>
${catText ? `<span class="lv2-gcard-cat lv2-gcard-cat-${catClass}">${escapeHtml(catText)}</span>` : ''}
<div class="lv2-gcard-divider"></div>
<div class="lv2-gcard-zh">${escapeHtml(word.chinese)}</div>
${formsHtml ? `<div class="lv2-gcard-forms">${formsHtml}</div>` : ''}
<div class="lv2-gcard-speak-row">
<button class="lv2-gcard-speak" data-action="quiz.speak" data-arg0="${safeWord}" title="正常语速"><i class="fas fa-volume-up"></i></button>
<button class="lv2-gcard-speak lv2-gcard-speak-slow" data-action="quiz.speakSlow" data-arg0="${safeWord}" title="慢速"><i class="fas fa-walking"></i></button>
<button class="lv2-gcard-speak" id="quiz-detail-fav-btn" data-action="quiz.toggleQuizFavorite" data-arg0="${word.id}" title="${isFavorite(word.id) ? '取消收藏' : '收藏'}" style="color:${isFavorite(word.id) ? 'var(--warning)' : 'var(--text-muted)'}"><i class="fas fa-star"></i></button>
</div>
${word.example ? `
<div class="lv2-gcard-ex">
<div class="lv2-gcard-ex-en">${escapeHtml(word.example.en)}</div>
<div class="lv2-gcard-ex-cn">${escapeHtml(word.example.cn)}</div>
</div>` : ''}
</div>
</div>` : ''}
</div>
</div>
</div>
</div>
`;
}
export function toggleQuizFavorite(wordId) {
if (!toggleFavorite(wordId)) return false;
const faved = isFavorite(wordId);
const headerBtn = document.getElementById('quiz-fav-btn');
if (headerBtn) {
headerBtn.style.color = faved ? 'var(--warning)' : 'var(--text-muted)';
headerBtn.title = faved ? '取消收藏' : '收藏';
}
const detailBtn = document.getElementById('quiz-detail-fav-btn');
if (detailBtn) {
detailBtn.style.color = faved ? 'var(--warning)' : 'var(--text-muted)';
detailBtn.title = faved ? '取消收藏' : '收藏';
}
}
export function answerQuiz(optionIndex) {
const s = state.quizSession;
if (!s || s.answers[s.index] !== undefined) return;
const q = s.questions[s.index];
const isCorrect = optionIndex === q.answer;
if (q.wordId && !recordAndUpdateSchedule(q.wordId, isCorrect, 'quiz', s.mode, {
removeIncorrectQuizRecords: s.removeCorrectErrors
})) return;
s.answers[s.index] = optionIndex;
if (isCorrect) s.correct++;
renderPage('quiz');
}
export function nextQuizQuestion() {
const s = state.quizSession;
s.index++;
if (s.index >= s.questions.length) {
s.finished = true;
}
renderPage('quiz');
maybePrefetchNextQuizAudioWindow(s);
if (!s.finished && s.mode === 'en2zh') {
const q = s.questions[s.index];
speak(getQuizAudioText(q));
}
}
export function prevQuizQuestion() {
const s = state.quizSession;
if (!s || s.index <= 0) return;
s.index--;
renderPage('quiz');
maybePrefetchNextQuizAudioWindow(s);
if (s.mode === 'en2zh') {
const q = s.questions[s.index];
speak(getQuizAudioText(q));
}
}
export function renderQuizResults(el) {
const s = state.quizSession;
const total = s.questions.length;
const pct = Math.round(s.correct / total * 100);
const elapsed = Math.round((Date.now() - s.startTime) / 1000);
const mins = Math.floor(elapsed / 60);
const secs = elapsed % 60;
const circumference = 2 * Math.PI * 70;
const dashOffset = circumference * (1 - pct / 100);
const ringColor = pct >= 80 ? 'var(--success)' : pct >= 60 ? 'var(--warning)' : 'var(--error)';
el.innerHTML = `
<div class="page-header">
<div>
<h1>测试结果</h1>
<p class="page-desc"> ${total} 用时 ${mins}:${secs.toString().padStart(2, '0')} ${s.isAI ? '· <i class="fas fa-robot"></i> AI 生成' : '· <i class="fas fa-database"></i> 本地题库'}</p>
</div>
</div>
<div class="quiz-results">
<div class="card">
<div class="quiz-ring-chart">
<svg viewBox="0 0 160 160">
<circle class="ring-bg" cx="80" cy="80" r="70" />
<circle class="ring-fill" cx="80" cy="80" r="70" stroke="${ringColor}" stroke-dasharray="${circumference}" stroke-dashoffset="${circumference}" data-target-offset="${dashOffset}" />
</svg>
<div class="ring-text">
<span class="ring-value" style="color:${ringColor}">${pct}%</span>
<span class="ring-label">正确率</span>
</div>
</div>
<h2 style="font-size:24px;font-weight:700;margin-bottom:24px">
${pct >= 80 ? '<i class="fas fa-trophy" style="color:var(--warning);margin-right:6px"></i> 优秀!'
: pct >= 60 ? '<i class="fas fa-thumbs-up" style="color:var(--warning);margin-right:6px"></i> 不错!'
: '<i class="fas fa-redo" style="color:var(--error);margin-right:6px"></i> 继续努力!'}
</h2>
<div class="quiz-result-stats">
<div class="quiz-result-stat">
<div class="value" style="color:var(--success)">${s.correct}</div>
<div class="label">正确</div>
</div>
<div class="quiz-result-stat">
<div class="value" style="color:var(--error)">${total - s.correct}</div>
<div class="label">错误</div>
</div>
<div class="quiz-result-stat">
<div class="value">${mins}:${secs.toString().padStart(2, '0')}</div>
<div class="label">用时</div>
</div>
</div>
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap">
<button class="btn btn-primary" data-action="quiz.restartQuiz"><i class="fas fa-redo"></i> </button>
<button class="btn btn-secondary" data-action="quiz.navigate" data-arg0="home"><i class="fas fa-home"></i> </button>
</div>
</div>
</div>
`;
requestAnimationFrame(() => {
const ring = el.querySelector('.ring-fill');
if (ring) ring.setAttribute('stroke-dashoffset', ring.dataset.targetOffset);
});
}
export function selectQuizMode(btn, mode) {
document.querySelectorAll('.mode-toggle-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.getElementById('quiz-mode-select').value = mode;
state.quizMode = mode;
}
export function endQuiz() {
state.quizSession = null;
renderPage('quiz');
}
export function restartQuiz() {
state.quizSession = null;
renderPage('quiz');
}
+373
View File
@@ -0,0 +1,373 @@
import { state } from '../core/state.js';
import { getFavoriteWords } from '../services/favorites.js';
import { renderPage } from '../core/router.js';
import { showToast } from '../ui/toast.js';
import { speak } from '../services/tts.js';
import { generateAIQuiz, getAiCooldownRemaining } from '../services/ai.js';
import { getQuizErrorWords } from '../services/stats.js';
import { generateLocalQuiz, shuffle } from '../services/quiz-generator.js';
import {
createQuizAudioPrefetchState,
getQuizAudioText,
prefetchQuizAudioWindow
} from './quiz-audio.js';
import { renderQuizQuestion, renderQuizResults } from './quiz-session.js';
export {
createQuizAudioPrefetchState,
getQuizAudioText,
getQuizAudioTexts,
maybePrefetchNextQuizAudioWindow,
prefetchQuizAudioWindow
} from './quiz-audio.js';
export {
answerQuiz,
endQuiz,
nextQuizQuestion,
prevQuizQuestion,
renderQuizQuestion,
renderQuizResults,
restartQuiz,
selectQuizMode,
toggleQuizFavorite
} from './quiz-session.js';
let quizGeneration = 0;
function isCurrentQuizGeneration(generation, libraryId) {
return generation === quizGeneration
&& state.currentPage === 'quiz'
&& state.activeWordLibrary === libraryId;
}
// ==================== Batch Helpers ====================
export function getWordBatches(batchSize) {
const batches = [];
for (let i = 0; i < state.words.length; i += batchSize) {
const end = Math.min(i + batchSize, state.words.length);
batches.push({
index: batches.length,
start: i,
end,
label: `${batches.length + 1}`,
range: `${i + 1}-${end}`,
words: state.words.slice(i, end)
});
}
return batches;
}
export function getBatchWords(batchSize, batchIndex) {
if (batchIndex < 0) return [...state.words];
const start = batchIndex * batchSize;
return state.words.slice(start, start + batchSize);
}
export function onQuizBatchSizeChange(size) {
state.quizBatchSize = size;
state.quizBatchIndex = -1;
rerenderQuizSetup();
}
export function selectQuizBatch(index) {
state.quizBatchIndex = index;
rerenderQuizSetup();
}
export function rerenderQuizSetup() {
const el = document.getElementById('page-content');
if (el && state.currentPage === 'quiz' && !(state.quizSession && state.quizSession.active)) {
renderQuiz(el);
} else {
renderPage('quiz');
}
}
// ==================== Page: Quiz ====================
export function renderQuiz(el) {
quizGeneration++;
if (state.words.length < 4) {
el.innerHTML = `
<div class="page-header">
<div>
<h1>AI 测试</h1>
<p class="page-desc">通过选择题测试你对单词的掌握程度</p>
</div>
</div>
<div class="empty-state">
<div class="empty-icon"><i class="fas fa-brain"></i></div>
<h3>单词不足</h3>
<p>至少需要 4 个单词才能开始测试</p>
<button class="btn btn-primary" data-action="quiz.navigate" data-arg0="words">去导入</button>
</div>`;
return;
}
if (state.quizSession && state.quizSession.active) {
if (state.quizSession.finished) {
renderQuizResults(el);
} else {
renderQuizQuestion(el);
}
return;
}
const hasAI = state.settings.apiKey && state.settings.apiUrl && state.settings.model;
const batchWords = getBatchWords(state.quizBatchSize, state.quizBatchIndex);
el.innerHTML = `
<div class="page-header">
<div>
<h1>AI 测试</h1>
<p class="page-desc">通过选择题测试你对单词的掌握程度</p>
</div>
</div>
<div class="quiz-setup-layout">
<div class="card quiz-setup-left">
<h3><i class="fas fa-sliders-h"></i> 测试设置</h3>
<div class="form-group">
<label>出题模式</label>
<div class="mode-toggle">
<button type="button" class="mode-toggle-btn ${state.quizMode === 'en2zh' ? 'active' : ''}" data-mode="en2zh" data-action="quiz.selectQuizMode" data-element-arg="0" data-arg1="en2zh">
<div class="mode-toggle-label">ENG → 中文</div>
<div class="mode-toggle-hint">看词选意</div>
</button>
<button type="button" class="mode-toggle-btn ${state.quizMode === 'zh2en' ? 'active' : ''}" data-mode="zh2en" data-action="quiz.selectQuizMode" data-element-arg="0" data-arg1="zh2en">
<div class="mode-toggle-label">中文 → ENG</div>
<div class="mode-toggle-hint">看意选词</div>
</button>
</div>
<input type="hidden" id="quiz-mode-select" value="${state.quizMode}">
</div>
<div class="form-group">
<label>题量</label>
<select id="quiz-count-select">
<option value="5">5 题</option>
<option value="10">10 题</option>
<option value="20">20 题</option>
<option value="all" selected>整组全部</option>
</select>
</div>
<div class="form-group">
<label>出题顺序</label>
<div class="setup-radio-group">
<label class="setup-radio"><input type="radio" name="quiz-order" value="random" checked> 随机乱序</label>
<label class="setup-radio"><input type="radio" name="quiz-order" value="order"> 按顺序</label>
</div>
</div>
<div class="setup-checkbox">
<label>
<input type="checkbox" id="use-ai" ${hasAI ? 'checked' : ''} ${!hasAI ? 'disabled' : ''}>
使用 AI 生成题目 ${!hasAI ? '<span style="color:var(--text-muted);font-size:12px">(请先配置AI)</span>' : ''}
</label>
</div>
${state.quizBatchIndex === -2 ? `
<div class="setup-checkbox" style="margin-bottom:16px">
<label>
<input type="checkbox" id="quiz-remove-correct-errors" ${state.quizRemoveCorrectErrors ? 'checked' : ''}>
错题答对移除 <span style="color:var(--text-muted);font-size:12px">(答对后从错题中删除)</span>
</label>
</div>` : ''}
<div style="padding:16px;background:var(--bg);border-radius:var(--radius-sm);border:1px solid var(--border);margin-bottom:20px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">
<span style="font-size:13px;color:var(--text-secondary)">当前范围</span>
<span style="font-size:13px;font-weight:700;color:${state.quizBatchIndex === -2 ? 'var(--error)' : state.quizBatchIndex === -3 ? 'var(--warning)' : 'var(--primary)'}">${getQuizPoolCount()} 个单词</span>
</div>
<div style="font-size:12px;color:var(--text-muted)">${getQuizPoolLabel()}</div>
</div>
<button class="btn btn-primary btn-lg" style="width:100%" data-action="quiz.startQuiz"><i class="fas fa-play"></i> 开始生成试卷</button>
</div>
<div class="card quiz-setup-right">
<h3><i class="fas fa-th"></i> 出题范围</h3>
${renderQuizBatchSelector()}
</div>
</div>
`;
}
export function renderQuizBatchSelector() {
const errorCount = getQuizErrorWords().length;
const favCount = getFavoriteWords().length;
const batches = getWordBatches(state.quizBatchSize);
const bi = state.quizBatchIndex;
const isError = bi === -2, isFav = bi === -3, isGroup = bi >= -1;
return `
<div class="form-group">
<label>快捷范围</label>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button type="button" class="batch-group-btn ${isError ? 'active' : ''}" style="flex:1;min-width:0;display:flex;align-items:center;gap:6px;justify-content:center;padding:10px 8px;${errorCount > 0 ? '' : 'opacity:0.45;cursor:not-allowed'}" ${errorCount > 0 ? 'data-action="quiz.selectQuizBatch" data-arg0="-2"' : ''}>
<i class="fas fa-redo-alt" style="color:${isError ? 'var(--primary)' : 'var(--error)'}"></i>
<span>错题测试</span>
<span class="badge ${isError ? 'badge-primary' : 'badge-error'}" style="font-size:11px;padding:2px 8px">${errorCount}</span>
</button>
<button type="button" class="batch-group-btn ${isFav ? 'active' : ''}" style="flex:1;min-width:0;display:flex;align-items:center;gap:6px;justify-content:center;padding:10px 8px;${favCount > 0 ? '' : 'opacity:0.45;cursor:not-allowed'}" ${favCount > 0 ? 'data-action="quiz.selectQuizBatch" data-arg0="-3"' : ''}>
<i class="fas fa-star" style="color:${isFav ? 'var(--primary)' : 'var(--warning)'}"></i>
<span>收藏测试</span>
<span class="badge ${isFav ? 'badge-primary' : 'badge-warning'}" style="font-size:11px;padding:2px 8px">${favCount}</span>
</button>
</div>
</div>
<div class="form-group" style="margin-top:16px">
<label>分组测试</label>
<select data-action="quiz.onQuizBatchSizeChange" data-action-event="change" data-value-arg="0" data-value-number="true" style="margin-bottom:10px">
<option value="50" ${state.quizBatchSize === 50 ? 'selected' : ''}>50 个 / 组</option>
<option value="100" ${state.quizBatchSize === 100 ? 'selected' : ''}>100 个 / 组</option>
</select>
<div class="batch-group-grid">
<button type="button" class="batch-group-btn ${bi === -1 ? 'active' : ''}" data-action="quiz.selectQuizBatch" data-arg0="-1">
全部<br><small>${state.words.length}词</small>
</button>
${batches.map(b => `
<button type="button" class="batch-group-btn ${bi === b.index ? 'active' : ''}" data-action="quiz.selectQuizBatch" data-arg0="${b.index}">
${b.label}<br><small>${b.range}</small>
</button>
`).join('')}
</div>
</div>`;
}
export function getQuizPoolCount() {
if (state.quizBatchIndex === -2) return getQuizErrorWords().length;
if (state.quizBatchIndex === -3) return getFavoriteWords().length;
return getBatchWords(state.quizBatchSize, state.quizBatchIndex).length;
}
export function getQuizPoolLabel() {
if (state.quizBatchIndex === -2) return '<i class="fas fa-redo-alt" style="margin-right:4px"></i>错题测试';
if (state.quizBatchIndex === -3) return '<i class="fas fa-star" style="margin-right:4px;color:var(--warning)"></i>收藏测试';
if (state.quizBatchIndex === -1) return '全部单词';
return '第' + (state.quizBatchIndex + 1) + '组';
}
export async function startQuiz() {
const mode = document.getElementById('quiz-mode-select').value;
const countVal = document.getElementById('quiz-count-select').value;
const quizOrder = document.querySelector('input[name="quiz-order"]:checked')?.value || 'random';
const useAI = document.getElementById('use-ai').checked;
const isErrorMode = state.quizBatchIndex === -2;
const removeCorrectEl = document.getElementById('quiz-remove-correct-errors');
state.quizRemoveCorrectErrors = isErrorMode && removeCorrectEl ? removeCorrectEl.checked : false;
let batchWords;
if (isErrorMode) {
batchWords = getQuizErrorWords();
} else if (state.quizBatchIndex === -3) {
batchWords = getFavoriteWords();
} else {
batchWords = getBatchWords(state.quizBatchSize, state.quizBatchIndex);
}
if (quizOrder === 'order') {
batchWords = [...batchWords].sort((a, b) => a.id - b.id);
}
const count = countVal === 'all' ? batchWords.length : parseInt(countVal);
if (batchWords.length < 4) {
const msg = isErrorMode ? '错题不足4个,无法生成测试'
: state.quizBatchIndex === -3 ? '收藏不足4个,无法生成测试'
: '所选范围内单词不足4个,无法生成测试';
showToast(msg, 'warning');
return;
}
const generation = ++quizGeneration;
const libraryId = state.activeWordLibrary;
let questions;
let lastQuizAI = false;
const aiCooldownRemaining = useAI ? getAiCooldownRemaining() : 0;
const aiCooling = aiCooldownRemaining > 0;
if (aiCooling) {
const secs = Math.ceil(aiCooldownRemaining / 1000);
showToast(`API 冷却中(${secs}秒),本次使用本地题库`, 'info');
}
if (useAI && !aiCooling) {
const el = document.getElementById('page-content');
el.innerHTML = `
<div class="page-header"><h1>AI 测试</h1></div>
<div class="loading"><div class="spinner"></div><span>AI 正在出题,请稍候...</span></div>`;
try {
const pool = (quizOrder === 'order' ? batchWords : shuffle(batchWords)).slice(0, Math.min(count * 3, batchWords.length));
const aiQuestions = await generateAIQuiz(pool, mode, count);
if (!isCurrentQuizGeneration(generation, libraryId)) return;
lastQuizAI = true;
showToast('AI 出题成功', 'success');
questions = aiQuestions.map((q, i) => {
const tagged = q.options.map((opt, idx) => ({
text: opt,
correct: idx === q.answer
}));
const shuffled = shuffle(tagged);
const matchedWord = mode === 'en2zh'
? (pool.find(w => w.english === q.question)
|| pool.find(w => q.question.toLowerCase().includes(`'${w.english.toLowerCase()}'`))
|| pool.find(w => q.question.toLowerCase().includes(w.english.toLowerCase())))
: (pool.find(w => w.chinese === q.question)
|| pool.find(w => q.question.includes(w.chinese))
|| pool.find(w => q.options.includes(w.english) && q.question.includes(w.chinese)));
const cleanQuestion = mode === 'zh2en' && matchedWord ? matchedWord.chinese : q.question;
return {
...q,
question: cleanQuestion,
options: shuffled.map(t => t.text),
answer: shuffled.findIndex(t => t.correct),
wordId: matchedWord?.id || null,
word: matchedWord || null,
phonetic: mode === 'en2zh' ? (matchedWord?.phonetic || '') : null,
displayWord: mode === 'en2zh' ? (matchedWord?.english || null) : null
};
});
} catch (e) {
if (!isCurrentQuizGeneration(generation, libraryId)) return;
lastQuizAI = false;
showToast('AI 出题失败,已切换本地题库:' + e.message, 'error');
questions = generateLocalQuiz(mode, count, batchWords, quizOrder);
}
} else {
questions = generateLocalQuiz(mode, count, batchWords, quizOrder);
}
if (!isCurrentQuizGeneration(generation, libraryId)) return;
if (!questions || questions.length === 0) {
showToast('生成题目失败', 'error');
return;
}
const audioPrefetch = mode === 'en2zh' ? createQuizAudioPrefetchState(questions) : null;
const sourceLabel = isErrorMode ? '错题测试' : state.quizBatchIndex === -3 ? '收藏测试' : state.quizBatchIndex === -1 ? '全部' : '第' + (state.quizBatchIndex + 1) + '组';
const modeLabel = mode === 'en2zh' ? 'ENG → 中文' : '中文 → ENG';
state.quizSession = {
active: true,
finished: false,
mode,
questions,
index: 0,
answers: [],
correct: 0,
startTime: Date.now(),
isErrorMode: isErrorMode,
removeCorrectErrors: state.quizRemoveCorrectErrors,
sourceLabel,
modeLabel,
isAI: lastQuizAI,
audioPrefetch
};
renderPage('quiz');
prefetchQuizAudioWindow(state.quizSession, 0);
if (mode === 'en2zh' && questions.length > 0) {
const q = questions[0];
speak(getQuizAudioText(q));
}
}
+371
View File
@@ -0,0 +1,371 @@
import { STOP_WORDS } from '../data/stopwords.js';
import { state } from '../core/state.js';
import { saveSchedule, saveWords } from '../core/storage.js';
import { showToast } from '../ui/toast.js';
import { speak } from '../services/tts.js';
import { callAI, translateWord } from '../services/ai.js';
import { initWordSchedule } from '../services/ebbinghaus.js';
import { getMastery } from '../services/stats.js';
import { escapeHtml, formatFormsHtml } from '../ui/dom.js';
import { findWordContext } from '../services/extractor.js';
import { getNextId } from './words.js';
import { beginMailLearnRequest, isCurrentMailLearnRequest } from './mail-learn.js';
export let readerTranslations = {};
export let readerSentences = [];
let readerSessionGeneration = 0;
// ==================== Full-Text Reading Mode ====================
export function splitSentences(text) {
const raw = text.split(/(?<=[.!?])\s+|(?:\r?\n){2,}/);
return raw.map(s => s.trim()).filter(s => s.length > 0);
}
export function isStopWord(w) { return STOP_WORDS.has(w.toLowerCase()); }
export function buildReadingSentenceHtml(sentence) {
const knownMap = {};
state.words.forEach(w => { knownMap[w.english.toLowerCase()] = w; });
const tokens = sentence.split(/(\s+|[,.;:!?'"()\[\]{}<>\/\\—–\-])/);
let html = '';
tokens.forEach(tok => {
const clean = tok.toLowerCase().replace(/[^a-z'-]/g, '');
const word = knownMap[clean];
if (word && clean.length >= 2) {
html += `<span class="reader-word known" data-word-id="${word.id}" data-action="reader.showReaderWordTip" data-element-arg="0" data-arg1="${word.id}" title="${escapeHtml(word.chinese || '')}">${escapeHtml(tok)}</span>`;
} else if (/^[a-zA-Z]{2,}$/.test(clean) && !isStopWord(clean)) {
html += `<span class="reader-word unknown" data-action="reader.showReaderUnknownTip" data-element-arg="0" data-arg1="${escapeHtml(clean)}" title="点击查看释义">${escapeHtml(tok)}</span>`;
} else {
html += escapeHtml(tok);
}
});
return html;
}
export async function startFullReading() {
const sessionGeneration = ++readerSessionGeneration;
const text = document.getElementById('mail-learn-input').value.trim();
if (!text) { showToast('请粘贴邮件内容', 'error'); return; }
const sentences = splitSentences(text);
if (!sentences.length) { showToast('无法解析邮件内容', 'error'); return; }
const resultEl = document.getElementById('mail-learn-result');
const request = beginMailLearnRequest(resultEl);
resultEl.innerHTML = '<div class="loading"><div class="spinner"></div> 正在准备全文阅读...</div>';
let translations = {};
const hasAI = state.settings.apiKey && state.settings.apiUrl && state.settings.model;
if (hasAI) {
try {
const numbered = sentences.map((s, i) => `${i + 1}. ${s}`).join('\n');
const content = await callAI([
{ role: 'system', content: '你是翻译助手。用户给出编号的英文句子,请逐句翻译为中文,严格保持编号格式返回。只返回翻译结果,每行格式:编号. 中文翻译' },
{ role: 'user', content: numbered }
]);
if (sessionGeneration !== readerSessionGeneration || !isCurrentMailLearnRequest(request)) return;
content.split('\n').forEach(line => {
const m = line.match(/^(\d+)\.\s*(.+)/);
if (m) translations[Number(m[1]) - 1] = m[2].trim();
});
} catch (err) {
if (sessionGeneration !== readerSessionGeneration || !isCurrentMailLearnRequest(request)) return;
showToast('AI 翻译失败,阅读模式仍可使用:' + err.message, 'warning');
}
}
if (sessionGeneration !== readerSessionGeneration || !isCurrentMailLearnRequest(request) || !resultEl.isConnected) return;
const knownCount = countKnownWordsInText(text);
const totalUniqueWords = countUniqueWords(text);
const coveragePct = totalUniqueWords > 0 ? Math.round(knownCount / totalUniqueWords * 100) : 0;
resultEl.innerHTML = `
<div class="card reader-card">
<div class="reader-header">
<h3><i class="fas fa-book-reader"></i> 全文阅读</h3>
<div class="reader-stats">
<span class="badge badge-primary">${sentences.length} 句</span>
<span class="badge badge-success">词库覆盖 ${coveragePct}%</span>
<button class="btn btn-sm btn-ghost" data-action="reader.toggleAllTranslations" title="显示/隐藏全部翻译"><i class="fas fa-eye"></i> 全部翻译</button>
</div>
</div>
<div class="reader-legend">
<span><span class="reader-dot known-dot"></span> 词库中的单词(点击查看详情)</span>
<span><span class="reader-dot unknown-dot"></span> 生词(点击查询)</span>
<span><i class="fas fa-hand-pointer" style="font-size:12px"></i> 点击句子查看翻译</span>
</div>
<div class="reader-body" id="reader-body">
${sentences.map((s, i) => `
<div class="reader-sentence-block" data-idx="${i}">
<div class="reader-sentence-num">${i + 1}</div>
<div class="reader-sentence-content">
<div class="reader-sentence-en" data-action="reader.toggleSentenceTranslation" data-arg0="${i}">${buildReadingSentenceHtml(s)}</div>
<div class="reader-sentence-cn" id="reader-cn-${i}" style="display:none">
${translations[i] ? escapeHtml(translations[i]) : '<span class="text-muted">点击上方英文句子查看翻译</span>'}
</div>
<div class="reader-sentence-tools">
<button class="btn btn-sm btn-ghost" data-action="reader.speakSentence" data-arg0="${i}" title="朗读"><i class="fas fa-volume-up"></i></button>
<button class="btn btn-sm btn-ghost" data-action="reader.toggleSentenceTranslation" data-arg0="${i}" title="翻译"><i class="fas fa-language"></i></button>
${!translations[i] && hasAI ? `<button class="btn btn-sm btn-ghost" id="ai-translate-btn-${i}" data-action="reader.aiTranslateSentence" data-arg0="${i}" data-element-arg="1" title="AI翻译"><i class="fas fa-magic"></i></button>` : ''}
</div>
</div>
</div>`).join('')}
</div>
</div>
<div id="reader-word-tooltip" class="reader-tooltip" style="display:none"></div>
`;
readerTranslations = translations;
readerSentences = sentences;
}
export function countKnownWordsInText(text) {
const knownSet = new Set(state.words.map(w => w.english.toLowerCase()));
const tokens = text.match(/\b[a-zA-Z]{2,}\b/g) || [];
const unique = new Set(tokens.map(t => t.toLowerCase()));
let count = 0;
unique.forEach(w => { if (!STOP_WORDS.has(w) && knownSet.has(w)) count++; });
return count;
}
export function countUniqueWords(text) {
const tokens = text.match(/\b[a-zA-Z]{2,}\b/g) || [];
const unique = new Set(tokens.map(t => t.toLowerCase()));
let count = 0;
unique.forEach(w => { if (!STOP_WORDS.has(w)) count++; });
return count;
}
export function speakSentence(idx) {
const sentences = readerSentences;
if (sentences && sentences[idx]) speak(sentences[idx]);
}
export function toggleSentenceTranslation(idx) {
const el = document.getElementById(`reader-cn-${idx}`);
if (!el) return;
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
export function toggleAllTranslations() {
const els = document.querySelectorAll('[id^="reader-cn-"]');
const anyHidden = [...els].some(e => e.style.display === 'none');
els.forEach(e => { e.style.display = anyHidden ? 'block' : 'none'; });
}
export async function aiTranslateSentence(idx, btnEl) {
const sessionGeneration = readerSessionGeneration;
const sentence = readerSentences?.[idx];
if (!sentence) return;
const cnEl = document.getElementById(`reader-cn-${idx}`);
if (!cnEl) return;
if (btnEl) btnEl.disabled = true;
const isCurrentSentence = () => sessionGeneration === readerSessionGeneration
&& readerSentences?.[idx] === sentence
&& cnEl.isConnected
&& (!btnEl || btnEl.isConnected);
try {
const content = await callAI([
{ role: 'system', content: '你是翻译助手,只返回中文翻译结果,不要任何解释。' },
{ role: 'user', content: `翻译为中文:${sentence}` }
]);
if (!isCurrentSentence()) return;
const translation = content.trim();
cnEl.textContent = translation;
cnEl.style.display = 'block';
readerTranslations[idx] = translation;
if (btnEl) btnEl.remove();
} catch (err) {
if (!isCurrentSentence()) return;
showToast('翻译失败: ' + err.message, 'error');
if (btnEl) btnEl.disabled = false;
}
}
export function positionReaderTooltip(tip, el) {
const margin = 8;
const gap = 6;
const rect = el.getBoundingClientRect();
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const tipWidth = tip.offsetWidth;
const tipHeight = tip.offsetHeight;
const left = Math.min(
Math.max(rect.left, margin),
Math.max(margin, window.innerWidth - tipWidth - margin)
);
const top = rect.bottom + gap + tipHeight <= window.innerHeight
? rect.bottom + scrollTop + gap
: Math.max(scrollTop + margin, rect.top + scrollTop - tipHeight - gap);
tip.style.left = `${left}px`;
tip.style.top = `${top}px`;
}
let readerTipCloseHandler = null;
let readerTipCloseTimer = null;
let readerTipSeq = 0;
function bindReaderTipClose(tip, anchorEl) {
if (readerTipCloseTimer !== null) {
clearTimeout(readerTipCloseTimer);
readerTipCloseTimer = null;
}
if (readerTipCloseHandler) {
document.removeEventListener('click', readerTipCloseHandler);
readerTipCloseHandler = null;
}
const handler = (e) => {
if (!tip.contains(e.target) && e.target !== anchorEl) {
++readerTipSeq;
tip.style.display = 'none';
document.removeEventListener('click', handler);
if (readerTipCloseHandler === handler) readerTipCloseHandler = null;
}
};
readerTipCloseHandler = handler;
readerTipCloseTimer = setTimeout(() => {
readerTipCloseTimer = null;
if (readerTipCloseHandler === handler) {
document.addEventListener('click', handler);
}
}, 10);
}
export function showReaderWordTip(el, wordId) {
++readerTipSeq;
const word = state.words.find(w => w.id === wordId);
if (!word) return;
const tip = document.getElementById('reader-word-tooltip');
if (!tip) return;
const mastery = getMastery(word.id);
const masteryText = mastery === 'mastered' ? '已掌握' : mastery === 'learning' ? '学习中' : '新词';
const badgeClass = mastery === 'mastered' ? 'badge-success' : mastery === 'learning' ? 'badge-warning' : 'badge-primary';
tip.innerHTML = `
<div class="reader-tip-header">
<strong>${escapeHtml(word.english)}</strong>
<span style="color:var(--text-muted);font-size:12px">${escapeHtml(word.phonetic || '')}</span>
<span class="badge ${badgeClass}" style="font-size:11px;padding:2px 6px">${masteryText}</span>
<button class="btn btn-sm btn-ghost" data-action="reader.speak" data-arg0="${escapeHtml(word.english)}"><i class="fas fa-volume-up"></i></button>
</div>
<div class="reader-tip-cn">${escapeHtml(word.chinese || '')}</div>
${word.forms ? `<div class="reader-tip-forms">${formatFormsHtml(word.forms)}</div>` : ''}
${word.example ? `<div class="reader-tip-example"><em>${escapeHtml(word.example.en)}</em><br><span>${escapeHtml(word.example.cn)}</span></div>` : ''}
`;
tip.style.display = 'block';
positionReaderTooltip(tip, el);
bindReaderTipClose(tip, el);
}
export async function showReaderUnknownTip(el, word) {
const tip = document.getElementById('reader-word-tooltip');
if (!tip) return;
const seq = ++readerTipSeq;
tip.innerHTML = `
<div class="reader-tip-header">
<strong>${escapeHtml(word)}</strong>
<button class="btn btn-sm btn-ghost" data-action="reader.speak" data-arg0="${escapeHtml(word)}"><i class="fas fa-volume-up"></i></button>
</div>
<div class="reader-tip-cn" style="color:var(--text-muted)">查询中...</div>
`;
tip.style.display = 'block';
positionReaderTooltip(tip, el);
bindReaderTipClose(tip, el);
const hasAI = state.settings.apiKey && state.settings.apiUrl && state.settings.model;
if (hasAI) {
try {
const result = await translateWord(word);
if (seq !== readerTipSeq) return;
tip.innerHTML = `
<div class="reader-tip-header">
<strong>${escapeHtml(result.english || word)}</strong>
<span style="color:var(--text-muted);font-size:12px">${escapeHtml(result.phonetic || '')}</span>
<button class="btn btn-sm btn-ghost" data-action="reader.speak" data-arg0="${escapeHtml(word)}"><i class="fas fa-volume-up"></i></button>
</div>
<div class="reader-tip-cn">${escapeHtml(result.chinese || '')}</div>
${result.forms ? `<div class="reader-tip-forms">${formatFormsHtml(result.forms)}</div>` : ''}
${result.example ? `<div class="reader-tip-example"><em>${escapeHtml(result.example.en)}</em><br><span>${escapeHtml(result.example.cn)}</span></div>` : ''}
<button class="btn btn-sm btn-primary" style="margin-top:8px;width:100%" data-action="reader.addWordFromReader" data-arg0="${escapeHtml(word)}" data-element-arg="1">
<i class="fas fa-plus"></i> 加入词库
</button>
`;
positionReaderTooltip(tip, el);
} catch (err) {
if (seq !== readerTipSeq) return;
const cnEl = tip.querySelector('.reader-tip-cn');
if (cnEl) cnEl.textContent = '查询失败: ' + err.message;
}
} else {
const cnEl = tip.querySelector('.reader-tip-cn');
if (cnEl) cnEl.textContent = '请先配置 AI 设置以启用翻译';
}
}
export async function addWordFromReader(word, btnEl) {
const key = String(word || '').trim().toLowerCase();
const libraryId = state.activeWordLibrary;
const sessionGeneration = readerSessionGeneration;
const wordExists = () => state.words.some(item => String(item.english || '').trim().toLowerCase() === key);
const isCurrentOperation = () => state.activeWordLibrary === libraryId
&& readerSessionGeneration === sessionGeneration
&& (!btnEl || btnEl.isConnected);
if (!key) return false;
if (wordExists()) {
showToast(`${word}」已在词库中`, 'info');
if (btnEl) { btnEl.disabled = true; btnEl.textContent = '已添加 ✓'; }
return true;
}
if (btnEl) { btnEl.disabled = true; btnEl.textContent = '添加中...'; }
try {
const result = await translateWord(word);
if (!isCurrentOperation()) return false;
if (wordExists()) {
showToast(`${word}」已在词库中`, 'info');
if (btnEl) { btnEl.textContent = '已添加 ✓'; btnEl.classList.remove('btn-primary'); btnEl.classList.add('btn-success'); }
return true;
}
const emailText = document.getElementById('mail-learn-input')?.value || '';
const contextSentence = findWordContext(word, emailText);
const emailTitle = emailText.slice(0, 40).replace(/\n/g, ' ') + '...';
const previousWords = state.words.slice();
const previousSchedule = { ...state.schedule };
const id = getNextId();
state.words.push({
id,
...result,
sourceContext: contextSentence ? { emailTitle, sentence: contextSentence } : null
});
initWordSchedule(id, false);
if (!(saveWords() && saveSchedule())) {
state.words = previousWords;
state.schedule = previousSchedule;
saveWords();
saveSchedule();
throw new Error('本地存储写入失败');
}
showToast(`已添加「${word}」到词库`, 'success');
if (btnEl) { btnEl.textContent = '已添加 \u2713'; btnEl.classList.remove('btn-primary'); btnEl.classList.add('btn-success'); }
return true;
} catch (err) {
if (!isCurrentOperation()) return false;
showToast('添加失败: ' + err.message, 'error');
if (btnEl) { btnEl.disabled = false; btnEl.textContent = '重试'; }
return false;
}
}
+143
View File
@@ -0,0 +1,143 @@
import { state } from '../core/state.js';
import { navigate } from '../core/router.js';
import { showToast } from '../ui/toast.js';
import { getQuizErrorWords } from '../services/stats.js';
import { shuffle } from '../services/quiz-generator.js';
import { escapeHtml } from '../ui/dom.js';
import { renderLearnSession, startLearnSession } from './learn.js';
// ==================== Page: Review ====================
export function renderReview(el) {
if (state.words.length === 0) {
el.innerHTML = `
<div class="page-header">
<div>
<h1>错题复习</h1>
<p class="page-desc">针对测试中答错的单词进行专项复习</p>
</div>
</div>
<div class="empty-state">
<div class="empty-icon"><i class="fas fa-redo-alt"></i></div>
<h3>暂无单词</h3>
<p>请先导入单词</p>
<button class="btn btn-primary" data-action="review.navigate" data-arg0="words">去导入</button>
</div>`;
return;
}
if (state.learnSession && state.learnSession.active && state.learnSession.isReview) {
renderLearnSession(el);
return;
}
const errorWords = getQuizErrorWords();
const critical = errorWords.filter(w => w.correctRate < 60);
const moderate = errorWords.filter(w => w.correctRate >= 60 && w.correctRate < 80);
const easy = errorWords.filter(w => w.correctRate >= 80);
const hasQuizRecords = state.records.some(r => r.type === 'quiz');
el.innerHTML = `
<div class="page-header">
<div>
<h1>错题复习</h1>
<p class="page-desc">针对测试中答错的单词进行专项复习</p>
</div>
${errorWords.length > 0 ? `<button class="btn btn-primary" data-action="review.startReviewSession"><i class="fas fa-play"></i> 开始复习</button>` : ''}
</div>
${errorWords.length > 0 ? `
<div class="stat-grid" style="margin-bottom:24px">
<div class="stat-card" style="border-left:3px solid var(--primary)">
<div class="stat-icon" style="background:var(--primary-bg);color:var(--primary)"><i class="fas fa-list-ul"></i></div>
<div class="stat-info">
<div class="stat-value">${errorWords.length}</div>
<div class="stat-label">错题总数</div>
</div>
</div>
<div class="stat-card" style="border-left:3px solid var(--error)">
<div class="stat-icon" style="background:var(--error-light);color:var(--error)"><i class="fas fa-times-circle"></i></div>
<div class="stat-info">
<div class="stat-value">${critical.length}</div>
<div class="stat-label">需重点复习</div>
</div>
</div>
<div class="stat-card" style="border-left:3px solid var(--warning)">
<div class="stat-icon" style="background:var(--warning-light);color:var(--warning)"><i class="fas fa-exclamation-triangle"></i></div>
<div class="stat-info">
<div class="stat-value">${moderate.length}</div>
<div class="stat-label">需巩固</div>
</div>
</div>
<div class="stat-card" style="border-left:3px solid var(--success)">
<div class="stat-icon" style="background:var(--success-light);color:var(--success)"><i class="fas fa-check-circle"></i></div>
<div class="stat-info">
<div class="stat-value">${easy.length}</div>
<div class="stat-label">接近掌握</div>
</div>
</div>
</div>` : `
<div class="card" style="margin-bottom:24px">
${hasQuizRecords
? `<div class="empty-state" style="padding:32px 20px"><div class="empty-icon" style="color:var(--success)"><i class="fas fa-laugh-beam"></i></div><h3 style="color:var(--success)">太棒了,没有错题!</h3><p>继续保持,坚持练习</p></div>`
: `<div class="empty-state" style="padding:32px 20px"><div class="empty-icon"><i class="fas fa-robot"></i></div><h3>还没有测试记录</h3><p>请先进行 AI 测试,错题会自动收集到这里</p><button class="btn btn-primary" data-action="review.navigate" data-arg0="quiz" style="margin-top:4px"><i class="fas fa-robot"></i> 去测试</button></div>`
}
</div>`}
${errorWords.length > 0 ? `
<div class="card" style="margin-bottom:20px">
<h3><i class="fas fa-info-circle"></i> 复习说明</h3>
<p style="font-size:13px;color:var(--text-muted)">复习内容来自 AI 测试中答错的单词,按正确率从低到高排列。通过反复练习巩固薄弱环节。</p>
</div>
${critical.length > 0 ? `
<div class="card" style="margin-bottom:16px">
<h3><i class="fas fa-exclamation-circle" style="color:var(--error)"></i> 需重点复习(正确率 &lt; 60%</h3>
<div class="review-list">
${critical.map(w => renderReviewWordItem(w)).join('')}
</div>
</div>` : ''}
${moderate.length > 0 ? `
<div class="card" style="margin-bottom:16px">
<h3><i class="fas fa-exclamation-triangle" style="color:var(--warning)"></i> 需巩固(正确率 60-80%</h3>
<div class="review-list">
${moderate.map(w => renderReviewWordItem(w)).join('')}
</div>
</div>` : ''}
${easy.length > 0 ? `
<div class="card" style="margin-bottom:16px">
<h3><i class="fas fa-check-circle" style="color:var(--success)"></i> 接近掌握(正确率 ≥ 80%</h3>
<div class="review-list">
${easy.map(w => renderReviewWordItem(w)).join('')}
</div>
</div>` : ''}
` : ''}
`;
}
export function renderReviewWordItem(w) {
const stats = w.quizStats || {};
const total = (stats.correct || 0) + (stats.incorrect || 0);
const rate = w.correctRate != null ? w.correctRate : (total > 0 ? Math.round(stats.correct / total * 100) : '-');
const borderColor = rate < 60 ? 'var(--error)' : rate < 80 ? 'var(--warning)' : 'var(--success)';
return `
<div class="review-word-item" style="border-left-color:${borderColor}">
<div class="rw-en">${escapeHtml(w.english)}</div>
<div class="rw-zh">${escapeHtml(w.chinese)}</div>
<div class="rw-stage">正确 ${stats.correct || 0} 次 · 错误 ${stats.incorrect || 0} 次 · 正确率 ${rate}%</div>
</div>`;
}
export function startReviewSession() {
const errorWords = getQuizErrorWords();
if (errorWords.length === 0) {
showToast('没有需要复习的错题', 'info');
return;
}
startLearnSession(shuffle(errorWords));
}
+419
View File
@@ -0,0 +1,419 @@
import { EBBINGHAUS_INTERVALS, WORD_LIBRARIES } from '../constants.js';
import { state } from '../core/state.js';
import {
getLibraryStorageKey,
loadJsonSetting,
saveFavorites,
saveJsonSetting,
snapshotStorageItems,
restoreStorageItems,
STORAGE_KEYS
} from '../core/storage.js';
import { renderPage } from '../core/router.js';
import { closeModal, showModal } from '../ui/modal.js';
import { showToast } from '../ui/toast.js';
import { getToday } from '../services/ebbinghaus.js';
import { invalidateErrorWordsCache } from '../services/stats.js';
let backupData = null;
let backupReadGeneration = 0;
let backupReadPending = false;
export function exportAllData() {
const libraries = {};
WORD_LIBRARIES.forEach(library => {
libraries[library.id] = library.id === state.activeWordLibrary ? {
words: state.words,
records: state.records,
schedule: state.schedule,
favorites: state.favorites
} : {
words: loadJsonSetting(getLibraryStorageKey(STORAGE_KEYS.words, library.id), []),
records: loadJsonSetting(getLibraryStorageKey(STORAGE_KEYS.records, library.id), []),
schedule: loadJsonSetting(getLibraryStorageKey(STORAGE_KEYS.schedule, library.id), {}),
favorites: loadJsonSetting(getLibraryStorageKey(STORAGE_KEYS.favorites, library.id), [])
};
});
const data = {
words: state.words,
records: state.records,
schedule: state.schedule,
favorites: state.favorites,
emails: state.mailEmails,
activeWordLibrary: state.activeWordLibrary,
libraries,
settings: Object.fromEntries(Object.entries(state.settings).filter(([key]) => !['apiKey', 'ttsApiKey'].includes(key))),
exportDate: new Date().toISOString()
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ai-english-backup-${getToday()}.json`;
a.click();
URL.revokeObjectURL(url);
showToast('数据已导出', 'success');
}
export function showImportAllModal() {
backupData = null;
backupReadPending = false;
backupReadGeneration++;
showModal('<i class="fas fa-upload" style="color:var(--primary)"></i> 导入数据备份', `
<div class="form-group">
<label>选择备份文件</label>
<input type="file" accept=".json" id="import-backup-file" data-action="settings.handleImportBackup" data-action-event="change" data-element-arg="0">
<p class="hint">选择之前导出的备份文件</p>
</div>
`, `
<button class="btn btn-secondary" data-action="settings.closeModal">取消</button>
<button class="btn btn-primary" id="import-backup-btn" data-action="settings.doImportBackup" disabled>导入</button>
`);
}
export function handleImportBackup(input) {
const generation = ++backupReadGeneration;
backupData = null;
backupReadPending = false;
const importButton = document.getElementById('import-backup-btn');
if (importButton) importButton.disabled = true;
const file = input.files[0];
if (!file) return;
backupReadPending = true;
const reader = new FileReader();
reader.onload = e => {
if (generation !== backupReadGeneration) return;
backupReadPending = false;
backupData = typeof e.target.result === 'string' ? e.target.result : null;
if (importButton) importButton.disabled = !backupData;
};
reader.onerror = () => {
if (generation !== backupReadGeneration) return;
backupReadPending = false;
backupData = null;
if (importButton) importButton.disabled = true;
showToast('备份文件读取失败', 'error');
};
reader.readAsText(file);
}
function normalizeBackupCategory(value) {
return typeof value === 'string' ? value.trim().slice(0, 100) : '';
}
function normalizeBackupWords(value) {
if (!Array.isArray(value)) throw new Error('单词数据格式错误');
const usedIds = new Set();
return value.map((raw, index) => {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error(`${index + 1} 个单词格式错误`);
const english = typeof raw.english === 'string' ? raw.english.trim() : '';
const chinese = typeof raw.chinese === 'string' ? raw.chinese.trim() : '';
if (!english || !chinese) throw new Error(`${index + 1} 个单词缺少英文或释义`);
const numericId = Number(raw.id);
const id = Number.isSafeInteger(numericId) && numericId > 0 ? numericId : index + 1;
if (usedIds.has(id)) throw new Error(`单词 ID 重复:${id}`);
usedIds.add(id);
const word = { ...raw, id, english, chinese };
if (Object.prototype.hasOwnProperty.call(raw, 'category')) word.category = normalizeBackupCategory(raw.category);
if (typeof raw.phonetic === 'string') word.phonetic = raw.phonetic.slice(0, 200);
if (Number.isFinite(Number(raw.frequency))) word.frequency = Number(raw.frequency);
if (raw.forms && typeof raw.forms === 'object' && !Array.isArray(raw.forms)) {
word.forms = Object.fromEntries(Object.entries(raw.forms).slice(0, 30).map(([key, val]) => [
String(key).slice(0, 100),
Array.isArray(val) ? val.slice(0, 30).map(item => String(item).slice(0, 200)) : String(val).slice(0, 500)
]));
}
if (raw.example && typeof raw.example === 'object' && !Array.isArray(raw.example)) {
word.example = { en: String(raw.example.en || '').slice(0, 5000), cn: String(raw.example.cn || '').slice(0, 5000) };
}
if (raw.sourceContext && typeof raw.sourceContext === 'object' && !Array.isArray(raw.sourceContext)) {
word.sourceContext = {
emailTitle: String(raw.sourceContext.emailTitle || '').slice(0, 500),
sentence: String(raw.sourceContext.sentence || '').slice(0, 5000)
};
}
return word;
});
}
function normalizeBackupSchedule(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('复习计划格式错误');
const schedule = {};
Object.entries(value).forEach(([wordId, raw]) => {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return;
const numericWordId = Number(wordId);
if (!Number.isSafeInteger(numericWordId) || numericWordId <= 0) return;
const numericStage = Number(raw.stage);
const stage = Number.isFinite(numericStage)
? Math.min(EBBINGHAUS_INTERVALS.length - 1, Math.max(0, Math.trunc(numericStage)))
: 0;
const normalizeCount = count => {
const numericCount = Number(count);
return Number.isFinite(numericCount) ? Math.max(0, Math.trunc(numericCount)) : 0;
};
const nextReview = typeof raw.nextReview === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(raw.nextReview)
? raw.nextReview
: getToday();
schedule[numericWordId] = {
stage,
nextReview,
correctCount: normalizeCount(raw.correctCount),
incorrectCount: normalizeCount(raw.incorrectCount)
};
});
return schedule;
}
function normalizeBackupEmails(value) {
if (!Array.isArray(value)) throw new Error('邮件数据格式错误');
return value.map((raw, index) => {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error(`${index + 1} 封邮件格式错误`);
return {
...raw,
id: Number.isSafeInteger(Number(raw.id)) ? Number(raw.id) : index + 1,
title: String(raw.title || '').trim().slice(0, 500),
content: String(raw.content || '').slice(0, 200000),
date: String(raw.date || '').trim().slice(0, 100)
};
});
}
function normalizeBackupRecords(value) {
if (!Array.isArray(value)) throw new Error('学习记录格式错误');
return value.map((raw, index) => {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error(`${index + 1} 条学习记录格式错误`);
const wordId = Number(raw.wordId);
if (!Number.isSafeInteger(wordId) || wordId <= 0) throw new Error(`${index + 1} 条学习记录的单词 ID 无效`);
if (typeof raw.isCorrect !== 'boolean') throw new Error(`${index + 1} 条学习记录的答题结果无效`);
if (raw.type !== 'quiz' && raw.type !== 'review') throw new Error(`${index + 1} 条学习记录的类型无效`);
const time = Number(raw.time);
return {
...raw,
wordId,
date: typeof raw.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(raw.date) ? raw.date : getToday(),
time: Number.isFinite(time) && time >= 0 ? time : Date.now(),
isCorrect: raw.isCorrect,
type: raw.type,
quizMode: typeof raw.quizMode === 'string' ? raw.quizMode.slice(0, 100) : ''
};
});
}
function normalizeBackupFavorites(value) {
if (!Array.isArray(value)) throw new Error('收藏数据格式错误');
return [...new Set(value.map(Number).filter(id => Number.isSafeInteger(id) && id > 0))];
}
function normalizeBackupLibrary(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('词库数据格式错误');
return {
words: normalizeBackupWords(raw.words ?? []),
records: normalizeBackupRecords(raw.records ?? []),
schedule: normalizeBackupSchedule(raw.schedule ?? {}),
favorites: normalizeBackupFavorites(raw.favorites ?? [])
};
}
export function doImportBackup() {
if (backupReadPending) {
showToast('文件仍在读取中,请稍候', 'warning');
return;
}
if (!backupData) {
showToast('请选择文件', 'warning');
return;
}
const memorySnapshot = {
activeWordLibrary: state.activeWordLibrary,
words: state.words,
records: state.records,
schedule: state.schedule,
favorites: state.favorites,
mailEmails: state.mailEmails,
settings: state.settings
};
const storageSnapshot = new Map();
const rememberStorage = key => {
if (!storageSnapshot.has(key)) storageSnapshot.set(key, localStorage.getItem(key));
};
const persist = (key, value) => {
rememberStorage(key);
if (!saveJsonSetting(key, value)) throw new Error('本地存储写入失败');
};
try {
const data = JSON.parse(backupData);
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('备份数据格式错误');
const activeWordLibrary = WORD_LIBRARIES.some(library => library.id === data.activeWordLibrary)
? data.activeWordLibrary
: state.activeWordLibrary;
const libraries = {};
if (data.libraries != null) {
if (typeof data.libraries !== 'object' || Array.isArray(data.libraries)) throw new Error('词库备份格式错误');
WORD_LIBRARIES.forEach(library => {
if (Object.prototype.hasOwnProperty.call(data.libraries, library.id)) {
libraries[library.id] = normalizeBackupLibrary(data.libraries[library.id]);
}
});
}
let activeLibrary = libraries[activeWordLibrary] || {
words: state.words,
records: state.records,
schedule: state.schedule,
favorites: state.favorites
};
activeLibrary = {
words: data.words == null ? activeLibrary.words : normalizeBackupWords(data.words),
records: data.records == null ? normalizeBackupRecords(activeLibrary.records) : normalizeBackupRecords(data.records),
schedule: data.schedule == null ? normalizeBackupSchedule(activeLibrary.schedule) : normalizeBackupSchedule(data.schedule),
favorites: data.favorites == null ? normalizeBackupFavorites(activeLibrary.favorites) : normalizeBackupFavorites(data.favorites)
};
libraries[activeWordLibrary] = activeLibrary;
const emails = data.emails == null ? state.mailEmails : normalizeBackupEmails(data.emails);
const settings = { ...state.settings };
if (data.settings != null) {
if (!data.settings || typeof data.settings !== 'object' || Array.isArray(data.settings)) throw new Error('设置数据格式错误');
const stringSettings = ['apiUrl', 'model', 'ttsProvider', 'ttsEndpoint', 'ttsRegion', 'ttsVoice', 'ttsOutputFormat'];
stringSettings.forEach(key => {
if (typeof data.settings[key] === 'string') settings[key] = data.settings[key].trim().slice(0, 1000);
});
['ttsEnabled', 'ttsPrefetchEnabled'].forEach(key => {
if (typeof data.settings[key] === 'boolean') settings[key] = data.settings[key];
});
}
Object.entries(libraries).forEach(([libraryId, lib]) => {
persist(getLibraryStorageKey(STORAGE_KEYS.words, libraryId), lib.words);
persist(getLibraryStorageKey(STORAGE_KEYS.records, libraryId), lib.records);
persist(getLibraryStorageKey(STORAGE_KEYS.schedule, libraryId), lib.schedule);
persist(getLibraryStorageKey(STORAGE_KEYS.favorites, libraryId), lib.favorites);
});
persist(STORAGE_KEYS.mailEmails, emails);
persist(STORAGE_KEYS.settings, settings);
rememberStorage(STORAGE_KEYS.activeWordLibrary);
localStorage.setItem(STORAGE_KEYS.activeWordLibrary, activeWordLibrary);
state.activeWordLibrary = activeWordLibrary;
state.words = activeLibrary.words;
state.records = activeLibrary.records;
state.schedule = activeLibrary.schedule;
state.favorites = activeLibrary.favorites;
state.mailEmails = emails;
state.settings = settings;
invalidateErrorWordsCache();
closeModal();
state.learnSession = null;
state.quizSession = null;
showToast('数据恢复成功', 'success');
renderPage(state.currentPage);
} catch (e) {
state.activeWordLibrary = memorySnapshot.activeWordLibrary;
state.words = memorySnapshot.words;
state.records = memorySnapshot.records;
state.schedule = memorySnapshot.schedule;
state.favorites = memorySnapshot.favorites;
state.mailEmails = memorySnapshot.mailEmails;
state.settings = memorySnapshot.settings;
storageSnapshot.forEach((value, key) => {
try {
if (value == null) localStorage.removeItem(key);
else localStorage.setItem(key, value);
} catch (restoreError) {
console.error(`恢复本地存储失败:${key}`, restoreError);
}
});
showToast('导入失败:' + e.message, 'error');
}
}
export function clearAllData() {
if (!confirm('确定要清空所有数据吗?(所有词库的单词、学习记录、复习计划和保存邮件都将被删除,收藏单词将保留,此操作不可恢复!)')) return false;
if (!confirm('再次确认:所有词库的数据将永久删除且无法恢复(收藏单词保留),是否继续?')) return false;
const libraryKeys = WORD_LIBRARIES.flatMap(library => [
getLibraryStorageKey(STORAGE_KEYS.words, library.id),
getLibraryStorageKey(STORAGE_KEYS.records, library.id),
getLibraryStorageKey(STORAGE_KEYS.schedule, library.id),
getLibraryStorageKey(STORAGE_KEYS.favorites, library.id)
]);
const storageSnapshot = snapshotStorageItems([...libraryKeys, STORAGE_KEYS.mailEmails]);
if (!storageSnapshot) {
showToast('无法读取本地存储,清空操作已取消', 'error');
return false;
}
const memorySnapshot = {
words: state.words,
records: state.records,
schedule: state.schedule,
favorites: state.favorites,
mailEmails: state.mailEmails
};
const clearedLibraries = {};
try {
WORD_LIBRARIES.forEach(library => {
const words = library.id === state.activeWordLibrary
? state.words
: loadJsonSetting(getLibraryStorageKey(STORAGE_KEYS.words, library.id), []);
const favorites = library.id === state.activeWordLibrary
? state.favorites
: loadJsonSetting(getLibraryStorageKey(STORAGE_KEYS.favorites, library.id), []);
const favoriteIds = new Set(Array.isArray(favorites) ? favorites : []);
const keptWords = (Array.isArray(words) ? words : []).filter(word => favoriteIds.has(word.id));
const schedule = Object.fromEntries(keptWords.map(word => [word.id, {
stage: 0,
nextReview: getToday(),
correctCount: 0,
incorrectCount: 0
}]));
const cleared = { words: keptWords, records: [], schedule, favorites: keptWords.map(word => word.id) };
clearedLibraries[library.id] = cleared;
if (!saveJsonSetting(getLibraryStorageKey(STORAGE_KEYS.words, library.id), cleared.words)
|| !saveJsonSetting(getLibraryStorageKey(STORAGE_KEYS.records, library.id), cleared.records)
|| !saveJsonSetting(getLibraryStorageKey(STORAGE_KEYS.schedule, library.id), cleared.schedule)
|| !saveJsonSetting(getLibraryStorageKey(STORAGE_KEYS.favorites, library.id), cleared.favorites)) {
throw new Error('本地存储写入失败');
}
});
if (!saveJsonSetting(STORAGE_KEYS.mailEmails, [])) throw new Error('邮件数据清除失败');
} catch (error) {
restoreStorageItems(storageSnapshot);
Object.assign(state, memorySnapshot);
showToast('清空失败:' + error.message, 'error');
renderPage('settings');
return false;
}
const activeLibrary = clearedLibraries[state.activeWordLibrary];
state.words = activeLibrary.words;
state.records = activeLibrary.records;
state.schedule = activeLibrary.schedule;
state.favorites = activeLibrary.favorites;
state.mailEmails = [];
state.learnSession = null;
state.quizSession = null;
invalidateErrorWordsCache();
const keptCount = Object.values(clearedLibraries).reduce((count, library) => count + library.words.length, 0);
const keptMsg = keptCount > 0 ? `,保留了 ${keptCount} 个收藏单词` : '';
showToast('所有词库数据已清空' + keptMsg, 'info');
renderPage('settings');
return true;
}
export function clearFavorites() {
if (!confirm('确定要清除所有收藏单词吗?此操作不可恢复!')) return false;
const previousFavorites = state.favorites;
state.favorites = [];
if (!saveFavorites()) {
state.favorites = previousFavorites;
renderPage('settings');
return false;
}
showToast('收藏单词已清除', 'info');
renderPage('settings');
return true;
}
+405
View File
@@ -0,0 +1,405 @@
import { WORD_LIBRARIES } from '../constants.js';
import { COMMON_NAMES, STOP_WORDS } from '../data/stopwords.js';
import { state } from '../core/state.js';
import { saveJsonSetting, saveSettings, STORAGE_KEYS } from '../core/storage.js';
import { renderPage } from '../core/router.js';
import { showToast } from '../ui/toast.js';
import { clearCloudTtsCache, getTtsSettings, resetCloudTtsAuthBlock, sanitizeAzureSpeechKey, testCloudTtsConnection as testAzureTTSConnection } from '../services/tts.js';
import { callAI } from '../services/ai.js';
import { escapeHtml } from '../ui/dom.js';
export { toggleBatchExtract } from './extract.js';
export { showFilterWordsModal } from './filter-words.js';
export { manualLoadWords, switchWordLibrary, toggleAutoLoad } from '../services/library.js';
export { clearAllData, clearFavorites, doImportBackup, exportAllData, handleImportBackup, showImportAllModal } from './settings-data.js';
// ==================== Page: Settings ====================
export function renderSettings(el) {
const s = state.settings;
el.innerHTML = `
<div class="page-header">
<div>
<h1>设置</h1>
<p class="page-desc">配置 AI 模型接口与管理学习数据</p>
</div>
</div>
<div class="settings-layout">
<div class="card settings-card">
<h3><span class="icon-badge" style="color:#a855f7;background:rgba(168,85,247,0.1)"><i class="fas fa-robot"></i></span> AI 模型配置</h3>
<div class="form-group">
<label>API Key</label>
<input type="password" id="set-api-key" value="${escapeHtml(s.apiKey || '')}" placeholder="输入你的 API Key">
</div>
<div class="form-group">
<label>API URL</label>
<input type="text" id="set-api-url" value="${escapeHtml(s.apiUrl || 'https://tohub.com/v1')}" placeholder="https://tohub.com/v1">
<p class="hint">格式:https://your-api.com/v1</p>
</div>
<div class="form-group">
<label>Model</label>
<input type="text" id="set-model" value="${escapeHtml(s.model || 'gemini-3-flash')}" placeholder="gemini-3-flash">
</div>
<div class="btn-group settings-card-actions">
<button class="btn btn-primary" data-action="settings.doSaveSettings"><i class="fas fa-save"></i> 保存设置</button>
<button class="btn btn-secondary" data-action="settings.testAIConnection"><i class="fas fa-plug"></i> 测试连接</button>
</div>
</div>
<div class="card settings-card">
<h3><span class="icon-badge" style="color:#06b6d4;background:rgba(6,182,212,0.1)"><i class="fas fa-headphones"></i></span> TTS 语音配置</h3>
<p class="settings-card-desc">优先使用云端 TTS 朗读,失败后自动回退到有道发音。</p>
<div class="settings-row" style="margin-bottom:20px">
<div class="settings-row-main">
<div>
<div class="settings-row-title">启用云端 TTS</div>
<div class="settings-row-desc">开启后朗读会优先调用下面配置的 TTS 模型</div>
</div>
</div>
<div class="toggle-switch ${s.ttsEnabled !== false ? 'active' : ''}" data-action="settings.toggleTtsEnabled"><div class="toggle-knob"></div></div>
</div>
<div class="form-group">
<label>API Key</label>
<input type="password" id="set-tts-api-key" value="${escapeHtml(s.ttsApiKey || '')}" placeholder="输入 Azure Speech API 密钥">
</div>
<div class="form-group">
<label>Region</label>
<input type="text" id="set-tts-region" value="${escapeHtml(s.ttsRegion || 'eastus')}" placeholder="eastus">
<p class="hint">例如 eastus,需与 API Key 所属区域一致。</p>
</div>
<div class="form-group">
<label>Voice</label>
<select id="set-tts-voice">
${['en-US-JennyNeural', 'en-US-AvaNeural', 'en-US-GuyNeural', 'en-US-AriaNeural', 'en-GB-SoniaNeural', 'en-GB-RyanNeural'].map(v => `<option value="${v}" ${(s.ttsVoice || 'en-US-JennyNeural') === v ? 'selected' : ''}>${v}</option>`).join('')}
</select>
</div>
<div class="settings-row" style="margin-bottom:20px">
<div class="settings-row-main">
<div>
<div class="settings-row-title">预取 Azure TTS 音频</div>
<div class="settings-row-desc">提前生成 Azure 音频,减少播放等待,但会消耗更多请求额度</div>
</div>
</div>
<div class="toggle-switch ${s.ttsPrefetchEnabled === true ? 'active' : ''}" data-action="settings.toggleTtsPrefetchEnabled"><div class="toggle-knob"></div></div>
</div>
<div class="btn-group settings-card-actions">
<button class="btn btn-primary" data-action="settings.doSaveSettings"><i class="fas fa-save"></i> 保存 TTS 设置</button>
<button class="btn btn-secondary" data-action="settings.testTTSConnection"><i class="fas fa-volume-up"></i> 测试 TTS</button>
</div>
</div>
<div class="card settings-card full-width">
<h3><span class="icon-badge" style="color:#3b82f6;background:rgba(59,130,246,0.1)"><i class="fas fa-database"></i></span> 数据管理</h3>
<p class="settings-card-desc" style="margin-bottom:0">所有数据存储在浏览器本地,清除浏览器数据会丢失。建议定期导出备份。</p>
<div class="settings-stat-grid">
<div class="settings-stat-item">
<div class="settings-stat-value" style="color:var(--primary)">${state.words.length}</div>
<div class="settings-stat-label">单词</div>
</div>
<div class="settings-stat-item">
<div class="settings-stat-value" style="color:var(--success)">${state.records.length}</div>
<div class="settings-stat-label">学习记录</div>
</div>
<div class="settings-stat-item">
<div class="settings-stat-value" style="color:var(--warning)">${Object.keys(state.schedule).length}</div>
<div class="settings-stat-label">复习计划</div>
</div>
<div class="settings-stat-item">
<div class="settings-stat-value" style="color:var(--warning)">${state.favorites.length}</div>
<div class="settings-stat-label">收藏单词</div>
</div>
<div class="settings-stat-item">
<div class="settings-stat-value" style="color:var(--primary)">${state.mailEmails.length}</div>
<div class="settings-stat-label">保存邮件</div>
</div>
</div>
<div class="btn-group settings-card-actions">
<button class="btn btn-secondary" data-action="settings.exportAllData"><i class="fas fa-download"></i> 导出全部数据</button>
<button class="btn btn-secondary" data-action="settings.showImportAllModal"><i class="fas fa-upload"></i> 导入数据备份</button>
</div>
<div class="settings-divider">
<div class="btn-group">
<button class="btn btn-error" data-action="settings.clearAllData"><i class="fas fa-trash-alt"></i> 清空所有数据</button>
<button class="btn btn-secondary" data-action="settings.clearFavorites" style="color:var(--warning);border-color:var(--warning)"><i class="fas fa-star"></i> 清除收藏单词</button>
</div>
<p class="hint" style="margin-top:8px"><i class="fas fa-exclamation-triangle" style="color:var(--warning)"></i> 清空数据不可恢复,AI 配置和收藏单词将保留</p>
</div>
</div>
</div>
<div class="settings-layout" style="margin-top:24px">
<div class="card settings-card">
<h3><span class="icon-badge" style="color:#22c55e;background:rgba(34,197,94,0.1)"><i class="fas fa-sliders-h"></i></span> 词库与提词</h3>
<div class="settings-row" style="display:block">
<div class="settings-row-title" style="margin-bottom:6px">当前单词库</div>
<div class="settings-row-desc" style="margin-bottom:10px">切换后会使用所选词库,并为每个词库分别保留学习进度和收藏。</div>
<div class="word-library-picker">
<select id="word-library-select">
${WORD_LIBRARIES.map(library => `<option value="${library.id}" ${state.activeWordLibrary === library.id ? 'selected' : ''}>${escapeHtml(library.name)}</option>`).join('')}
</select>
<button class="btn btn-primary" data-action="settings.switchWordLibrary"><i class="fas fa-exchange-alt"></i> 切换词库</button>
</div>
</div>
<div class="settings-row">
<div class="settings-row-main">
<div class="toggle-switch ${state.autoLoadEnabled ? 'active' : ''}" data-action="settings.toggleAutoLoad" data-arg0="${!state.autoLoadEnabled}">
<div class="toggle-knob"></div>
</div>
<div>
<div class="settings-row-title">启动时自动加载内置词库</div>
<div class="settings-row-desc">若词库为空,自动加载 data/ 目录下的单词文件</div>
</div>
</div>
<button class="btn btn-sm btn-secondary" data-action="settings.manualLoadWords" title="手动加载"><i class="fas fa-sync-alt"></i></button>
</div>
<div class="settings-row">
<div class="settings-row-main">
<div class="toggle-switch ${state.batchExtractEnabled ? 'active' : ''}" data-action="settings.toggleBatchExtract" data-arg0="${!state.batchExtractEnabled}">
<div class="toggle-knob"></div>
</div>
<div>
<div class="settings-row-title">邮件批量提词</div>
<div class="settings-row-desc">在提词页面显示批量提取功能,支持上传文件并统计词频</div>
</div>
</div>
</div>
<div class="settings-divider">
<div class="settings-row-title" style="margin-bottom:8px"><i class="fas fa-filter" style="color:var(--error);margin-right:4px"></i> 过滤词管理</div>
<p class="settings-row-desc" style="margin-bottom:10px">管理提词时自动排除的停用词和常见人名</p>
<div class="btn-group">
<button class="btn btn-sm btn-secondary" data-action="settings.showFilterWordsModal" data-arg0="stop"><i class="fas fa-ban"></i> 停用词 (${STOP_WORDS.size})</button>
<button class="btn btn-sm btn-secondary" data-action="settings.showFilterWordsModal" data-arg0="names"><i class="fas fa-user-slash"></i> 常见人名 (${COMMON_NAMES.size})</button>
</div>
</div>
</div>
<div class="card settings-card">
<h3><span class="icon-badge" style="color:#f59e0b;background:rgba(245,158,11,0.1)"><i class="fas fa-volume-up"></i></span> 自动播放设置</h3>
<p class="settings-card-desc">配置单词库自动播放时的朗读次数和间隔时间</p>
<div class="settings-row" style="display:block">
<div class="settings-row-title" style="margin-bottom:10px">每个单词朗读次数</div>
<div class="btn-group" style="margin-top:0">
<button class="btn btn-sm ${state.autoPlayRepeat === 1 ? 'btn-primary' : 'btn-secondary'}" data-action="settings.setAutoPlayRepeat" data-arg0="1">1 次</button>
<button class="btn btn-sm ${state.autoPlayRepeat === 2 ? 'btn-primary' : 'btn-secondary'}" data-action="settings.setAutoPlayRepeat" data-arg0="2">2 次</button>
<button class="btn btn-sm ${state.autoPlayRepeat === 3 ? 'btn-primary' : 'btn-secondary'}" data-action="settings.setAutoPlayRepeat" data-arg0="3">3 次</button>
</div>
</div>
<div id="repeat-delay-setting" class="settings-row" style="display:${state.autoPlayRepeat <= 1 ? 'none' : 'block'};margin-top:12px">
<div class="settings-row-title" style="margin-bottom:6px">重复朗读间隔</div>
<div class="settings-row-desc" style="margin-bottom:8px">同一单词多次朗读之间的间隔时间</div>
<div class="settings-input-row">
<input type="number" id="set-repeat-delay" value="${state.autoPlayRepeatDelay || ''}" placeholder="默认 1000" min="300" max="5000" step="100">
<span class="unit">毫秒(留空使用默认 1 秒)</span>
</div>
</div>
<div class="settings-row" style="display:block;margin-top:12px">
<div class="settings-row-title" style="margin-bottom:6px">单词切换间隔</div>
<div class="settings-row-desc" style="margin-bottom:8px">自动播放时切换到下一个单词的间隔时间</div>
<div class="settings-input-row">
<input type="number" id="set-word-interval" value="${state.autoPlayInterval || ''}" placeholder="默认 2500" min="500" max="10000" step="100">
<span class="unit">毫秒(留空使用默认 2.5 秒)</span>
</div>
</div>
<div class="settings-row" style="display:block;margin-top:12px">
<div class="settings-row-title" style="margin-bottom:6px">播放时滚动方式</div>
<div class="settings-row-desc" style="margin-bottom:8px">自动播放切换单词时,页面滚动行为</div>
<div class="btn-group" style="margin-top:0">
<button class="btn btn-sm ${state.autoPlayScrollMode === 'center' ? 'btn-primary' : 'btn-secondary'}" data-action="settings.setAutoPlayScrollMode" data-arg0="center">始终居中</button>
<button class="btn btn-sm ${state.autoPlayScrollMode === 'visible' ? 'btn-primary' : 'btn-secondary'}" data-action="settings.setAutoPlayScrollMode" data-arg0="visible">不在视口内时才滚动</button>
</div>
</div>
<div class="btn-group settings-card-actions">
<button class="btn btn-sm btn-primary" data-action="settings.saveAutoPlaySettings"><i class="fas fa-save"></i> 保存播放设置</button>
</div>
</div>
<div class="card settings-card full-width">
<h3><span class="icon-badge" style="color:#ef4444;background:rgba(239,68,68,0.1)"><i class="fas fa-cloud-download-alt"></i></span> 应用更新</h3>
<p class="settings-card-desc">如果 iPhone Safari 仍显示旧版本,可重新获取最新页面和程序文件。单词、学习记录、收藏及设置不会被清除。</p>
<div class="btn-group settings-card-actions">
<button class="btn btn-secondary" id="force-refresh-btn" data-action="settings.forceRefreshBrowserCache"><i class="fas fa-sync-alt"></i> 获取最新版本</button>
</div>
</div>
</div>
`;
}
export async function forceRefreshBrowserCache() {
if (!confirm('确定获取最新版本并重新加载吗?本地学习数据和设置不会被删除。')) return;
const button = document.getElementById('force-refresh-btn');
if (button) {
button.disabled = true;
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 正在获取最新版本...';
}
try {
// 当前无 Service WorkerCache Storage 通常为空;此处为将来引入 SW 时的前瞻兼容。
// 真正的缓存绕过依赖下方的时间戳 URL + 静态资源 ?v= 版本号。
if ('caches' in window) {
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map(cacheName => caches.delete(cacheName)));
}
} catch (error) {
console.warn('清理应用缓存失败,将继续重新加载:', error);
}
const url = new URL(window.location.href);
url.searchParams.set('_app_update', Date.now().toString());
window.location.replace(url.toString());
}
export function setAutoPlayRepeat(count) {
const previousValue = state.autoPlayRepeat;
state.autoPlayRepeat = count;
if (!saveJsonSetting(STORAGE_KEYS.autoPlayRepeat, count)) {
state.autoPlayRepeat = previousValue;
return false;
}
const delayEl = document.getElementById('repeat-delay-setting');
if (delayEl) delayEl.style.display = count <= 1 ? 'none' : '';
if (state.currentPage === 'settings') renderPage('settings');
return true;
}
export function setAutoPlayScrollMode(mode) {
const previousValue = state.autoPlayScrollMode;
state.autoPlayScrollMode = mode === 'visible' ? 'visible' : 'center';
if (!saveJsonSetting(STORAGE_KEYS.autoPlayScrollMode, state.autoPlayScrollMode)) {
state.autoPlayScrollMode = previousValue;
return false;
}
if (state.currentPage === 'settings') renderPage('settings');
return true;
}
export function saveAutoPlaySettings() {
const intervalVal = parseInt(document.getElementById('set-word-interval').value) || 0;
const repeatDelayVal = parseInt(document.getElementById('set-repeat-delay').value) || 0;
const previousInterval = state.autoPlayInterval;
const previousRepeatDelay = state.autoPlayRepeatDelay;
state.autoPlayInterval = intervalVal;
state.autoPlayRepeatDelay = repeatDelayVal;
const saved = saveJsonSetting(STORAGE_KEYS.autoPlayInterval, intervalVal)
&& saveJsonSetting(STORAGE_KEYS.autoPlayRepeatDelay, repeatDelayVal);
if (!saved) {
state.autoPlayInterval = previousInterval;
state.autoPlayRepeatDelay = previousRepeatDelay;
saveJsonSetting(STORAGE_KEYS.autoPlayInterval, previousInterval);
saveJsonSetting(STORAGE_KEYS.autoPlayRepeatDelay, previousRepeatDelay);
return false;
}
showToast('播放设置已保存', 'success');
return true;
}
function valueById(id, fallback = '') {
const el = document.getElementById(id);
return el ? el.value.trim() : fallback;
}
function syncSettingsFromForm() {
state.settings = {
...state.settings,
apiKey: valueById('set-api-key', state.settings.apiKey || ''),
apiUrl: valueById('set-api-url', state.settings.apiUrl || '').replace(/\/$/, ''),
model: valueById('set-model', state.settings.model || ''),
ttsProvider: 'azure',
ttsApiKey: sanitizeAzureSpeechKey(valueById('set-tts-api-key', state.settings.ttsApiKey || '')),
ttsEndpoint: '',
ttsRegion: valueById('set-tts-region', state.settings.ttsRegion || 'eastus').toLowerCase(),
ttsVoice: valueById('set-tts-voice', state.settings.ttsVoice || 'en-US-JennyNeural'),
ttsOutputFormat: 'audio-24khz-48kbitrate-mono-mp3',
ttsPrefetchEnabled: state.settings.ttsPrefetchEnabled === true
};
}
export function toggleTtsEnabled() {
const previousSettings = state.settings;
syncSettingsFromForm();
state.settings.ttsEnabled = state.settings.ttsEnabled === false;
resetCloudTtsAuthBlock();
clearCloudTtsCache();
if (!saveSettings()) {
state.settings = previousSettings;
return false;
}
renderPage('settings');
return true;
}
export function toggleTtsPrefetchEnabled() {
const previousSettings = state.settings;
syncSettingsFromForm();
state.settings.ttsPrefetchEnabled = state.settings.ttsPrefetchEnabled !== true;
if (!saveSettings()) {
state.settings = previousSettings;
return false;
}
renderPage('settings');
return true;
}
function persistSettings() {
const previousSettings = state.settings;
syncSettingsFromForm();
resetCloudTtsAuthBlock();
clearCloudTtsCache();
if (saveSettings()) return true;
state.settings = previousSettings;
return false;
}
export function doSaveSettings() {
if (!persistSettings()) return false;
showToast('设置已保存', 'success');
return true;
}
export async function testTTSConnection() {
if (!persistSettings()) return;
const cfg = getTtsSettings();
if (!cfg.enabled) {
showToast('请先启用云端 TTS', 'warning');
return;
}
if (!cfg.apiKey || !cfg.region || !cfg.voice) {
showToast('请填写 TTS API Key、Region 和 Voice', 'warning');
return;
}
showToast('正在测试 TTS...', 'info');
try {
const played = await testAzureTTSConnection();
showToast(played ? 'TTS 测试成功' : 'TTS 未播放:请检查配置或稍后重试', played ? 'success' : 'warning');
} catch (e) {
showToast('TTS 测试失败:' + e.message, 'error');
}
}
export async function testAIConnection() {
if (!doSaveSettings()) return;
if (!state.settings.apiKey || !state.settings.apiUrl || !state.settings.model) {
showToast('请填写所有配置项', 'warning');
return;
}
showToast('正在测试连接...', 'info');
try {
const reply = await callAI([
{ role: 'user', content: '请回复"连接成功"四个字' }
]);
showToast('连接成功!AI 回复:' + reply.slice(0, 50), 'success');
} catch (e) {
showToast('连接失败:' + e.message, 'error');
}
}
+216
View File
@@ -0,0 +1,216 @@
import { state } from '../core/state.js';
import { getToday } from '../services/ebbinghaus.js';
import { getErrorTopWords, getLast30DaysData, getStats, getStreak } from '../services/stats.js';
import { escapeHtml } from '../ui/dom.js';
let studyChart = null;
// ==================== Page: Stats ====================
export function renderStats(el) {
destroyStudyChart();
if (state.records.length === 0 && state.words.length === 0) {
el.innerHTML = `
<div class="page-header">
<div>
<h1>学习统计</h1>
<p class="page-desc">查看你的学习进度与数据分析</p>
</div>
</div>
<div class="empty-state">
<div class="empty-icon"><i class="fas fa-chart-line"></i></div>
<h3>暂无数据</h3>
<p>开始学习后这里会显示你的统计数据</p>
</div>`;
return;
}
const s = getStats();
const streak = getStreak();
const todayRecords = state.records.filter(r => r.date === getToday());
const todayCorrect = todayRecords.filter(r => r.isCorrect).length;
const todayTotal = todayRecords.length;
const todayRate = todayTotal > 0 ? Math.round(todayCorrect / todayTotal * 100) : 0;
const allCorrect = state.records.filter(r => r.isCorrect).length;
const allTotal = state.records.length;
const allRate = allTotal > 0 ? Math.round(allCorrect / allTotal * 100) : 0;
const dailyData = getLast30DaysData();
const errorTop = getErrorTopWords(10);
el.innerHTML = `
<div class="page-header">
<div>
<h1>学习统计</h1>
<p class="page-desc">查看你的学习进度与数据分析</p>
</div>
</div>
<div class="stats-body">
<div class="stats-left">
<div class="stats-left-cards">
<div class="stat-card" style="border-left:3px solid var(--primary)">
<div class="stat-icon" style="background:var(--primary-bg);color:var(--primary)"><i class="fas fa-database"></i></div>
<div class="stat-info">
<div class="stat-value">${s.total}</div>
<div class="stat-label">总单词</div>
</div>
</div>
<div class="stat-card" style="border-left:3px solid var(--success)">
<div class="stat-icon" style="background:var(--success-light);color:var(--success)"><i class="fas fa-check-circle"></i></div>
<div class="stat-info">
<div class="stat-value">${s.mastered}</div>
<div class="stat-label">已掌握</div>
</div>
</div>
<div class="stat-card" style="border-left:3px solid var(--warning)">
<div class="stat-icon" style="background:var(--warning-light);color:var(--warning)"><i class="fas fa-fire"></i></div>
<div class="stat-info">
<div class="stat-value">${streak}</div>
<div class="stat-label">连续天数</div>
</div>
</div>
<div class="stat-card" style="border-left:3px solid var(--error)">
<div class="stat-icon" style="background:var(--error-light);color:var(--error)"><i class="fas fa-bullseye"></i></div>
<div class="stat-info">
<div class="stat-value">${allRate}%</div>
<div class="stat-label">总正确率</div>
</div>
</div>
<div class="stat-card" style="border-left:3px solid var(--primary)">
<div class="stat-icon" style="background:var(--primary-bg);color:var(--primary)"><i class="fas fa-pen"></i></div>
<div class="stat-info">
<div class="stat-value">${todayTotal}</div>
<div class="stat-label">今日练习次数</div>
</div>
</div>
<div class="stat-card" style="border-left:3px solid var(--success)">
<div class="stat-icon" style="background:var(--success-light);color:var(--success)"><i class="fas fa-bullseye"></i></div>
<div class="stat-info">
<div class="stat-value">${todayRate}%</div>
<div class="stat-label">今日正确率</div>
</div>
</div>
</div>
<div class="card" style="flex:1">
<h3><i class="fas fa-chart-bar"></i> 近30天学习量</h3>
<div class="chart-container">
<canvas id="study-chart"></canvas>
</div>
</div>
</div>
<div class="card stats-error-card">
<h3><i class="fas fa-exclamation-triangle" style="color:var(--error)"></i> 最容易出错的单词 Top 10</h3>
${errorTop.length > 0 ? `
<div style="overflow-y:auto;flex:1">
<table class="error-table">
<thead><tr><th>单词</th><th>释义</th><th>正确率</th><th>次数</th></tr></thead>
<tbody>
${errorTop.map(w => {
const color = w.rate < 40 ? 'var(--error)' : w.rate < 70 ? 'var(--warning)' : 'var(--success)';
return `
<tr>
<td><strong>${escapeHtml(w.english)}</strong></td>
<td style="color:var(--text-secondary)">${escapeHtml(w.chinese)}</td>
<td>
<span style="color:${color};font-weight:600">${w.rate}%</span>
<div class="accuracy-bar"><div class="accuracy-fill" style="width:${w.rate}%;background:${color}"></div></div>
</td>
<td style="color:var(--text-muted);text-align:center">${w.total}</td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>` : '<div style="text-align:center;padding:40px 20px;color:var(--text-muted);flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center"><i class="fas fa-chart-bar" style="font-size:32px;margin-bottom:12px;opacity:0.4"></i><p>暂无测试数据</p><p style="font-size:12px;margin-top:4px">完成 AI 测试后,错误统计将在此展示</p></div>'}
</div>
</div>
<div class="card">
<h3><i class="fas fa-chart-pie"></i> 掌握程度分布</h3>
<div style="padding:16px 0">
<div style="display:flex;justify-content:space-between;align-items:center;font-size:13px;margin-bottom:6px">
<span style="color:var(--success);font-weight:600"><i class="fas fa-check-circle" style="margin-right:4px"></i> 已掌握</span><span style="font-weight:700">${s.mastered}</span>
</div>
<div class="progress-bar" style="height:10px;margin-bottom:16px">
<div class="progress-fill" style="width:${s.total ? s.mastered/s.total*100 : 0}%;background:var(--success)"></div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;font-size:13px;margin-bottom:6px">
<span style="color:var(--warning);font-weight:600"><i class="fas fa-spinner" style="margin-right:4px"></i> 学习中</span><span style="font-weight:700">${s.learning}</span>
</div>
<div class="progress-bar" style="height:10px;margin-bottom:16px">
<div class="progress-fill" style="width:${s.total ? s.learning/s.total*100 : 0}%;background:var(--warning)"></div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;font-size:13px;margin-bottom:6px">
<span style="color:var(--text-muted);font-weight:600"><i class="fas fa-plus-circle" style="margin-right:4px"></i> 新词</span><span style="font-weight:700">${s.newCount}</span>
</div>
<div class="progress-bar" style="height:10px">
<div class="progress-fill" style="width:${s.total ? s.newCount/s.total*100 : 0}%;background:var(--text-muted)"></div>
</div>
</div>
</div>
`;
renderStudyChart(dailyData);
}
export function destroyStudyChart() {
if (!studyChart) return;
studyChart.destroy();
studyChart = null;
}
export function refreshStudyChart() {
if (!document.getElementById('study-chart')) return;
renderStudyChart(getLast30DaysData());
}
export function renderStudyChart(dailyData) {
const ctx = document.getElementById('study-chart');
if (!ctx || typeof globalThis.Chart === 'undefined') return;
destroyStudyChart();
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
const gridColor = isDark ? 'rgba(148, 163, 184, 0.1)' : 'rgba(148, 163, 184, 0.15)';
const textColor = isDark ? '#94a3b8' : '#64748b';
studyChart = new globalThis.Chart(ctx, {
type: 'bar',
data: {
labels: dailyData.map(d => d.label),
datasets: [{
label: '每日学习量',
data: dailyData.map(d => d.count),
backgroundColor: 'rgba(99, 102, 241, 0.7)',
hoverBackgroundColor: 'rgba(99, 102, 241, 0.9)',
borderRadius: 4,
borderSkipped: false
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: isDark ? '#334155' : '#1e293b',
titleFont: { size: 12 },
bodyFont: { size: 13 },
padding: 10,
cornerRadius: 8
}
},
scales: {
y: {
beginAtZero: true,
grid: { color: gridColor },
ticks: { color: textColor, font: { size: 11 } }
},
x: {
grid: { display: false },
ticks: { color: textColor, font: { size: 10 }, maxRotation: 45 }
}
}
}
});
}
+106
View File
@@ -0,0 +1,106 @@
import { state } from '../core/state.js';
import { logTts, prefetchAudio, speak, stopAllAudio } from '../services/tts.js';
let autoPlayActive = false;
let autoPlayTimer = null;
export function toggleAutoPlay() {
if (autoPlayActive) stopAutoPlay();
else startAutoPlay();
}
export function startAutoPlay() {
const rows = document.querySelectorAll('.word-table tbody tr[data-word-id]');
const wordIds = Array.from(rows).map(row => Number.parseInt(row.dataset.wordId, 10));
if (wordIds.length === 0) return;
autoPlayActive = true;
updateAutoPlayBtn();
prefetchWordListAudio(wordIds);
playWordSequence(wordIds, 0);
}
export function prefetchWordListAudio(wordIds) {
const texts = wordIds
.map(id => state.words.find(word => word.id === id)?.english)
.filter(Boolean);
if (!texts.length) return;
void prefetchAudio(texts, 0.92, { allowCloud: true })
.catch(error => logTts('word list audio prefetch failed', error));
}
export function playWordSequence(wordIds, index) {
if (!autoPlayActive || index >= wordIds.length) {
stopAutoPlay();
return;
}
const word = state.words.find(item => item.id === wordIds[index]);
if (!word) {
playWordSequence(wordIds, index + 1);
return;
}
document.querySelectorAll('.word-table tbody tr').forEach(row => row.classList.remove('is-playing'));
const row = document.querySelector(`tr[data-word-id="${word.id}"]`);
if (row) {
row.classList.add('is-playing');
let shouldScroll = true;
if (state.autoPlayScrollMode === 'visible') {
const rect = row.getBoundingClientRect();
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const header = document.getElementById('mobile-header');
const topBound = header ? header.getBoundingClientRect().height : 0;
shouldScroll = rect.top < topBound || rect.bottom > viewportHeight;
}
if (shouldScroll) {
row.scrollIntoView({
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
block: 'center',
inline: 'nearest'
});
}
}
const repeatCount = Math.max(1, state.autoPlayRepeat || 1);
const repeatDelay = state.autoPlayRepeatDelay || 1000;
const wordInterval = state.autoPlayInterval || 2500;
let spoken = 0;
async function playCurrentWord() {
if (!autoPlayActive) return;
await speak(word.english);
if (!autoPlayActive) return;
spoken++;
if (spoken < repeatCount) {
autoPlayTimer = setTimeout(playCurrentWord, repeatDelay);
} else {
autoPlayTimer = setTimeout(() => playWordSequence(wordIds, index + 1), wordInterval);
}
}
void playCurrentWord();
}
export function stopAutoPlay() {
autoPlayActive = false;
if (autoPlayTimer) {
clearTimeout(autoPlayTimer);
autoPlayTimer = null;
}
stopAllAudio();
document.querySelectorAll('.word-table tbody tr').forEach(row => row.classList.remove('is-playing'));
updateAutoPlayBtn();
}
export function updateAutoPlayBtn() {
const button = document.getElementById('auto-play-btn');
if (!button) return;
if (autoPlayActive) {
button.innerHTML = '<i class="fas fa-stop"></i> 停止播放';
button.className = 'btn btn-error';
} else {
button.innerHTML = '<i class="fas fa-play"></i> 自动播放';
button.className = 'btn btn-secondary';
}
}
+446
View File
@@ -0,0 +1,446 @@
import { STAGE_LABELS, WORDS_PER_PAGE } from '../constants.js';
import { state } from '../core/state.js';
import { saveFavorites, saveRecords, saveSchedule, saveWords } from '../core/storage.js';
import { isFavorite, toggleFavorite } from '../services/favorites.js';
import { navigate, renderPage } from '../core/router.js';
import { showToast } from '../ui/toast.js';
import { closeModal, showModal } from '../ui/modal.js';
import { speak } from '../services/tts.js';
import { getToday, initWordSchedule } from '../services/ebbinghaus.js';
import { getMastery, getQuizErrorWords } from '../services/stats.js';
import { debounce, escapeHtml, formatFormsHtml } from '../ui/dom.js';
export { playWordSequence, prefetchWordListAudio, startAutoPlay, stopAutoPlay, toggleAutoPlay, updateAutoPlayBtn } from './words-autoplay.js';
export function getNextId() {
const usedIds = new Set([
...state.words.map(w => Number(w.id)),
...state.records.map(r => Number(r.wordId)),
...Object.keys(state.schedule).map(Number),
...state.favorites.map(Number)
].filter(Number.isFinite));
let next = 1;
for (const id of usedIds) {
if (id >= next) next = id + 1;
}
return next;
}
export function normalizeImportedWord(raw, id) {
const word = { ...raw, id };
word.english = String(word.english || '').trim();
word.chinese = String(word.chinese || '').trim();
if (word.phonetic != null) word.phonetic = String(word.phonetic);
if (word.category != null) word.category = String(word.category);
if (word.frequency != null) word.frequency = Number(word.frequency) || 0;
return word;
}
// ==================== Page: Words ====================
export function renderWords(el) {
const categories = [...new Set(state.words.map(w => w.category))].filter(Boolean);
el.innerHTML = `
<div class="page-header">
<div>
<h1>单词库</h1>
<p class="page-desc">管理和浏览你的单词库,共 ${state.words.length} 个单词</p>
</div>
<div class="page-actions">
<button class="btn btn-primary" data-action="words.showImportModal"><i class="fas fa-file-import"></i> 导入单词</button>
${state.words.length > 0 ? `<button class="btn btn-secondary" data-action="words.exportWords"><i class="fas fa-file-export"></i> 导出</button>` : ''}
</div>
</div>
${state.words.length > 0 ? `
<div class="search-bar">
<input type="text" placeholder="搜索单词或释义..." id="word-search" value="${escapeHtml(state.wordSearch)}" data-action="words.onWordSearch" data-action-event="input" data-value-arg="0">
<select id="category-filter" data-action="words.onCategoryFilter" data-action-event="change" data-value-arg="0">
<option value="">全部分类</option>
<option value="__favorites__" ${state.wordCategory === '__favorites__' ? 'selected' : ''}>⭐ 收藏 (${state.favorites.length})</option>
<option value="__errors__" ${state.wordCategory === '__errors__' ? 'selected' : ''}>❌ 错题 (${getQuizErrorWords().length})</option>
<option value="__freq_high__" ${state.wordCategory === '__freq_high__' ? 'selected' : ''}>📊 高频次 (≥50)</option>
<option value="__freq_mid__" ${state.wordCategory === '__freq_mid__' ? 'selected' : ''}>📊 中频次 (10-49)</option>
<option value="__freq_low__" ${state.wordCategory === '__freq_low__' ? 'selected' : ''}>📊 低频次 (1-9)</option>
${categories.map(c => `<option value="${escapeHtml(c)}" ${state.wordCategory === c ? 'selected' : ''}>${escapeHtml(categoryLabel(c))}</option>`).join('')}
</select>
<button id="auto-play-btn" class="btn btn-secondary" data-action="words.toggleAutoPlay" style="white-space:nowrap"><i class="fas fa-play"></i> 自动播放</button>
</div>
<div id="word-list-container"></div>` : `
<div class="empty-state">
<div class="empty-icon"><i class="fas fa-book-open"></i></div>
<h3>单词库是空的</h3>
<p>通过邮件提词或导入 JSON 文件添加单词</p>
<div style="display:flex;gap:10px;justify-content:center;flex-wrap:wrap">
<button class="btn btn-primary" data-action="words.navigate" data-arg0="extract"><i class="fas fa-envelope"></i> 去邮件提词</button>
<button class="btn btn-secondary" data-action="words.showImportModal"><i class="fas fa-file-import"></i> 导入单词</button>
</div>
</div>`}
`;
if (state.words.length > 0) renderWordList();
}
export function renderWordList() {
const container = document.getElementById('word-list-container');
if (!container) return;
let filtered = state.words;
if (state.wordSearch) {
const q = state.wordSearch.toLowerCase();
filtered = filtered.filter(w =>
w.english.toLowerCase().includes(q) || w.chinese.includes(q)
);
}
if (state.wordCategory === '__favorites__') {
filtered = filtered.filter(w => isFavorite(w.id));
} else if (state.wordCategory === '__errors__') {
const errorIds = new Set(getQuizErrorWords().map(w => w.id));
filtered = filtered.filter(w => errorIds.has(w.id));
} else if (state.wordCategory === '__freq_high__') {
filtered = filtered.filter(w => (w.frequency || 0) >= 50);
} else if (state.wordCategory === '__freq_mid__') {
filtered = filtered.filter(w => { const f = w.frequency || 0; return f >= 10 && f < 50; });
} else if (state.wordCategory === '__freq_low__') {
filtered = filtered.filter(w => (w.frequency || 0) < 10 && (w.frequency || 0) > 0);
} else if (state.wordCategory) {
filtered = filtered.filter(w => w.category === state.wordCategory);
}
const totalPages = Math.ceil(filtered.length / WORDS_PER_PAGE);
state.wordPage = totalPages === 0 ? 0 : Math.max(1, Math.min(state.wordPage, totalPages));
const start = state.wordPage > 0 ? (state.wordPage - 1) * WORDS_PER_PAGE : 0;
const pageWords = filtered.slice(start, start + WORDS_PER_PAGE);
container.innerHTML = `
<div class="word-table-wrapper">
<table class="word-table">
<thead>
<tr>
<th>单词</th>
<th class="phonetic-cell">音标</th>
<th>释义</th>
<th>状态</th>
<th class="action-cell">操作</th>
</tr>
</thead>
<tbody>
${pageWords.map(w => {
const mastery = getMastery(w.id);
const badgeClass = mastery === 'mastered' ? 'badge-success' : mastery === 'learning' ? 'badge-warning' : 'badge-primary';
const badgeText = mastery === 'mastered' ? '已掌握' : mastery === 'learning' ? '学习中' : '新词';
const favored = isFavorite(w.id);
const safeEn = escapeHtml(w.english);
return `
<tr data-word-id="${w.id}">
<td class="word-cell" data-action="words.showWordDetail" data-arg0="${w.id}">${escapeHtml(w.english)}</td>
<td class="phonetic-cell">${escapeHtml(w.phonetic || '-')}</td>
<td class="meaning-cell" title="${escapeHtml(w.chinese)}">${escapeHtml(w.chinese)}</td>
<td class="status-cell"><span class="badge ${badgeClass}">${badgeText}</span></td>
<td class="action-cell">
<button class="fav-toggle-btn" data-action="words.toggleWordFavorite" data-arg0="${w.id}" title="${favored ? '取消收藏' : '收藏'}" aria-label="${favored ? '取消收藏' : '收藏'} ${escapeHtml(w.english)}" style="color:${favored ? 'var(--warning)' : 'var(--text-muted)'}"><i class="${favored ? 'fas' : 'far'} fa-star"></i></button>
<button data-action="words.speak" data-arg0="${safeEn}" title="朗读" aria-label="朗读 ${escapeHtml(w.english)}"><i class="fas fa-volume-up"></i></button>
<button data-action="words.showWordDetail" data-arg0="${w.id}" title="详情" aria-label="查看 ${escapeHtml(w.english)} 详情"><i class="fas fa-eye"></i></button>
<button class="btn-delete" data-action="words.deleteWord" data-arg0="${w.id}" title="删除" aria-label="删除 ${escapeHtml(w.english)}"><i class="fas fa-trash-alt"></i></button>
</td>
</tr>`;
}).join('')}
</tbody>
</table>
<div class="word-table-footer">
<span>共 ${filtered.length} 个单词,第 ${state.wordPage} / ${totalPages} 页</span>
<div>${totalPages > 1 ? renderPagination(totalPages) : ''}</div>
</div>
</div>
`;
}
export function renderPagination(totalPages) {
let html = '<div class="pagination">';
html += `<button data-action="words.goWordPage" data-arg0="${state.wordPage - 1}" ${state.wordPage <= 1 ? 'disabled' : ''}></button>`;
const maxShow = 7;
let startP = Math.max(1, state.wordPage - 3);
let endP = Math.min(totalPages, startP + maxShow - 1);
if (endP - startP < maxShow - 1) startP = Math.max(1, endP - maxShow + 1);
for (let i = startP; i <= endP; i++) {
html += `<button class="${i === state.wordPage ? 'active' : ''}" data-action="words.goWordPage" data-arg0="${i}">${i}</button>`;
}
html += `<button data-action="words.goWordPage" data-arg0="${state.wordPage + 1}" ${state.wordPage >= totalPages ? 'disabled' : ''}></button>`;
html += '</div>';
return html;
}
export function goWordPage(p) {
state.wordPage = p;
renderWordList();
requestAnimationFrame(() => {
document.querySelector('.search-bar')?.scrollIntoView({
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches
? 'auto'
: 'smooth',
block: 'start'
});
});
}
const debouncedRenderWordList = debounce(() => renderWordList(), 180);
export function onWordSearch(val) {
state.wordSearch = val;
state.wordPage = 1;
debouncedRenderWordList(); // 逐键防抖,避免每次击键全量重建列表
}
export function onCategoryFilter(val) {
state.wordCategory = val;
state.wordPage = 1;
renderWordList();
}
export function categoryLabel(cat) {
const map = {
high_frequency: '🔴 高频词汇',
medium_frequency: '🟡 中频词汇',
low_frequency: '🔵 低频词汇',
action_verbs: '⚡ 动作动词',
business_core: '💼 商务核心',
technical_core: '🔧 技术核心',
technical_operations: '🛠️ 技术操作',
time_planning: '📅 时间计划',
communication: '💬 沟通交流',
descriptive_words: '📝 描述词汇',
academic: '🎓 学术词汇',
daily_life: '🏠 日常生活',
emotions: '😊 情感词汇',
nature: '🌿 自然环境',
health: '🏥 健康医疗',
food: '🍽️ 饮食相关',
travel: '✈️ 旅行出行',
science: '🔬 科学技术',
law: '⚖️ 法律词汇',
finance: '💰 金融财务'
};
return map[cat] || cat || '未分类';
}
export function showImportModal() {
showModal('<i class="fas fa-file-import" style="color:var(--primary)"></i> 导入单词', `
<div class="form-group">
<label>选择 JSON 文件</label>
<input type="file" accept=".json" id="import-file" data-action="words.handleImportFile" data-action-event="change" data-element-arg="0">
<p class="hint">支持项目自带的 words-*.json 格式</p>
</div>
<div style="text-align:center;color:var(--text-muted);margin:16px 0">— 或者 —</div>
<div class="form-group">
<label>粘贴 JSON 内容</label>
<textarea id="import-text" placeholder='[{"id":1,"english":"word","chinese":"单词",...}]' rows="6"></textarea>
</div>
`, `
<button class="btn btn-secondary" data-action="words.closeModal">取消</button>
<button class="btn btn-primary" data-action="words.doImport">导入</button>
`);
}
let importFileReadGeneration = 0;
export function handleImportFile(input) {
const generation = ++importFileReadGeneration;
const file = input.files[0];
const target = document.getElementById('import-text');
if (!file || !target) return;
const reader = new FileReader();
reader.onload = e => {
if (generation === importFileReadGeneration && target.isConnected && input.isConnected && document.getElementById('import-text') === target) {
target.value = e.target.result;
}
};
reader.onerror = () => {
if (generation === importFileReadGeneration && target.isConnected) showToast('单词文件读取失败', 'error');
};
reader.readAsText(file);
}
export function doImport() {
const text = document.getElementById('import-text').value.trim();
if (!text) { showToast('请选择文件或粘贴 JSON 内容', 'warning'); return; }
try {
const data = JSON.parse(text);
if (!Array.isArray(data)) throw new Error('数据格式错误');
const valid = data.filter(w => w.english && w.chinese);
if (valid.length === 0) throw new Error('未找到有效单词');
const previousWords = state.words.slice();
const previousSchedule = { ...state.schedule };
const existingKeys = new Set(state.words.map(w => String(w.english || '').trim().toLowerCase()).filter(Boolean));
let added = 0;
let nextId = getNextId();
valid.forEach(raw => {
const key = String(raw.english || '').trim().toLowerCase();
if (!key || existingKeys.has(key)) return;
const word = normalizeImportedWord(raw, nextId++);
state.words.push(word);
existingKeys.add(key);
initWordSchedule(word.id, false);
added++;
});
if (added === 0) {
showToast('没有新单词可导入(全部已存在)', 'info');
return;
}
if (!(saveWords() && saveSchedule())) {
state.words = previousWords;
state.schedule = previousSchedule;
saveWords();
saveSchedule();
throw new Error('本地存储写入失败');
}
closeModal();
showToast(`成功导入 ${added} 个单词(共 ${valid.length} 个,${valid.length - added} 个重复跳过)`, 'success');
renderPage('words');
} catch (e) {
showToast('导入失败:' + e.message, 'error');
}
}
export function exportWords() {
const blob = new Blob([JSON.stringify(state.words, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `words-export-${getToday()}.json`;
a.click();
URL.revokeObjectURL(url);
showToast('导出成功', 'success');
}
export function toggleWordFavorite(wordId) {
if (!toggleFavorite(wordId)) return false;
const faved = isFavorite(wordId);
if (state.wordCategory === '__favorites__' && !faved) {
const row = document.querySelector(`tr[data-word-id="${wordId}"]`);
if (row) {
row.style.opacity = '0.45';
const btn = row.querySelector('.fav-toggle-btn');
if (btn) {
btn.style.color = 'var(--text-muted)';
btn.title = '收藏';
btn.querySelector('i').className = 'far fa-star';
}
return true;
}
}
renderWordList();
return true;
}
export function deleteWord(id) {
if (!confirm('确定要删除这个单词吗?')) return false;
const previousWords = state.words;
const previousSchedule = state.schedule;
const previousRecords = state.records;
const previousFavorites = state.favorites;
state.words = state.words.filter(w => w.id !== id);
state.schedule = { ...state.schedule };
delete state.schedule[id];
state.records = state.records.filter(r => r.wordId !== id);
state.favorites = state.favorites.filter(wordId => wordId !== id);
const saved = saveWords() && saveSchedule() && saveRecords() && saveFavorites();
if (!saved) {
state.words = previousWords;
state.schedule = previousSchedule;
state.records = previousRecords;
state.favorites = previousFavorites;
saveWords();
saveSchedule();
saveRecords();
saveFavorites();
renderWordList();
return false;
}
renderWordList();
showToast('已删除', 'info');
return true;
}
export function showWordDetail(id) {
const w = state.words.find(x => x.id === id);
if (!w) return;
const sch = state.schedule[w.id];
const mastery = getMastery(w.id);
const masteryText = mastery === 'mastered' ? '已掌握' : mastery === 'learning' ? '学习中' : '新词';
const safeEn = escapeHtml(w.english);
showModal(safeEn, `
<div style="text-align:center;margin-bottom:20px">
<div style="font-size:32px;font-weight:700;margin-bottom:6px">${escapeHtml(w.english)}</div>
<div style="color:var(--text-muted);font-size:16px;margin-bottom:8px">${escapeHtml(w.phonetic || '')}</div>
<button class="btn btn-sm btn-ghost" data-action="words.speak" data-arg0="${safeEn}"><i class="fas fa-volume-up"></i> 朗读</button>
<span class="badge ${mastery === 'mastered' ? 'badge-success' : mastery === 'learning' ? 'badge-warning' : 'badge-primary'}">${masteryText}</span>
</div>
<div style="margin-bottom:16px" id="word-detail-chinese-${w.id}">
<strong>中文释义:</strong> <span id="chinese-text-${w.id}">${escapeHtml(w.chinese)}</span>
<button class="btn btn-sm btn-ghost" data-action="words.editWordChinese" data-arg0="${w.id}" title="修改释义" style="margin-left:8px"><i class="fas fa-edit"></i></button>
</div>
${w.forms ? `<div style="margin-bottom:16px"><strong>词形变化:</strong><br>${formatFormsHtml(w.forms)}</div>` : ''}
${w.example ? `
<div style="margin-bottom:16px">
<strong>例句:</strong><br>
<em>${escapeHtml(w.example.en)}</em><br>
<span style="color:var(--text-secondary)">${escapeHtml(w.example.cn)}</span>
</div>` : ''}
${w.sourceContext ? `
<div style="margin-bottom:16px;padding:12px;background:var(--primary-bg);border-radius:var(--radius-sm);border-left:3px solid var(--primary)">
<div style="font-size:12px;color:var(--text-muted);margin-bottom:4px"><i class="fas fa-envelope"></i> 来源邮件</div>
<div style="font-size:13px;color:var(--text-secondary);font-style:italic">"${escapeHtml(w.sourceContext.sentence)}"</div>
</div>` : ''}
${sch ? `
<div style="font-size:13px;color:var(--text-muted);border-top:1px solid var(--border);padding-top:12px;margin-top:12px">
复习阶段:${STAGE_LABELS[sch.stage] || '—'} | 下次复习:${escapeHtml(sch.nextReview || '—')}<br>
正确 ${sch.correctCount} 次 / 错误 ${sch.incorrectCount}
</div>` : ''}
`);
}
export function editWordChinese(id) {
const w = state.words.find(x => x.id === id);
if (!w) return;
const container = document.getElementById(`word-detail-chinese-${id}`);
if (!container) return;
container.innerHTML = `
<strong>中文释义:</strong>
<div style="display:flex;gap:8px;align-items:center;margin-top:6px">
<input type="text" id="edit-chinese-input-${id}" value="${escapeHtml(w.chinese)}" style="flex:1;padding:6px 10px;border:1px solid var(--border);border-radius:var(--radius-sm);font-size:14px;background:var(--card);color:var(--text)">
<button class="btn btn-sm btn-primary" data-action="words.saveWordChinese" data-arg0="${id}"><i class="fas fa-check"></i></button>
<button class="btn btn-sm btn-secondary" data-action="words.showWordDetail" data-arg0="${id}"><i class="fas fa-times"></i></button>
</div>`;
const input = document.getElementById(`edit-chinese-input-${id}`);
if (input) { input.focus(); input.select(); }
}
export function saveWordChinese(id) {
const input = document.getElementById(`edit-chinese-input-${id}`);
if (!input) return;
const newVal = input.value.trim();
if (!newVal) { showToast('释义不能为空', 'warning'); return; }
const w = state.words.find(x => x.id === id);
if (!w) return;
const previousChinese = w.chinese;
w.chinese = newVal;
if (!saveWords()) {
w.chinese = previousChinese;
return;
}
if (state.currentPage === 'words') renderWordList();
showToast('释义已更新', 'success');
showWordDetail(id);
}