fix: 增强学习流程与数据处理的稳定性

- 确保异步提词操作结束后可靠恢复按钮状态
- 改进批量文件解析、清空操作与来源列表同步
- 防止复习提前完成,并拦截无效的会话跳转
- 校验 AI 翻译数据并保留 JSON 最外层结构
- 仅使用测验记录计算正确率和易错单词排名
- 限制自动播放设置范围,并在词表变化时停止播放
- 避免云端语音被中断后触发重复的降级朗读
- 改进 Service Worker 对临时网络故障的缓存回退
- 增加 AI 响应解析和测验统计相关测试
This commit is contained in:
eddy
2026-07-30 01:47:54 +08:00
parent 738275b493
commit 5a7149151e
19 changed files with 520 additions and 192 deletions
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="theme-color" content="#6366f1">
+30 -10
View File
@@ -12,9 +12,19 @@ function extractHtmlText(html) {
return documentFragment.body?.textContent || '';
}
export function resetBatchUploads() {
batchFileReadGeneration++;
batchUploadedTexts = [];
const input = document.getElementById('batch-file-input');
if (input) input.value = '';
renderBatchFileList();
}
export function handleBatchFiles(input) {
const generation = ++batchFileReadGeneration;
const files = Array.from(input.files);
// File 对象已复制到数组,随即清空输入框的值,否则再次选择同一批文件不会触发 change 事件。
input.value = '';
if (!files.length) {
batchUploadedTexts = [];
renderBatchFileList();
@@ -44,15 +54,24 @@ export function handleBatchFiles(input) {
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);
// 解析异常必须就地兜住:否则 finishFile() 不会执行,计数永远到不了文件总数,
// 整个列表会一直停在「读取文件中...」,同批次其他文件也跟着卡死。
try {
let text = event.target.result;
if (name.endsWith('.eml')) {
text = extractEmlBody(text);
} else if (name.endsWith('.html') || name.endsWith('.htm')) {
text = extractHtmlText(text);
}
// 空正文的文件一律提示后跳过:静默丢弃会让用户面对一个空列表,无从判断是哪一步出了问题。
if (String(text || '').trim()) loadedTexts[index] = { name: file.name, content: text };
else showToast(`文件「${file.name}」未解析到可读正文,已跳过`, 'warning');
} catch (error) {
console.warn(`Failed to parse uploaded file "${file.name}"`, error);
showToast(`文件「${file.name}」解析失败,已跳过`, 'warning');
} finally {
finishFile();
}
loadedTexts[index] = { name: file.name, content: text };
finishFile();
};
reader.onerror = () => {
if (generation !== batchFileReadGeneration) return;
@@ -67,6 +86,9 @@ export function handleBatchFiles(input) {
export function renderBatchFileList() {
const listElement = document.getElementById('batch-file-list');
if (!listElement) return;
// 来源信息必须与列表同步刷新,否则移除最后一个文件后仍显示「1 个上传文件」,
// 而批量提取此时已经没有上传来源了。
updateBatchSourceInfo();
if (batchUploadedTexts.length === 0) {
listElement.innerHTML = '';
return;
@@ -82,8 +104,6 @@ export function renderBatchFileList() {
<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) {
+194 -148
View File
@@ -9,17 +9,37 @@ 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';
import { batchUploadedTexts, renderBatchFileList, resetBatchUploads } 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;
// 每类操作各自的归属令牌:只有某个按钮最近一次操作才能恢复该按钮,
// 避免旧的异步流程在新流程运行期间把按钮改回可用状态。
let extractBusyToken = 0;
let batchBusyToken = 0;
let addSelectedBusyToken = 0;
// 按钮文案在模板和恢复函数里各出现一次,抽成常量以免改了一处漏了另一处。
const EXTRACT_BTN_LABEL = '<i class="fas fa-search"></i> 提取单词';
const BATCH_EXTRACT_BTN_LABEL = '<i class="fas fa-search-plus"></i> 批量提取';
const EXTRACTING_BTN_LABEL = '<i class="fas fa-spinner fa-spin"></i> 提取中...';
function beginExtractGeneration() {
return ++extractGeneration;
}
function resetExtractButton() {
const btn = document.querySelector('[data-action="extract.doExtractFromEmail"]');
if (btn?.isConnected) { btn.disabled = false; btn.innerHTML = EXTRACT_BTN_LABEL; }
}
function resetBatchExtractButton() {
const btn = document.getElementById('batch-extract-btn');
if (btn?.isConnected) { btn.disabled = false; btn.innerHTML = BATCH_EXTRACT_BTN_LABEL; }
}
function isCurrentExtractGeneration(generation, ...elements) {
return generation === extractGeneration
&& state.currentPage === 'extract'
@@ -40,7 +60,7 @@ export function renderExtract(el) {
</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>
<button class="btn btn-primary" id="batch-extract-btn" data-action="extract.doBatchExtract">${BATCH_EXTRACT_BTN_LABEL}</button>
<span style="font-size:12px;color:var(--text-muted)" id="batch-source-info">已保存 ${state.mailEmails.length} 封邮件</span>
<span id="batch-result"></span>
</div>
@@ -60,7 +80,7 @@ export function renderExtract(el) {
<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-primary" data-action="extract.doExtractFromEmail">${EXTRACT_BTN_LABEL}</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>
@@ -95,6 +115,10 @@ export function renderExtract(el) {
const ta = document.getElementById('email-input');
ta.addEventListener('input', () => autoResizeTextarea(ta));
ta.addEventListener('paste', () => setTimeout(() => autoResizeTextarea(ta), 0));
// 已上传的文件保存在模块状态里,会跨页面切换存活。重建 DOM 后必须重新渲染,
// 否则界面看起来没有任何来源,批量提取却仍在使用这些看不见的文件。
if (state.batchExtractEnabled) renderBatchFileList();
}
export async function doExtractFromEmail() {
@@ -106,47 +130,51 @@ export async function doExtractFromEmail() {
if (!text) { showToast('请粘贴邮件内容', 'error'); return; }
batchMode = false;
if (extractBtn) { extractBtn.disabled = true; extractBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 提取中...'; }
const busyToken = ++extractBusyToken;
if (extractBtn) { extractBtn.disabled = true; extractBtn.innerHTML = EXTRACTING_BTN_LABEL; }
const extracted = extractEnglishWords(text);
if (!extracted.length) {
if (extractBtn?.isConnected) { extractBtn.disabled = false; extractBtn.innerHTML = '<i class="fas fa-search"></i> 提取单词'; }
showToast('未提取到有效英文单词', 'info');
return;
}
// 无论正常结束、提前退出还是抛错,按钮都要恢复,否则会一直停在禁用+转圈状态。
try {
const extracted = extractEnglishWords(text);
if (!extracted.length) {
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 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;
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]);
}
}
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]);
renderWordChips(filtered);
const nameMsg = nameCount > 0 ? `,过滤了 ${nameCount} 个人名/专有名词` : '';
const dedupMsg = dedupCount > 0 ? `,合并了 ${dedupCount} 个词形变化` : '';
showToast(`提取到 ${filtered.length} 个单词${nameMsg}${dedupMsg}`, 'success');
} finally {
if (busyToken === extractBusyToken) resetExtractButton();
}
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) {
@@ -198,6 +226,7 @@ export async function addSelectedWords() {
const progress = document.getElementById('extract-progress');
progress.style.display = 'block';
const busyToken = ++addSelectedBusyToken;
document.getElementById('add-selected-btn').disabled = true;
let success = 0, fail = 0, skipped = 0;
const addedWordIds = [];
@@ -218,83 +247,97 @@ export async function addSelectedWords() {
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]));
// 中途放弃(切换词库、页面重渲染等)也必须解禁按钮,否则「翻译并添加选中」会一直不可点。
try {
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);
batch.forEach(item => {
const key = item.word.trim().toLowerCase();
if (!key || handled.has(key)) return;
handled.add(key);
if (existing.has(key)) {
skipped++;
return;
}
if (existing.has(key)) {
skipped++;
return;
}
const base = resultMap.get(key);
if (!base || !String(base.chinese || '').trim()) {
// 缺少中文释义的脏数据不入库,否则本地出题会因缺字段崩溃
fail++;
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 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++;
});
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++;
});
} 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;
}
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');
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);
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);
}
}
} finally {
if (busyToken === addSelectedBusyToken) {
const addBtn = document.getElementById('add-selected-btn');
if (addBtn?.isConnected) addBtn.disabled = false;
}
}
}
export function clearEmailExtract() {
// 上传的文件同样属于「本次提词的输入」,否则清空后批量提取仍会用到它们;
// 但按钮标签只说「清空」,丢弃文件又不可撤销,交给用户单独确认。
// 确认结果只决定是否连带移除文件:拒绝也要照常清空输入框,否则点了「清空」什么都没发生。
const uploadedCount = batchUploadedTexts.length;
const alsoResetUploads = uploadedCount === 0
|| confirm(`同时移除已上传的 ${uploadedCount} 个文件?`);
beginExtractGeneration();
batchMode = false;
batchResults = [];
@@ -307,6 +350,7 @@ export function clearEmailExtract() {
document.getElementById('extract-progress').style.display = 'none';
const statusEl = document.getElementById('batch-result');
if (statusEl) statusEl.innerHTML = '';
if (alsoResetUploads) resetBatchUploads();
}
export function saveEmailForLearn() {
@@ -332,66 +376,68 @@ export async function doBatchExtract() {
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> 提取中...'; }
const busyToken = ++batchBusyToken;
if (batchBtn) { batchBtn.disabled = true; batchBtn.innerHTML = EXTRACTING_BTN_LABEL; }
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]);
// 与单封提词同理:按钮状态放在 finally 里恢复,任何提前退出都不会留下卡住的转圈按钮。
try {
let results = extractWithFrequency(allEmails);
if (results.length === 0) {
showToast('未提取到有效单词', 'info');
return;
}
}
if (!isCurrentExtractGeneration(generation, chipsContainer)) return;
if (batchBtn?.isConnected) { batchBtn.disabled = false; batchBtn.innerHTML = '<i class="fas fa-search-plus"></i> 批量提取'; }
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]);
}
}
batchResults = results;
batchMode = true;
if (!isCurrentExtractGeneration(generation, chipsContainer)) return;
const existingWords = new Set(state.words.map(w => w.english.toLowerCase()));
const infoEl = document.getElementById('extract-info');
const actionsEl = document.getElementById('extract-actions');
batchResults = results;
batchMode = true;
if (!chipsContainer) return;
const existingWords = new Set(state.words.map(w => w.english.toLowerCase()));
const infoEl = document.getElementById('extract-info');
const actionsEl = document.getElementById('extract-actions');
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}"
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('');
}).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 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>`;
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');
updateSelectedCount();
showToast(`批量提取完成:${results.length} 个单词${dedupMsg}`, 'success');
} finally {
if (busyToken === batchBusyToken) resetBatchExtractButton();
}
}
export function toggleBatchExtract(enabled) {
+22 -8
View File
@@ -189,14 +189,26 @@ export function renderLearnSession(el) {
}
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);
if (!s || !Array.isArray(s.cards)) return;
// 越界的下标会让 index 直接跨过卡片总数,从而误触发「复习完成」。
if (!Number.isInteger(idx) || idx < 0 || idx >= s.cards.length) return;
s.index = idx;
s.flipped = false;
rerenderLearnSession();
speak(s.cards[idx].english);
}
// 复习完成的判断依据是「所有卡片都已评分」,而不是下标越过末尾:用户可以在侧栏
// 自由跳转,若只看下标,跳到最后一张评分后就会带着一堆未评分的卡片直接结束。
function nextUnratedIndex(s, from) {
for (let i = from; i < s.cards.length; i++) {
if (!s.rated[i]) return i;
}
for (let i = 0; i < from; i++) {
if (!s.rated[i]) return i;
}
return s.cards.length;
}
export function toggleLearnFavorite(wordId) {
@@ -238,7 +250,7 @@ export function rateLearnCard(rating) {
s.results[rating]++;
s.rated[s.index] = rating;
s.index++;
s.index = nextUnratedIndex(s, s.index + 1);
s.flipped = false;
rerenderLearnSession();
if (s.index < s.cards.length) {
@@ -299,7 +311,9 @@ export function prevLearnCard() {
export function nextLearnCardDirect() {
const s = state.learnSession;
if (!s || !s.rated?.[s.index]) return;
s.index++;
const next = s.index + 1;
// 仍有未评分卡片时不允许越过末尾,否则手动翻到最后一张就会提前进入完成页。
s.index = next >= s.cards.length ? nextUnratedIndex(s, 0) : next;
s.flipped = false;
rerenderLearnSession();
const w = s.cards[s.index];
+1
View File
@@ -150,6 +150,7 @@ export function answerQuiz(optionIndex) {
export function nextQuizQuestion() {
const s = state.quizSession;
if (!s) return;
s.index++;
if (s.index >= s.questions.length) {
s.finished = true;
+3
View File
@@ -332,6 +332,9 @@ export async function addWordFromReader(word, btnEl) {
if (btnEl) { btnEl.disabled = true; btnEl.textContent = '添加中...'; }
try {
const result = await translateWord(word);
// 兜底:缺中文释义的单词不入库,否则单词页搜索会在 w.chinese.includes() 上抛错。
// translateWords 已在上游过滤,这里只防它日后放宽校验。
if (!String(result?.chinese || '').trim()) throw new Error('AI 未返回中文释义');
if (!isCurrentOperation()) return false;
if (wordExists()) {
showToast(`${word}」已在词库中`, 'info');
+2
View File
@@ -79,6 +79,8 @@ export function handleImportBackup(input) {
if (importButton) importButton.disabled = true;
const file = input.files[0];
// 立即清空输入框的值,否则导入失败后再次选择同一个备份文件不会触发 change 事件。
input.value = '';
if (!file) return;
backupReadPending = true;
+20 -2
View File
@@ -304,11 +304,29 @@ export function setAutoPlayScrollMode(mode) {
return true;
}
// 0 表示留空(沿用内置默认值);其余取值夹到输入框声明的范围内,
// 因为 min/max 属性本身不阻止用户手动键入越界数字,存进去会得到快到无法听清的播放节奏。
function clampAutoPlayMs(rawValue, min, max) {
const parsed = parseInt(rawValue, 10);
if (!Number.isFinite(parsed) || parsed <= 0) return 0;
return Math.min(max, Math.max(min, parsed));
}
// 夹取结果写回输入框:否则表单继续显示被拒绝的数字,用户会以为存进去的是自己填的值。
// 输入框缺失时沿用当前值,不能当成留空 —— 否则会把已保存的设置静默重置为默认。
function readClampedAutoPlayMs(id, min, max, currentValue) {
const input = document.getElementById(id);
if (!input) return currentValue;
const value = clampAutoPlayMs(input.value, min, max);
input.value = value > 0 ? String(value) : '';
return value;
}
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;
const intervalVal = readClampedAutoPlayMs('set-word-interval', 500, 10000, previousInterval);
const repeatDelayVal = readClampedAutoPlayMs('set-repeat-delay', 300, 5000, previousRepeatDelay);
state.autoPlayInterval = intervalVal;
state.autoPlayRepeatDelay = repeatDelayVal;
const saved = saveJsonSetting(STORAGE_KEYS.autoPlayInterval, intervalVal)
+15 -7
View File
@@ -4,6 +4,12 @@ import { getErrorTopWords, getLast30DaysData, getStats, getStreak } from '../ser
import { escapeHtml } from '../ui/dom.js';
let studyChart = null;
function rateOf(records) {
if (records.length === 0) return 0;
return Math.round(records.filter(r => r.isCorrect).length / records.length * 100);
}
// ==================== Page: Stats ====================
export function renderStats(el) {
destroyStudyChart();
@@ -25,14 +31,16 @@ export function renderStats(el) {
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 today = getToday();
// 练习次数统计所有记录(复习也算练习量)。
const todayTotal = state.records.filter(r => r.date === today).length;
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 quizRecords = state.records.filter(r => r.type === 'quiz');
const todayQuizRecords = quizRecords.filter(r => r.date === today);
const todayRate = rateOf(todayQuizRecords);
const allRate = rateOf(quizRecords);
const dailyData = getLast30DaysData();
const errorTop = getErrorTopWords(10);
+6
View File
@@ -4,12 +4,17 @@ import { logTts, prefetchAudio, speak, stopAllAudio } from '../services/tts.js';
let autoPlayActive = false;
let autoPlayTimer = null;
export function isAutoPlayActive() {
return autoPlayActive;
}
export function toggleAutoPlay() {
if (autoPlayActive) stopAutoPlay();
else startAutoPlay();
}
export function startAutoPlay() {
if (autoPlayActive) return;
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;
@@ -62,6 +67,7 @@ export function playWordSequence(wordIds, index) {
}
}
// 0 表示「未设置」,回落到默认值:设置页把留空的输入框保存为 0,并明示「留空使用默认」。
const repeatCount = Math.max(1, state.autoPlayRepeat || 1);
const repeatDelay = state.autoPlayRepeatDelay || 1000;
const wordInterval = state.autoPlayInterval || 2500;
+7
View File
@@ -9,6 +9,7 @@ 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';
import { isAutoPlayActive, stopAutoPlay } from './words-autoplay.js';
export { playWordSequence, prefetchWordListAudio, startAutoPlay, stopAutoPlay, toggleAutoPlay, updateAutoPlayBtn } from './words-autoplay.js';
@@ -84,6 +85,12 @@ export function renderWordList() {
const container = document.getElementById('word-list-container');
if (!container) return;
// 自动播放按渲染时的行序列推进。列表内容变化(搜索、筛选、翻页、删除)后原序列
// 已经对不上界面,继续播下去会朗读屏幕上不存在的单词,因此先停掉。
// 必须先判断是否在播:stopAutoPlay() 会连带 stopAllAudio(),无条件调用会掐断
// 用户手动点「朗读」触发的单次发音。
if (isAutoPlayActive()) stopAutoPlay();
let filtered = state.words;
if (state.wordSearch) {
const q = state.wordSearch.toLowerCase();
+10 -3
View File
@@ -164,16 +164,23 @@ export async function translateWords(words) {
]);
const parsed = extractJsonValue(content, null);
const list = Array.isArray(parsed) ? parsed : (isPlainObject(parsed) && Array.isArray(parsed.words) ? parsed.words : []);
// 单词查询常只返回一个裸对象(而非单元素数组),按单元素列表处理,
// 否则会被当成格式错误而丢弃一条本可用的释义。
const list = Array.isArray(parsed)
? parsed
: (isPlainObject(parsed) ? (Array.isArray(parsed.words) ? parsed.words : [parsed]) : []);
// 缺中文释义的条目不算有效返回:入库后单词页搜索会在 w.chinese.includes() 上抛错,
// 而这份脏数据已经落盘,清缓存也救不回来。
const isUsableEntry = item => isPlainObject(item) && !!String(item.chinese || '').trim();
const byWord = new Map();
list.forEach(item => {
if (!isPlainObject(item)) return;
if (!isUsableEntry(item)) return;
const english = String(item.english || '').trim().toLowerCase();
if (english) byWord.set(english, { ...item, english });
});
const results = uniqueWords.map(word => byWord.get(word)).filter(Boolean);
if (!results.length && uniqueWords.length === 1 && list.length === 1 && isPlainObject(list[0])) {
if (!results.length && uniqueWords.length === 1 && list.length === 1 && isUsableEntry(list[0])) {
results.push({ ...list[0], english: String(list[0].english || uniqueWords[0]).trim() });
}
if (!results.length) throw new Error('AI 返回的单词数据格式无效');
+10 -9
View File
@@ -84,15 +84,16 @@ export function extractJsonValue(content, fallback = null) {
try {
return JSON.parse(cleaned);
} catch (_) {
const firstObject = cleaned.indexOf('{');
const lastObject = cleaned.lastIndexOf('}');
const firstArray = cleaned.indexOf('[');
const lastArray = cleaned.lastIndexOf(']');
const candidates = [];
if (firstObject >= 0 && lastObject > firstObject) candidates.push(cleaned.slice(firstObject, lastObject + 1));
if (firstArray >= 0 && lastArray > firstArray) candidates.push(cleaned.slice(firstArray, lastArray + 1));
for (const candidate of candidates) {
try { return JSON.parse(candidate); } catch (_) { /* try next */ }
// 模型常在 JSON 前后附带说明文字。最外层结构以先出现的括号为准,
// 否则「这是结果:[{...}]」会被截成内层的第一个对象,整个数组元素全部丢失。
const candidates = [
{ start: cleaned.indexOf('{'), end: cleaned.lastIndexOf('}') },
{ start: cleaned.indexOf('['), end: cleaned.lastIndexOf(']') }
]
.filter(({ start, end }) => start >= 0 && end > start)
.sort((a, b) => a.start - b.start);
for (const { start, end } of candidates) {
try { return JSON.parse(cleaned.slice(start, end + 1)); } catch (_) { /* try next */ }
}
return fallback;
}
+3 -1
View File
@@ -93,7 +93,9 @@ export function getLast30DaysData() {
export function getErrorTopWords(n) {
const wordStats = {};
state.records.forEach(r => {
// 与 getQuizErrorWords 保持一致,只统计测验记录:复习时的自评(认识/模糊/不认识)
// 与答题正确率不是一回事,混在一起会让「最易错单词」的正确率和排名失真。
state.records.filter(r => r.type === 'quiz').forEach(r => {
if (!wordStats[r.wordId]) wordStats[r.wordId] = { correct: 0, total: 0 };
wordStats[r.wordId].total++;
if (r.isCorrect) wordStats[r.wordId].correct++;
+9 -3
View File
@@ -272,22 +272,28 @@ async function speakCloudTts(text, rate = 1, requestSeq = ttsRequestSeq) {
}
};
try {
await new Promise((resolve, reject) => {
// 返回值表示「云端 TTS 是否已接管本次播放」,而非是否播完:只要声音已经开始,
// 被暂停(新的朗读请求、stopAllAudio、系统层面打断)也算接管,否则调用方会
// 继续降级到有道并把同一个词重播一遍。本次请求是否已被取代由调用方比对
// requestSeq 判断。
return await new Promise((resolve, reject) => {
let settled = false;
let playbackStarted = false;
const settle = (fn, value) => {
if (settled) return;
settled = true;
fn(value);
};
audio.addEventListener('playing', () => { playbackStarted = true; }, { once: true });
audio.addEventListener('ended', () => settle(resolve, true), { once: true });
audio.addEventListener('pause', () => settle(resolve, false), { once: true });
// 个别浏览器不派发 playing,用播放位置兜底判断声音是否已经出来过。
audio.addEventListener('pause', () => settle(resolve, playbackStarted || audio.currentTime > 0), { once: true });
audio.addEventListener('error', () => settle(reject, new Error('Azure TTS 音频播放失败')), { once: true });
const playPromise = audio.play();
if (playPromise) playPromise.catch(err => settle(reject, err));
});
return true;
} finally {
cleanupAudio();
}
+11 -1
View File
@@ -83,7 +83,17 @@ async function handleSameOrigin(event, request, url) {
request,
url.searchParams.has('v') ? undefined : { cache: 'no-cache' }
);
if (!response.ok) return response;
if (!response.ok) {
// 只在两种情况下回退到缓存副本:瞬时故障(服务重启、网关抖动、限流)以及导航
// 请求(拿不到 HTML 入口等于白屏,而 HTML 每次都网络优先重取,不存在卡版本)。
// 其余永久性错误(404/410:资源已删除或改名)原样返回,但保留缓存条目:
// 这条分支在查缓存之前就返回了,旧副本不可能顶掉新版本,而留着它能让后续
// 离线访问(catch 分支)仍有兜底 —— 部署间隙的一次 404 不该毁掉离线副本。
const isTransient = response.status >= 500 || response.status === 408 || response.status === 429;
if (!isTransient && request.mode !== 'navigate') return response;
const cached = await matchSameOriginCache(request);
return cached || response;
}
// 只缓存成功的基础响应(避免缓存重定向/错误页/opaque 响应)。
if (response.type === 'basic') {
+101
View File
@@ -0,0 +1,101 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { state } from '../js/core/state.js';
import { translateWords } from '../js/services/ai.js';
function stubAiReply(content) {
const calls = [];
globalThis.fetch = async (url, options) => {
calls.push({ url, options });
return {
ok: true,
status: 200,
json: async () => ({ choices: [{ message: { content } }] })
};
};
return calls;
}
function withAiSettings() {
state.settings = { apiKey: 'k', apiUrl: 'https://example.test/v1', model: 'test-model' };
}
test('translateWords accepts a plain JSON array', async () => {
withAiSettings();
stubAiReply('[{"english":"hello","chinese":"你好"}]');
const results = await translateWords(['hello']);
assert.equal(results.length, 1);
assert.equal(results[0].english, 'hello');
assert.equal(results[0].chinese, '你好');
});
test('translateWords accepts an array wrapped in explanatory prose', async () => {
withAiSettings();
stubAiReply('这是结果:[{"english":"hello","chinese":"你好"},{"english":"world","chinese":"世界"}]');
const results = await translateWords(['hello', 'world']);
assert.deepEqual(results.map(item => item.english), ['hello', 'world']);
assert.deepEqual(results.map(item => item.chinese), ['你好', '世界']);
});
test('translateWords accepts a single bare object instead of a one-element array', async () => {
withAiSettings();
stubAiReply('{"english":"hello","chinese":"你好","phonetic":"/həˈloʊ/"}');
const results = await translateWords(['hello']);
assert.equal(results.length, 1);
assert.equal(results[0].english, 'hello');
assert.equal(results[0].chinese, '你好');
});
test('translateWords accepts a bare object missing the english field for a single word', async () => {
withAiSettings();
stubAiReply('{"chinese":"你好"}');
const results = await translateWords(['hello']);
assert.equal(results.length, 1);
assert.equal(results[0].english, 'hello');
assert.equal(results[0].chinese, '你好');
});
test('translateWords rejects a bare object without a Chinese meaning', async () => {
withAiSettings();
stubAiReply('{"error":"quota exceeded"}');
await assert.rejects(() => translateWords(['hello']), /单词数据格式无效/);
});
test('translateWords drops list entries that carry no Chinese meaning', async () => {
withAiSettings();
stubAiReply('[{"english":"hello","chinese":"你好"},{"english":"world"}]');
const results = await translateWords(['hello', 'world']);
assert.deepEqual(results.map(item => item.english), ['hello']);
});
test('translateWords accepts a words-keyed wrapper object', async () => {
withAiSettings();
stubAiReply('{"words":[{"english":"hello","chinese":"你好"}]}');
const results = await translateWords(['hello']);
assert.equal(results.length, 1);
assert.equal(results[0].chinese, '你好');
});
test('translateWords deduplicates and lowercases requested words', async () => {
withAiSettings();
const calls = stubAiReply('[{"english":"hello","chinese":"你好"}]');
const results = await translateWords(['Hello', 'HELLO', ' hello ']);
assert.equal(results.length, 1);
const body = JSON.parse(calls[0].options.body);
assert.equal(body.messages.at(-1).content.includes('"hello"'), true);
});
test('translateWords rejects unusable AI payloads', async () => {
withAiSettings();
stubAiReply('完全不是 JSON');
await assert.rejects(() => translateWords(['hello']), /单词数据格式无效/);
});
test('translateWords returns an empty list without calling the API for blank input', async () => {
withAiSettings();
let called = false;
globalThis.fetch = async () => { called = true; throw new Error('should not be called'); };
assert.deepEqual(await translateWords(['', ' ']), []);
assert.equal(called, false);
});
+17
View File
@@ -68,6 +68,23 @@ test('extractJsonValue handles fences, surrounding prose, and fallback values',
assert.equal(extractJsonValue('not json', 'fallback'), 'fallback');
});
test('extractJsonValue keeps the outermost structure when prose wraps an array of objects', () => {
// 取先出现的括号,否则数组会被截成里面的第一个对象,其余元素全部丢失。
assert.deepEqual(
extractJsonValue('Here you go: [{"english":"hello"},{"english":"world"}]'),
[{ english: 'hello' }, { english: 'world' }]
);
assert.deepEqual(
extractJsonValue('```json\n好的:[{"english":"hi"}]\n```'),
[{ english: 'hi' }]
);
// 对象在前时仍应取对象包装体,而不是里面嵌套的数组。
assert.deepEqual(
extractJsonValue('Sure! {"questions":[{"question":"q"}]}'),
{ questions: [{ question: 'q' }] }
);
});
test('isPlainObject accepts ordinary and null-prototype objects only', () => {
assert.equal(isPlainObject({}), true);
assert.equal(isPlainObject(Object.create(null)), true);
+58
View File
@@ -0,0 +1,58 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { state } from '../js/core/state.js';
import { getErrorTopWords, invalidateErrorWordsCache } from '../js/services/stats.js';
const WORDS = [
{ id: 1, english: 'alpha', chinese: '甲' },
{ id: 2, english: 'beta', chinese: '乙' }
];
function setRecords(records) {
state.words = WORDS;
state.records = records;
invalidateErrorWordsCache();
}
test('getErrorTopWords counts only quiz records', () => {
setRecords([
{ wordId: 1, type: 'quiz', isCorrect: false },
{ wordId: 1, type: 'quiz', isCorrect: true },
// 复习自评不应计入答题正确率,否则会把 alpha 的正确率抬到 75%
{ wordId: 1, type: 'review', isCorrect: true },
{ wordId: 1, type: 'review', isCorrect: true }
]);
const [alpha] = getErrorTopWords(10);
assert.equal(alpha.id, 1);
assert.equal(alpha.total, 2);
assert.equal(alpha.rate, 50);
});
test('getErrorTopWords omits words with only review records', () => {
setRecords([
{ wordId: 1, type: 'quiz', isCorrect: false },
{ wordId: 2, type: 'review', isCorrect: false }
]);
assert.deepEqual(getErrorTopWords(10).map(word => word.id), [1]);
});
test('getErrorTopWords sorts by ascending accuracy and respects the limit', () => {
setRecords([
{ wordId: 1, type: 'quiz', isCorrect: true },
{ wordId: 1, type: 'quiz', isCorrect: true },
{ wordId: 1, type: 'quiz', isCorrect: false },
{ wordId: 2, type: 'quiz', isCorrect: false },
{ wordId: 2, type: 'quiz', isCorrect: false }
]);
assert.deepEqual(getErrorTopWords(10).map(word => word.id), [2, 1]);
assert.equal(getErrorTopWords(1).length, 1);
});
test('getErrorTopWords skips records whose word no longer exists', () => {
setRecords([{ wordId: 999, type: 'quiz', isCorrect: false }]);
assert.deepEqual(getErrorTopWords(10), []);
});