fix: 增强学习流程与数据处理的稳定性
- 确保异步提词操作结束后可靠恢复按钮状态 - 改进批量文件解析、清空操作与来源列表同步 - 防止复习提前完成,并拦截无效的会话跳转 - 校验 AI 翻译数据并保留 JSON 最外层结构 - 仅使用测验记录计算正确率和易错单词排名 - 限制自动播放设置范围,并在词表变化时停止播放 - 避免云端语音被中断后触发重复的降级朗读 - 改进 Service Worker 对临时网络故障的缓存回退 - 增加 AI 响应解析和测验统计相关测试
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
// 解析异常必须就地兜住:否则 finishFile() 不会执行,计数永远到不了文件总数,
|
||||
// 整个列表会一直停在「读取文件中...」,同批次其他文件也跟着卡死。
|
||||
try {
|
||||
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 };
|
||||
// 空正文的文件一律提示后跳过:静默丢弃会让用户面对一个空列表,无从判断是哪一步出了问题。
|
||||
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();
|
||||
}
|
||||
};
|
||||
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) {
|
||||
|
||||
+58
-12
@@ -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="将英文邮件内容粘贴到此处... 系统会自动提取英文单词(过滤常见停用词), 您可以选择需要的单词加入学习列表。" 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,11 +130,13 @@ 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; }
|
||||
|
||||
// 无论正常结束、提前退出还是抛错,按钮都要恢复,否则会一直停在禁用+转圈状态。
|
||||
try {
|
||||
const extracted = extractEnglishWords(text);
|
||||
if (!extracted.length) {
|
||||
if (extractBtn?.isConnected) { extractBtn.disabled = false; extractBtn.innerHTML = '<i class="fas fa-search"></i> 提取单词'; }
|
||||
showToast('未提取到有效英文单词', 'info');
|
||||
return;
|
||||
}
|
||||
@@ -141,12 +167,14 @@ export async function doExtractFromEmail() {
|
||||
}
|
||||
|
||||
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');
|
||||
} finally {
|
||||
if (busyToken === extractBusyToken) resetExtractButton();
|
||||
}
|
||||
}
|
||||
|
||||
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,6 +247,8 @@ export async function addSelectedWords() {
|
||||
const handled = new Set();
|
||||
let nextId = getNextId(); // 循环外取一次基准 id,循环内自增,避免逐词全量重扫
|
||||
|
||||
// 中途放弃(切换词库、页面重渲染等)也必须解禁按钮,否则「翻译并添加选中」会一直不可点。
|
||||
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);
|
||||
@@ -278,7 +309,6 @@ export async function addSelectedWords() {
|
||||
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');
|
||||
@@ -292,9 +322,22 @@ export async function addSelectedWords() {
|
||||
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,11 +376,13 @@ 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; }
|
||||
|
||||
// 与单封提词同理:按钮状态放在 finally 里恢复,任何提前退出都不会留下卡住的转圈按钮。
|
||||
try {
|
||||
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;
|
||||
}
|
||||
@@ -363,7 +409,6 @@ export async function doBatchExtract() {
|
||||
}
|
||||
|
||||
if (!isCurrentExtractGeneration(generation, chipsContainer)) return;
|
||||
if (batchBtn?.isConnected) { batchBtn.disabled = false; batchBtn.innerHTML = '<i class="fas fa-search-plus"></i> 批量提取'; }
|
||||
|
||||
batchResults = results;
|
||||
batchMode = true;
|
||||
@@ -372,8 +417,6 @@ export async function doBatchExtract() {
|
||||
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}"
|
||||
@@ -392,6 +435,9 @@ export async function doBatchExtract() {
|
||||
|
||||
updateSelectedCount();
|
||||
showToast(`批量提取完成:${results.length} 个单词${dedupMsg}`, 'success');
|
||||
} finally {
|
||||
if (busyToken === batchBusyToken) resetBatchExtractButton();
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleBatchExtract(enabled) {
|
||||
|
||||
+21
-7
@@ -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]) {
|
||||
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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 返回的单词数据格式无效');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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), []);
|
||||
});
|
||||
Reference in New Issue
Block a user