/* global state */ let selectedWords = []; let currentRecord = null; // 正在查看/编辑的历史记录 let editing = false; const $ = (sel) => document.querySelector(sel); /* ---------- 视图切换 ---------- */ function switchView(view) { $('#view-query').classList.toggle('hidden', view !== 'query'); $('#view-history').classList.toggle('hidden', view !== 'history'); $('#nav-query').classList.toggle('active', view === 'query'); $('#nav-history').classList.toggle('active', view === 'history'); } $('#nav-query').addEventListener('click', () => switchView('query')); $('#nav-history').addEventListener('click', () => { switchView('history'); currentRecord = null; $('#detail-area').classList.add('hidden'); loadHistory(); loadWordLib(); }); /* ---------- 单词标签 ---------- */ /* ---------- 句子分词 + 点击选词 ---------- */ function tokenizeSentence(text) { const seen = new Set(); const tokens = []; for (const m of text.matchAll(/[A-Za-z]+(?:['\u2019][A-Za-z]+)*|[A-Za-z]+/g)) { const w = m[0].toLowerCase(); if (seen.has(w)) continue; // 只按出现顺序去重,不过滤虚词 seen.add(w); tokens.push(w); } return tokens; } function renderTokens() { const box = $('#word-tokens'); const sentence = $('#sentence').value; const tokens = tokenizeSentence(sentence); box.innerHTML = ''; // 清理已不在句中的选中词 selectedWords = selectedWords.filter((w) => tokens.includes(w)); if (tokens.length === 0) { box.innerHTML = '输入句子后自动分词'; return; } tokens.forEach((w) => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'word-token' + (selectedWords.includes(w) ? ' selected' : ''); btn.textContent = w; btn.addEventListener('click', () => { const idx = selectedWords.indexOf(w); if (idx !== -1) { selectedWords.splice(idx, 1); } else if (selectedWords.length >= 5) { setStatus('一次最多选择 5 个单词', 'error'); return; } else { selectedWords.push(w); setStatus(''); } renderTokens(); }); box.appendChild(btn); }); } let tokenTimer; $('#sentence').addEventListener('input', () => { clearTimeout(tokenTimer); tokenTimer = setTimeout(renderTokens, 250); // 输入停顿后自动分词 }); $('#sentence').addEventListener('blur', renderTokens); function setStatus(msg, type = 'info') { const el = $('#query-status'); el.textContent = msg || ''; el.className = `status ${type}`; if (!msg) el.classList.add('hidden'); else el.classList.remove('hidden'); } /* ---------- 发音(浏览器 TTS) ---------- */ function speak(text) { const u = new SpeechSynthesisUtterance(text); u.lang = 'en-US'; u.rate = 0.9; speechSynthesis.cancel(); speechSynthesis.speak(u); } /* ---------- 查询 ---------- */ $('#query-btn').addEventListener('click', async () => { const sentence = $('#sentence').value.trim(); if (!sentence) return setStatus('请先粘贴英语句子', 'error'); if (selectedWords.length === 0) return setStatus('请先点击分词列表选择要查询的单词', 'error'); const btn = $('#query-btn'); btn.disabled = true; const streamText = $('#stream-text'); streamText.textContent = ''; $('#stream-box').classList.remove('hidden'); setStatus('正在连接 AI…', 'info'); try { const res = await fetch('/api/query/stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sentence, words: selectedWords }), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || '查询失败'); } setStatus('AI 生成中,请稍候…'); let record = null; const reader = res.body.getReader(); const decoder = new TextDecoder(); let buf = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); const events = buf.split('\n\n'); buf = events.pop(); // 最后一段可能不完整,留到下一轮 for (const evt of events) { const line = evt.trim(); if (!line.startsWith('data:')) continue; const msg = JSON.parse(line.slice(5)); if (msg.type === 'delta') { appendStreamText(msg.text); } else if (msg.type === 'done') { record = msg.record; } else if (msg.type === 'error') { throw new Error(msg.message); } } } if (!record) throw new Error('生成中断,未返回结果'); setStatus('查询完成,已保存到历史记录 ✓'); historyPage = 1; // 新记录在最前,重置到第 1 页 wordLibPage = 1; currentRecord = record; editing = false; renderResult($('#result-area'), record, false, true); } catch (err) { setStatus(err.message, 'error'); } finally { btn.disabled = false; $('#stream-box').classList.add('hidden'); } }); /* 流式输出窗口:追加文本并自动滚到底部 */ function appendStreamText(text) { const pre = $('#stream-text'); pre.textContent += text; pre.parentElement.scrollTop = pre.parentElement.scrollHeight; } /* ---------- 结果渲染 ---------- */ function exampleLi(ex, word, pathPrefix, editable) { const li = document.createElement('li'); if (editable) { li.className = 'editing'; li.innerHTML = ` `; li.querySelector('.ex-del').addEventListener('click', () => li.remove()); } else { li.innerHTML = `
${highlight(ex.en || '', word)}
${escapeHtml(ex.zh || '')}
`; } return li; } function senseBlock(title, examples, word, pathPrefix, editable) { const block = document.createElement('div'); block.className = 'sense-block'; const titleEl = document.createElement('div'); titleEl.className = 'sense-title'; if (editable) { titleEl.innerHTML = ``; titleEl.querySelector('input').dataset.path = pathPrefix + '.sense'; } else { titleEl.textContent = title; } block.appendChild(titleEl); const ul = document.createElement('ul'); ul.className = 'example-list'; examples.forEach((ex, i) => { ul.appendChild(exampleLi(ex, word, `${pathPrefix}.examples.${i}`, editable)); }); block.appendChild(ul); if (editable) { const addBtn = document.createElement('button'); addBtn.className = 'btn small ghost'; addBtn.textContent = '+ 添加例句'; addBtn.addEventListener('click', () => { const idx = ul.querySelectorAll('li').length; ul.appendChild(exampleLi({ en: '', zh: '' }, word, `${pathPrefix}.examples.${idx}`, true)); }); block.appendChild(addBtn); if (pathPrefix.includes('other_senses')) { const delSense = document.createElement('button'); delSense.className = 'btn small danger'; delSense.style.marginLeft = '8px'; delSense.textContent = '删除此释义'; delSense.addEventListener('click', () => block.remove()); block.appendChild(delSense); } } return block; } /* ---------- 词根拆解渲染 ---------- */ function rootToLine(r) { return [r.part, r.type, r.phonetic, r.meaning].map((s) => (s || '').trim()).join(' | '); } function renderRoots(container, w, wp, editable) { const block = document.createElement('div'); block.className = 'sense-block roots-block'; const label = document.createElement('div'); label.style.cssText = 'font-size:12px;color:var(--muted);margin-bottom:4px'; label.textContent = '🧩 词根拆解(点击读出,练习拼读)'; block.appendChild(label); if (editable) { const ta = document.createElement('textarea'); ta.className = 'roots-edit'; ta.dataset.roots = `${wp}.roots`; ta.rows = Math.max(2, (w.roots || []).length); ta.placeholder = '每行一个部分:部分 | 类型 | 音标 | 含义,如:ana- | 前缀 | /əˈnæ/ | 向上、全面'; ta.value = (w.roots || []).map(rootToLine).join('\n'); block.appendChild(ta); } else if ((w.roots || []).length) { const row = document.createElement('div'); row.className = 'roots-row'; const panel = document.createElement('div'); panel.className = 'root-panel hidden'; (w.roots || []).forEach((r) => { const chip = document.createElement('span'); chip.className = 'root-chip'; chip.title = '点击查看该词根在已学单词中的体现'; chip.innerHTML = ` ${escapeHtml(r.part || '')} ${escapeHtml(r.phonetic || '')} ${escapeHtml(r.meaning || '')} ${escapeHtml(r.type || '')} `; // 🔊 只负责发音 chip.querySelector('.root-speak').addEventListener('click', (e) => { e.stopPropagation(); speak((r.part || '').replace(/[-•·]/g, '')); }); // 点击词根:展开/收起该词根的历史词例面板 chip.addEventListener('click', () => toggleRootPanel(panel, r.part, w.word)); row.appendChild(chip); }); block.appendChild(row); block.appendChild(panel); } else { block.appendChild(Object.assign(document.createElement('div'), { textContent: '(无拆解数据)', className: 'root-phon' })); } container.appendChild(block); } /* 点击词根:按需加载该词根在已学单词中的全部体现(选择性触发) */ function normalizeRootKey(part) { return String(part || '').replace(/[^a-zA-Z]/g, '').toLowerCase(); } async function toggleRootPanel(panel, part, currentWord) { if (!panel.classList.contains('hidden')) { panel.classList.add('hidden'); panel.innerHTML = ''; return; } panel.classList.remove('hidden'); panel.innerHTML = '
加载词根历史中…
'; const key = normalizeRootKey(part); try { const res = await fetch(`/api/roots/${encodeURIComponent(key)}`); if (!res.ok) throw new Error('无历史记录'); const r = await res.json(); panel.innerHTML = ''; const title = document.createElement('div'); title.className = 'root-panel-title'; let titleHtml = `🔍 词根 ${escapeHtml(r.display || part)}`; if (r.phonetic) titleHtml += ` ${escapeHtml(r.phonetic)}`; title.innerHTML = titleHtml; panel.appendChild(title); const hint = document.createElement('div'); hint.className = 'root-panel-note'; hint.textContent = '先回想它在各词中是什么意思,点击单词揭晓 👇'; panel.appendChild(hint); // 含义默认隐藏:点击单词才揭晓(先猜后看,帮助大脑抽象词根义) const row = document.createElement('div'); row.className = 'root-group-words'; (r.words || []).forEach((e) => { const b = document.createElement('button'); b.className = 'root-word'; b.title = '点击揭晓含义并发音'; b.innerHTML = `${escapeHtml(e.word)} ${escapeHtml(e.phonetic || '')} ${escapeHtml(e.meaning || '(未标注)')}${(e.word_senses || []).length ? '📌 词义:' + e.word_senses.map((s) => escapeHtml(s)).join(';') + '' : ''}`; b.addEventListener('click', () => { const revealed = b.classList.toggle('revealed'); if (revealed) speak(e.word); }); row.appendChild(b); }); panel.appendChild(row); const others = (r.words || []).filter((e) => e.word !== currentWord).length; const note = document.createElement('div'); note.className = 'root-panel-note'; note.textContent = others > 0 ? `除当前单词外,还有 ${others} 个已学单词包含此词根` : '目前只有当前单词包含此词根,继续查询积累吧~'; panel.appendChild(note); } catch (err) { panel.innerHTML = `
暂无该词根的历史记录
`; } } function wordCard(w, idx, editable) { const card = document.createElement('div'); card.className = 'word-card' + (editable ? ' editing' : ''); const wp = `words.${idx}`; const head = document.createElement('div'); head.className = 'word-head'; if (editable) { head.innerHTML = ` `; head.querySelector('.speak').addEventListener('click', () => { speak(head.querySelector(`[data-path="${wp}.word"]`).value); }); } else { head.innerHTML = ` ${escapeHtml(w.word || '')} ${escapeHtml(w.phonetic || '')} `; head.querySelector('.speak-btn').addEventListener('click', () => speak(w.word)); } card.appendChild(head); const body = document.createElement('div'); body.className = 'word-body'; // 词根拆解(发音拼读训练) renderRoots(body, w, wp, editable); // 当前语境释义 const ctxBlock = document.createElement('div'); ctxBlock.className = 'sense-block'; const ctxTitle = document.createElement('div'); ctxTitle.className = 'sense-title'; if (editable) { ctxTitle.innerHTML = ``; const lbl = document.createElement('div'); lbl.style.cssText = 'font-size:12px;color:var(--muted);margin-bottom:4px'; lbl.textContent = '📌 原句语境中的释义'; ctxBlock.appendChild(lbl); } else { ctxTitle.textContent = '📌 原句语境中的释义:' + (w.context_sense || ''); } ctxBlock.appendChild(ctxTitle); const ctxUl = document.createElement('ul'); ctxUl.className = 'example-list'; (w.context_examples || []).forEach((ex, i) => { ctxUl.appendChild(exampleLi(ex, w.word, `${wp}.context_examples.${i}`, editable)); }); ctxBlock.appendChild(ctxUl); if (editable) { const addBtn = document.createElement('button'); addBtn.className = 'btn small ghost'; addBtn.textContent = '+ 添加例句'; addBtn.addEventListener('click', () => { const n = ctxUl.querySelectorAll('li').length; ctxUl.appendChild(exampleLi({ en: '', zh: '' }, w.word, `${wp}.context_examples.${n}`, true)); }); ctxBlock.appendChild(addBtn); } body.appendChild(ctxBlock); // 其他释义 (w.other_senses || []).forEach((s, si) => { body.appendChild(senseBlock(s.sense || '', s.examples || [], w.word, `${wp}.other_senses.${si}`, editable)); }); if (editable) { const addSense = document.createElement('button'); addSense.className = 'btn small secondary'; addSense.textContent = '+ 添加其他释义'; addSense.addEventListener('click', () => { const si = (w.other_senses || []).length; w.other_senses = w.other_senses || []; w.other_senses.push({ sense: '', examples: [{ en: '', zh: '' }] }); body.insertBefore( senseBlock('', [{ en: '', zh: '' }], w.word, `${wp}.other_senses.${body.querySelectorAll('.sense-block').length - 1}`, true), addSense ); }); body.appendChild(addSense); } card.appendChild(body); return card; } function renderResult(container, record, editable, showSentence) { container.innerHTML = ''; // 先清空,避免多次查询结果叠加 if (showSentence) { const sc = document.createElement('div'); sc.className = 'card sentence-card'; const tr = record.result?.sentence_translation || ''; sc.innerHTML = `
${escapeHtml(record.sentence || '')}
${tr ? '
' + escapeHtml(tr) + '
' : ''}`; container.appendChild(sc); } (record.result?.words || []).forEach((w, i) => { container.appendChild(wordCard(w, i, editable)); }); } /* ---------- 历史记录 ---------- */ const PAGE_SIZE = 10; let historyPage = 1; let wordLibPage = 1; let historySearch = ''; let wordLibSearch = ''; /* 搜索输入(300ms 防抖,输入变化重置到第 1 页) */ function bindSearch(inputId, get, set, reload) { let timer; $(inputId).addEventListener('input', (e) => { clearTimeout(timer); timer = setTimeout(() => { if (get() === e.target.value.trim()) return; set(e.target.value.trim()); reload(); }, 300); }); } bindSearch( '#history-search', () => historySearch, (v) => { historySearch = v; historyPage = 1; }, loadHistory ); bindSearch( '#word-lib-search', () => wordLibSearch, (v) => { wordLibSearch = v; wordLibPage = 1; }, loadWordLib ); /* 通用分页控件 */ function renderPager(container, data, onPage) { container.innerHTML = ''; const info = document.createElement('span'); info.className = 'pager-info'; info.textContent = `共 ${data.total} 条 · 第 ${data.page}/${data.total_pages} 页`; container.appendChild(info); const prev = mkBtn('‹ 上一页', 'btn ghost small', () => onPage(data.page - 1)); prev.disabled = data.page <= 1; const next = mkBtn('下一页 ›', 'btn ghost small', () => onPage(data.page + 1)); next.disabled = data.page >= data.total_pages; container.appendChild(prev); container.appendChild(next); } async function loadHistory() { const q = historySearch ? `&q=${encodeURIComponent(historySearch)}` : ''; const res = await fetch(`/api/history?page=${historyPage}&size=${PAGE_SIZE}${q}`); const data = await res.json(); const list = data.items; const box = $('#history-list'); box.innerHTML = ''; if (list.length === 0) { box.innerHTML = historySearch ? '
没有匹配的查询记录
' : '
暂无查询记录,去查询页试试吧~
'; renderPager($('#history-pager'), data, () => {}); return; } list.forEach((r) => { const item = document.createElement('div'); item.className = 'history-item'; item.innerHTML = `
${escapeHtml(r.sentence)}
${r.words.map(escapeHtml).join('、')}
${escapeHtml(r.created_at)}
`; item.addEventListener('click', () => openDetail(r.id)); box.appendChild(item); }); renderPager($('#history-pager'), data, (p) => { historyPage = p; loadHistory(); }); } async function openDetail(id) { const res = await fetch(`/api/history/${id}`); if (!res.ok) return alert('加载记录失败'); currentRecord = await res.json(); editing = false; $('#detail-area').classList.remove('hidden'); renderDetail(); $('#detail-area').scrollIntoView({ behavior: 'smooth' }); } /* ---------- 我的词库(按单词维度) ---------- */ async function loadWordLib() { const q = wordLibSearch ? `&q=${encodeURIComponent(wordLibSearch)}` : ''; const res = await fetch(`/api/words?page=${wordLibPage}&size=${PAGE_SIZE}${q}`); const data = await res.json(); const list = data.items; const box = $('#word-lib'); box.innerHTML = ''; if (list.length === 0) { box.innerHTML = wordLibSearch ? '
没有匹配的单词
' : '
词库还是空的,去查询页添加单词吧~
'; renderPager($('#word-lib-pager'), data, () => {}); return; } list.forEach((r) => { const item = document.createElement('div'); item.className = 'word-lib-item'; item.innerHTML = `
${escapeHtml(r.word)} ${escapeHtml(r.phonetic || '')}
${r.sense_count} 个释义 · 出现 ${r.occurrence_count} 次
`; item.addEventListener('click', () => openWordDetail(r.word)); box.appendChild(item); }); renderPager($('#word-lib-pager'), data, (p) => { wordLibPage = p; loadWordLib(); }); } async function openWordDetail(word) { const res = await fetch(`/api/words/${encodeURIComponent(word)}`); if (!res.ok) return alert('加载失败'); const w = await res.json(); const area = $('#word-detail-area'); area.classList.remove('hidden'); area.innerHTML = ''; const toolbar = document.createElement('div'); toolbar.className = 'detail-toolbar'; toolbar.appendChild(mkBtn('← 返回词库列表', 'btn ghost small', () => area.classList.add('hidden'))); area.appendChild(toolbar); const card = document.createElement('div'); card.className = 'word-card'; const head = document.createElement('div'); head.className = 'word-head'; head.innerHTML = ` ${escapeHtml(w.word)} ${escapeHtml(w.phonetic || '')} `; head.querySelector('.speak-btn').addEventListener('click', () => speak(w.word)); card.appendChild(head); const body = document.createElement('div'); body.className = 'word-body'; renderRoots(body, w, 'words', false); (w.senses || []).forEach((s, si) => { const block = document.createElement('div'); block.className = 'sense-block'; const title = document.createElement('div'); title.className = 'sense-title'; title.textContent = `${si + 1}. ${s.sense}`; block.appendChild(title); const ul = document.createElement('ul'); ul.className = 'example-list'; (s.examples || []).forEach((e) => { const li = document.createElement('li'); li.innerHTML = `
${e.from_sentence ? '📌 ' : ''}${highlight(e.en, w.word)}
${escapeHtml(e.zh || '')}
`; ul.appendChild(li); }); block.appendChild(ul); body.appendChild(block); }); card.appendChild(body); area.appendChild(card); area.scrollIntoView({ behavior: 'smooth' }); } function renderDetail() { const area = $('#detail-area'); area.classList.remove('hidden'); area.innerHTML = ''; // 工具栏 const toolbar = document.createElement('div'); toolbar.className = 'detail-toolbar'; const backBtn = mkBtn('← 返回列表', 'btn ghost small', () => { area.classList.add('hidden'); currentRecord = null; loadHistory(); }); toolbar.appendChild(backBtn); if (!editing) { toolbar.appendChild(mkBtn('✏️ 编辑', 'btn primary small', () => { editing = true; renderDetail(); })); toolbar.appendChild(mkBtn('🗑 删除', 'btn danger small', async () => { if (!confirm('确定删除这条记录?')) return; await fetch(`/api/history/${currentRecord.id}`, { method: 'DELETE' }); area.classList.add('hidden'); currentRecord = null; loadHistory(); })); } else { toolbar.appendChild(mkBtn('💾 保存', 'btn primary small', saveDetail)); toolbar.appendChild(mkBtn('✖ 取消', 'btn ghost small', () => { editing = false; openDetail(currentRecord.id); })); } area.appendChild(toolbar); // 原句 const sc = document.createElement('div'); sc.className = 'card sentence-card' + (editing ? ' editing' : ''); if (editing) { sc.innerHTML = ` `; } else { const tr = currentRecord.result?.sentence_translation || ''; sc.innerHTML = `
${escapeHtml(currentRecord.sentence)}
${tr ? '
' + escapeHtml(tr) + '
' : ''}`; } area.appendChild(sc); // 单词结果 const resultBox = document.createElement('div'); renderResult(resultBox, currentRecord, editing, false); while (resultBox.firstChild) area.appendChild(resultBox.firstChild); } function mkBtn(text, cls, onClick) { const b = document.createElement('button'); b.className = cls; b.textContent = text; b.addEventListener('click', onClick); return b; } /* 按 data-path 从 DOM 收集值,写回对象 */ function collectFromDOM(rootEl, obj) { rootEl.querySelectorAll('[data-path]').forEach((el) => { const path = el.dataset.path; if (!path) return; setByPath(obj, path.split('.'), el.value); }); } function setByPath(obj, parts, value) { let cur = obj; for (let i = 0; i < parts.length - 1; i++) { const p = parts[i]; const next = parts[i + 1]; if (cur[p] == null) cur[p] = /^\d+$/.test(next) ? [] : {}; cur = cur[p]; } cur[parts[parts.length - 1]] = value; } async function saveDetail() { collectFromDOM($('#detail-area'), currentRecord); // 清理:other_senses / examples 数组中可能因删除留下空洞或需要按 DOM 顺序重排 normalizeArrays(currentRecord); const res = await fetch(`/api/history/${currentRecord.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sentence: currentRecord.sentence, words: currentRecord.words, result: currentRecord.result, }), }); if (!res.ok) return alert('保存失败'); currentRecord = await res.json(); editing = false; renderDetail(); alert('已保存 ✓'); } /* 按 DOM 顺序重建可变长数组(例句、其他释义),去掉已删除项 */ function normalizeArrays(record) { const area = $('#detail-area'); record.result.words = record.result.words.map((w, wi) => { const out = { word: w.word, phonetic: w.phonetic, context_sense: w.context_sense, context_examples: [], other_senses: [], }; // 词根拆解:从行格式文本解析(部分 | 类型 | 音标 | 含义) const rootsTa = area.querySelector(`textarea[data-roots="words.${wi}.roots"]`); out.roots = rootsTa ? rootsTa.value .split('\n') .map((l) => l.trim()) .filter(Boolean) .map((line) => { const [part, type, phonetic, meaning] = line.split('|').map((s) => s.trim()); return { part, type, phonetic, meaning }; }) : w.roots || []; // 语境例句:按 DOM 中 li 顺序 const ctxLis = area.querySelectorAll( `[data-path^="words.${wi}.context_examples."][data-path$=".en"]` ); ctxLis.forEach((el) => { const idx = el.dataset.path.match(/\.(\d+)\.en$/)[1]; const li = el.closest('li'); out.context_examples.push({ en: el.value, zh: li.querySelector(`[data-path="words.${wi}.context_examples.${idx}.zh"]`)?.value || '', }); }); // 其他释义:按 sense 输入框 DOM 顺序 const senseEls = [...area.querySelectorAll(`[data-path^="words.${wi}.other_senses."][data-path$=".sense"]`)]; senseEls.forEach((el, si) => { const block = el.closest('.sense-block'); const examples = []; block.querySelectorAll('li').forEach((li) => { examples.push({ en: li.querySelector('textarea[data-path$=".en"]')?.value || '', zh: li.querySelector('textarea[data-path$=".zh"]')?.value || '', }); }); out.other_senses.push({ sense: el.value, examples }); }); return out; }); } /* ---------- 工具 ---------- */ function escapeHtml(s) { return String(s ?? '') .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } function highlight(text, word) { const safe = escapeHtml(text); if (!word) return safe; try { const re = new RegExp(`\\b${word.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\w*`, 'gi'); return safe.replace(re, (m) => `${m}`); } catch { return safe; } }