Files
AI_English/src/js/pages/reader.js
T
2026-07-19 21:09:58 +08:00

372 lines
16 KiB
JavaScript

import { STOP_WORDS } from '../data/stopwords.js';
import { state } from '../core/state.js';
import { saveSchedule, saveWords } from '../core/storage.js';
import { showToast } from '../ui/toast.js';
import { speak } from '../services/tts.js';
import { callAI, translateWord } from '../services/ai.js';
import { initWordSchedule } from '../services/ebbinghaus.js';
import { getMastery } from '../services/stats.js';
import { escapeHtml, formatFormsHtml } from '../ui/dom.js';
import { findWordContext } from '../services/extractor.js';
import { getNextId } from './words.js';
import { beginMailLearnRequest, isCurrentMailLearnRequest } from './mail-learn.js';
export let readerTranslations = {};
export let readerSentences = [];
let readerSessionGeneration = 0;
// ==================== Full-Text Reading Mode ====================
export function splitSentences(text) {
const raw = text.split(/(?<=[.!?])\s+|(?:\r?\n){2,}/);
return raw.map(s => s.trim()).filter(s => s.length > 0);
}
export function isStopWord(w) { return STOP_WORDS.has(w.toLowerCase()); }
export function buildReadingSentenceHtml(sentence) {
const knownMap = {};
state.words.forEach(w => { knownMap[w.english.toLowerCase()] = w; });
const tokens = sentence.split(/(\s+|[,.;:!?'"()\[\]{}<>\/\\—–\-])/);
let html = '';
tokens.forEach(tok => {
const clean = tok.toLowerCase().replace(/[^a-z'-]/g, '');
const word = knownMap[clean];
if (word && clean.length >= 2) {
html += `<span class="reader-word known" data-word-id="${word.id}" data-action="reader.showReaderWordTip" data-element-arg="0" data-arg1="${word.id}" title="${escapeHtml(word.chinese || '')}">${escapeHtml(tok)}</span>`;
} else if (/^[a-zA-Z]{2,}$/.test(clean) && !isStopWord(clean)) {
html += `<span class="reader-word unknown" data-action="reader.showReaderUnknownTip" data-element-arg="0" data-arg1="${escapeHtml(clean)}" title="点击查看释义">${escapeHtml(tok)}</span>`;
} else {
html += escapeHtml(tok);
}
});
return html;
}
export async function startFullReading() {
const sessionGeneration = ++readerSessionGeneration;
const text = document.getElementById('mail-learn-input').value.trim();
if (!text) { showToast('请粘贴邮件内容', 'error'); return; }
const sentences = splitSentences(text);
if (!sentences.length) { showToast('无法解析邮件内容', 'error'); return; }
const resultEl = document.getElementById('mail-learn-result');
const request = beginMailLearnRequest(resultEl);
resultEl.innerHTML = '<div class="loading"><div class="spinner"></div> 正在准备全文阅读...</div>';
let translations = {};
const hasAI = state.settings.apiKey && state.settings.apiUrl && state.settings.model;
if (hasAI) {
try {
const numbered = sentences.map((s, i) => `${i + 1}. ${s}`).join('\n');
const content = await callAI([
{ role: 'system', content: '你是翻译助手。用户给出编号的英文句子,请逐句翻译为中文,严格保持编号格式返回。只返回翻译结果,每行格式:编号. 中文翻译' },
{ role: 'user', content: numbered }
]);
if (sessionGeneration !== readerSessionGeneration || !isCurrentMailLearnRequest(request)) return;
content.split('\n').forEach(line => {
const m = line.match(/^(\d+)\.\s*(.+)/);
if (m) translations[Number(m[1]) - 1] = m[2].trim();
});
} catch (err) {
if (sessionGeneration !== readerSessionGeneration || !isCurrentMailLearnRequest(request)) return;
showToast('AI 翻译失败,阅读模式仍可使用:' + err.message, 'warning');
}
}
if (sessionGeneration !== readerSessionGeneration || !isCurrentMailLearnRequest(request) || !resultEl.isConnected) return;
const knownCount = countKnownWordsInText(text);
const totalUniqueWords = countUniqueWords(text);
const coveragePct = totalUniqueWords > 0 ? Math.round(knownCount / totalUniqueWords * 100) : 0;
resultEl.innerHTML = `
<div class="card reader-card">
<div class="reader-header">
<h3><i class="fas fa-book-reader"></i> 全文阅读</h3>
<div class="reader-stats">
<span class="badge badge-primary">${sentences.length} 句</span>
<span class="badge badge-success">词库覆盖 ${coveragePct}%</span>
<button class="btn btn-sm btn-ghost" data-action="reader.toggleAllTranslations" title="显示/隐藏全部翻译"><i class="fas fa-eye"></i> 全部翻译</button>
</div>
</div>
<div class="reader-legend">
<span><span class="reader-dot known-dot"></span> 词库中的单词(点击查看详情)</span>
<span><span class="reader-dot unknown-dot"></span> 生词(点击查询)</span>
<span><i class="fas fa-hand-pointer" style="font-size:12px"></i> 点击句子查看翻译</span>
</div>
<div class="reader-body" id="reader-body">
${sentences.map((s, i) => `
<div class="reader-sentence-block" data-idx="${i}">
<div class="reader-sentence-num">${i + 1}</div>
<div class="reader-sentence-content">
<div class="reader-sentence-en" data-action="reader.toggleSentenceTranslation" data-arg0="${i}">${buildReadingSentenceHtml(s)}</div>
<div class="reader-sentence-cn" id="reader-cn-${i}" style="display:none">
${translations[i] ? escapeHtml(translations[i]) : '<span class="text-muted">点击上方英文句子查看翻译</span>'}
</div>
<div class="reader-sentence-tools">
<button class="btn btn-sm btn-ghost" data-action="reader.speakSentence" data-arg0="${i}" title="朗读"><i class="fas fa-volume-up"></i></button>
<button class="btn btn-sm btn-ghost" data-action="reader.toggleSentenceTranslation" data-arg0="${i}" title="翻译"><i class="fas fa-language"></i></button>
${!translations[i] && hasAI ? `<button class="btn btn-sm btn-ghost" id="ai-translate-btn-${i}" data-action="reader.aiTranslateSentence" data-arg0="${i}" data-element-arg="1" title="AI翻译"><i class="fas fa-magic"></i></button>` : ''}
</div>
</div>
</div>`).join('')}
</div>
</div>
<div id="reader-word-tooltip" class="reader-tooltip" style="display:none"></div>
`;
readerTranslations = translations;
readerSentences = sentences;
}
export function countKnownWordsInText(text) {
const knownSet = new Set(state.words.map(w => w.english.toLowerCase()));
const tokens = text.match(/\b[a-zA-Z]{2,}\b/g) || [];
const unique = new Set(tokens.map(t => t.toLowerCase()));
let count = 0;
unique.forEach(w => { if (!STOP_WORDS.has(w) && knownSet.has(w)) count++; });
return count;
}
export function countUniqueWords(text) {
const tokens = text.match(/\b[a-zA-Z]{2,}\b/g) || [];
const unique = new Set(tokens.map(t => t.toLowerCase()));
let count = 0;
unique.forEach(w => { if (!STOP_WORDS.has(w)) count++; });
return count;
}
export function speakSentence(idx) {
const sentences = readerSentences;
if (sentences && sentences[idx]) speak(sentences[idx]);
}
export function toggleSentenceTranslation(idx) {
const el = document.getElementById(`reader-cn-${idx}`);
if (!el) return;
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
export function toggleAllTranslations() {
const els = document.querySelectorAll('[id^="reader-cn-"]');
const anyHidden = [...els].some(e => e.style.display === 'none');
els.forEach(e => { e.style.display = anyHidden ? 'block' : 'none'; });
}
export async function aiTranslateSentence(idx, btnEl) {
const sessionGeneration = readerSessionGeneration;
const sentence = readerSentences?.[idx];
if (!sentence) return;
const cnEl = document.getElementById(`reader-cn-${idx}`);
if (!cnEl) return;
if (btnEl) btnEl.disabled = true;
const isCurrentSentence = () => sessionGeneration === readerSessionGeneration
&& readerSentences?.[idx] === sentence
&& cnEl.isConnected
&& (!btnEl || btnEl.isConnected);
try {
const content = await callAI([
{ role: 'system', content: '你是翻译助手,只返回中文翻译结果,不要任何解释。' },
{ role: 'user', content: `翻译为中文:${sentence}` }
]);
if (!isCurrentSentence()) return;
const translation = content.trim();
cnEl.textContent = translation;
cnEl.style.display = 'block';
readerTranslations[idx] = translation;
if (btnEl) btnEl.remove();
} catch (err) {
if (!isCurrentSentence()) return;
showToast('翻译失败: ' + err.message, 'error');
if (btnEl) btnEl.disabled = false;
}
}
export function positionReaderTooltip(tip, el) {
const margin = 8;
const gap = 6;
const rect = el.getBoundingClientRect();
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const tipWidth = tip.offsetWidth;
const tipHeight = tip.offsetHeight;
const left = Math.min(
Math.max(rect.left, margin),
Math.max(margin, window.innerWidth - tipWidth - margin)
);
const top = rect.bottom + gap + tipHeight <= window.innerHeight
? rect.bottom + scrollTop + gap
: Math.max(scrollTop + margin, rect.top + scrollTop - tipHeight - gap);
tip.style.left = `${left}px`;
tip.style.top = `${top}px`;
}
let readerTipCloseHandler = null;
let readerTipCloseTimer = null;
let readerTipSeq = 0;
function bindReaderTipClose(tip, anchorEl) {
if (readerTipCloseTimer !== null) {
clearTimeout(readerTipCloseTimer);
readerTipCloseTimer = null;
}
if (readerTipCloseHandler) {
document.removeEventListener('click', readerTipCloseHandler);
readerTipCloseHandler = null;
}
const handler = (e) => {
if (!tip.contains(e.target) && e.target !== anchorEl) {
++readerTipSeq;
tip.style.display = 'none';
document.removeEventListener('click', handler);
if (readerTipCloseHandler === handler) readerTipCloseHandler = null;
}
};
readerTipCloseHandler = handler;
readerTipCloseTimer = setTimeout(() => {
readerTipCloseTimer = null;
if (readerTipCloseHandler === handler) {
document.addEventListener('click', handler);
}
}, 10);
}
export function showReaderWordTip(el, wordId) {
++readerTipSeq;
const word = state.words.find(w => w.id === wordId);
if (!word) return;
const tip = document.getElementById('reader-word-tooltip');
if (!tip) return;
const mastery = getMastery(word.id);
const masteryText = mastery === 'mastered' ? '已掌握' : mastery === 'learning' ? '学习中' : '新词';
const badgeClass = mastery === 'mastered' ? 'badge-success' : mastery === 'learning' ? 'badge-warning' : 'badge-primary';
tip.innerHTML = `
<div class="reader-tip-header">
<strong>${escapeHtml(word.english)}</strong>
<span style="color:var(--text-muted);font-size:12px">${escapeHtml(word.phonetic || '')}</span>
<span class="badge ${badgeClass}" style="font-size:11px;padding:2px 6px">${masteryText}</span>
<button class="btn btn-sm btn-ghost" data-action="reader.speak" data-arg0="${escapeHtml(word.english)}"><i class="fas fa-volume-up"></i></button>
</div>
<div class="reader-tip-cn">${escapeHtml(word.chinese || '')}</div>
${word.forms ? `<div class="reader-tip-forms">${formatFormsHtml(word.forms)}</div>` : ''}
${word.example ? `<div class="reader-tip-example"><em>${escapeHtml(word.example.en)}</em><br><span>${escapeHtml(word.example.cn)}</span></div>` : ''}
`;
tip.style.display = 'block';
positionReaderTooltip(tip, el);
bindReaderTipClose(tip, el);
}
export async function showReaderUnknownTip(el, word) {
const tip = document.getElementById('reader-word-tooltip');
if (!tip) return;
const seq = ++readerTipSeq;
tip.innerHTML = `
<div class="reader-tip-header">
<strong>${escapeHtml(word)}</strong>
<button class="btn btn-sm btn-ghost" data-action="reader.speak" data-arg0="${escapeHtml(word)}"><i class="fas fa-volume-up"></i></button>
</div>
<div class="reader-tip-cn" style="color:var(--text-muted)">查询中...</div>
`;
tip.style.display = 'block';
positionReaderTooltip(tip, el);
bindReaderTipClose(tip, el);
const hasAI = state.settings.apiKey && state.settings.apiUrl && state.settings.model;
if (hasAI) {
try {
const result = await translateWord(word);
if (seq !== readerTipSeq) return;
tip.innerHTML = `
<div class="reader-tip-header">
<strong>${escapeHtml(result.english || word)}</strong>
<span style="color:var(--text-muted);font-size:12px">${escapeHtml(result.phonetic || '')}</span>
<button class="btn btn-sm btn-ghost" data-action="reader.speak" data-arg0="${escapeHtml(word)}"><i class="fas fa-volume-up"></i></button>
</div>
<div class="reader-tip-cn">${escapeHtml(result.chinese || '')}</div>
${result.forms ? `<div class="reader-tip-forms">${formatFormsHtml(result.forms)}</div>` : ''}
${result.example ? `<div class="reader-tip-example"><em>${escapeHtml(result.example.en)}</em><br><span>${escapeHtml(result.example.cn)}</span></div>` : ''}
<button class="btn btn-sm btn-primary" style="margin-top:8px;width:100%" data-action="reader.addWordFromReader" data-arg0="${escapeHtml(word)}" data-element-arg="1">
<i class="fas fa-plus"></i> 加入词库
</button>
`;
positionReaderTooltip(tip, el);
} catch (err) {
if (seq !== readerTipSeq) return;
const cnEl = tip.querySelector('.reader-tip-cn');
if (cnEl) cnEl.textContent = '查询失败: ' + err.message;
}
} else {
const cnEl = tip.querySelector('.reader-tip-cn');
if (cnEl) cnEl.textContent = '请先配置 AI 设置以启用翻译';
}
}
export async function addWordFromReader(word, btnEl) {
const key = String(word || '').trim().toLowerCase();
const libraryId = state.activeWordLibrary;
const sessionGeneration = readerSessionGeneration;
const wordExists = () => state.words.some(item => String(item.english || '').trim().toLowerCase() === key);
const isCurrentOperation = () => state.activeWordLibrary === libraryId
&& readerSessionGeneration === sessionGeneration
&& (!btnEl || btnEl.isConnected);
if (!key) return false;
if (wordExists()) {
showToast(`「${word}」已在词库中`, 'info');
if (btnEl) { btnEl.disabled = true; btnEl.textContent = '已添加 ✓'; }
return true;
}
if (btnEl) { btnEl.disabled = true; btnEl.textContent = '添加中...'; }
try {
const result = await translateWord(word);
if (!isCurrentOperation()) return false;
if (wordExists()) {
showToast(`「${word}」已在词库中`, 'info');
if (btnEl) { btnEl.textContent = '已添加 ✓'; btnEl.classList.remove('btn-primary'); btnEl.classList.add('btn-success'); }
return true;
}
const emailText = document.getElementById('mail-learn-input')?.value || '';
const contextSentence = findWordContext(word, emailText);
const emailTitle = emailText.slice(0, 40).replace(/\n/g, ' ') + '...';
const previousWords = state.words.slice();
const previousSchedule = { ...state.schedule };
const id = getNextId();
state.words.push({
id,
...result,
sourceContext: contextSentence ? { emailTitle, sentence: contextSentence } : null
});
initWordSchedule(id, false);
if (!(saveWords() && saveSchedule())) {
state.words = previousWords;
state.schedule = previousSchedule;
saveWords();
saveSchedule();
throw new Error('本地存储写入失败');
}
showToast(`已添加「${word}」到词库`, 'success');
if (btnEl) { btnEl.textContent = '已添加 \u2713'; btnEl.classList.remove('btn-primary'); btnEl.classList.add('btn-success'); }
return true;
} catch (err) {
if (!isCurrentOperation()) return false;
showToast('添加失败: ' + err.message, 'error');
if (btnEl) { btnEl.disabled = false; btnEl.textContent = '重试'; }
return false;
}
}