372 lines
16 KiB
JavaScript
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;
|
|
}
|
|
}
|
|
|