diff --git a/src/index.html b/src/index.html index 3a11862..e4aa46b 100644 --- a/src/index.html +++ b/src/index.html @@ -3,6 +3,7 @@ + diff --git a/src/js/pages/extract-batch-files.js b/src/js/pages/extract-batch-files.js index a904878..7f0eb01 100644 --- a/src/js/pages/extract-batch-files.js +++ b/src/js/pages/extract-batch-files.js @@ -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() { `).join('')} `; - - updateBatchSourceInfo(); } export function removeBatchFile(index) { diff --git a/src/js/pages/extract.js b/src/js/pages/extract.js index 2150e58..fa38d0f 100644 --- a/src/js/pages/extract.js +++ b/src/js/pages/extract.js @@ -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 = ' 提取单词'; +const BATCH_EXTRACT_BTN_LABEL = ' 批量提取'; +const EXTRACTING_BTN_LABEL = ' 提取中...'; 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) {
- + 已保存 ${state.mailEmails.length} 封邮件
@@ -60,7 +80,7 @@ export function renderExtract(el) {

粘贴邮件内容

- +
@@ -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 = ' 提取中...'; } + 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 = ' 提取单词'; } - 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 = ' 提取单词'; } - - 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 = ' 提取中...'; } + 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 = ' 批量提取'; } - 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 = ' 批量提取'; } + 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 ` { + const isExist = existingWords.has(r.word); + return `${escapeHtml(r.word)} ${r.emailCount}${isExist ? ' \u2713' : ''}`; - }).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 = ` 已提取到右侧面板`; + const statusEl = document.getElementById('batch-result'); + if (statusEl) statusEl.innerHTML = ` 已提取到右侧面板`; - updateSelectedCount(); - showToast(`批量提取完成:${results.length} 个单词${dedupMsg}`, 'success'); + updateSelectedCount(); + showToast(`批量提取完成:${results.length} 个单词${dedupMsg}`, 'success'); + } finally { + if (busyToken === batchBusyToken) resetBatchExtractButton(); + } } export function toggleBatchExtract(enabled) { diff --git a/src/js/pages/learn.js b/src/js/pages/learn.js index a56b5c2..6f4ee02 100644 --- a/src/js/pages/learn.js +++ b/src/js/pages/learn.js @@ -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]; diff --git a/src/js/pages/quiz-session.js b/src/js/pages/quiz-session.js index 88fddd1..de1d9ab 100644 --- a/src/js/pages/quiz-session.js +++ b/src/js/pages/quiz-session.js @@ -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; diff --git a/src/js/pages/reader.js b/src/js/pages/reader.js index 7f8d662..32fba5e 100644 --- a/src/js/pages/reader.js +++ b/src/js/pages/reader.js @@ -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'); diff --git a/src/js/pages/settings-data.js b/src/js/pages/settings-data.js index 7ad2797..95ed0e9 100644 --- a/src/js/pages/settings-data.js +++ b/src/js/pages/settings-data.js @@ -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; diff --git a/src/js/pages/settings.js b/src/js/pages/settings.js index e086548..b589d9f 100644 --- a/src/js/pages/settings.js +++ b/src/js/pages/settings.js @@ -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) diff --git a/src/js/pages/stats.js b/src/js/pages/stats.js index 194ee86..da71dd3 100644 --- a/src/js/pages/stats.js +++ b/src/js/pages/stats.js @@ -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); diff --git a/src/js/pages/words-autoplay.js b/src/js/pages/words-autoplay.js index 6ce0c24..95bffc1 100644 --- a/src/js/pages/words-autoplay.js +++ b/src/js/pages/words-autoplay.js @@ -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; diff --git a/src/js/pages/words.js b/src/js/pages/words.js index 8a82a8b..ccca5d6 100644 --- a/src/js/pages/words.js +++ b/src/js/pages/words.js @@ -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(); diff --git a/src/js/services/ai.js b/src/js/services/ai.js index 4eb36b9..746415b 100644 --- a/src/js/services/ai.js +++ b/src/js/services/ai.js @@ -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 返回的单词数据格式无效'); diff --git a/src/js/services/quiz-generator.js b/src/js/services/quiz-generator.js index 164335e..94379e4 100644 --- a/src/js/services/quiz-generator.js +++ b/src/js/services/quiz-generator.js @@ -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; } diff --git a/src/js/services/stats.js b/src/js/services/stats.js index 7453887..74272d4 100644 --- a/src/js/services/stats.js +++ b/src/js/services/stats.js @@ -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++; diff --git a/src/js/services/tts.js b/src/js/services/tts.js index 9ba5008..0969143 100644 --- a/src/js/services/tts.js +++ b/src/js/services/tts.js @@ -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(); } diff --git a/src/sw.js b/src/sw.js index 9874a9a..71c5f04 100644 --- a/src/sw.js +++ b/src/sw.js @@ -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') { diff --git a/src/test/ai.test.js b/src/test/ai.test.js new file mode 100644 index 0000000..44cd66e --- /dev/null +++ b/src/test/ai.test.js @@ -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); +}); diff --git a/src/test/quiz-generator.test.js b/src/test/quiz-generator.test.js index ccfc0bd..a7a128d 100644 --- a/src/test/quiz-generator.test.js +++ b/src/test/quiz-generator.test.js @@ -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); diff --git a/src/test/stats.test.js b/src/test/stats.test.js new file mode 100644 index 0000000..3265c5d --- /dev/null +++ b/src/test/stats.test.js @@ -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), []); +});