- 流式查询(SSE 实时展示生成过程) - 三维度数据模型:history(查询)/ words(单词档案)/ roots(词根档案) - 词根拆解与发音拼读训练(词根点击揭晓历史词例) - 词义/同义例句跨查询累积,同义项自动合并 - 历史与词库分页、多维度搜索 - 模型:Qwen3.6-35B-A3B(硅基流动,可 LLM_MODEL 切换)
808 lines
28 KiB
JavaScript
808 lines
28 KiB
JavaScript
/* 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 = '<span class="tokens-empty">输入句子后自动分词</span>';
|
||
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 = `
|
||
<textarea data-path="${pathPrefix}.en" rows="2">${escapeHtml(ex.en || '')}</textarea>
|
||
<textarea data-path="${pathPrefix}.zh" rows="1">${escapeHtml(ex.zh || '')}</textarea>
|
||
<button class="btn small danger ex-del" style="margin-top:4px">删除此例句</button>`;
|
||
li.querySelector('.ex-del').addEventListener('click', () => li.remove());
|
||
} else {
|
||
li.innerHTML = `
|
||
<div class="example-en">${highlight(ex.en || '', word)}</div>
|
||
<div class="example-zh">${escapeHtml(ex.zh || '')}</div>`;
|
||
}
|
||
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 = `<input type="text" data-path="${pathPrefix ? pathPrefix + '.sense' : ''}" value="${escapeHtml(title)}" />`;
|
||
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 = `
|
||
<b class="root-part">${escapeHtml(r.part || '')}</b>
|
||
<span class="root-phon">${escapeHtml(r.phonetic || '')}</span>
|
||
<span class="root-mean">${escapeHtml(r.meaning || '')}</span>
|
||
<span class="root-type">${escapeHtml(r.type || '')}</span>
|
||
<button class="root-speak" title="朗读词根">🔊</button>`;
|
||
// 🔊 只负责发音
|
||
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 = '<div class="root-panel-loading">加载词根历史中…</div>';
|
||
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 = `🔍 词根 <b>${escapeHtml(r.display || part)}</b>`;
|
||
if (r.phonetic) titleHtml += ` <span class="root-phon">${escapeHtml(r.phonetic)}</span>`;
|
||
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)} <span class="root-phon">${escapeHtml(e.phonetic || '')}</span> <span class="root-word-mean">${escapeHtml(e.meaning || '(未标注)')}${(e.word_senses || []).length ? '<span class="root-word-senses">📌 词义:' + e.word_senses.map((s) => escapeHtml(s)).join(';') + '</span>' : ''}</span>`;
|
||
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 = `<div class="root-panel-loading">暂无该词根的历史记录</div>`;
|
||
}
|
||
}
|
||
|
||
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 = `
|
||
<input type="text" data-path="${wp}.word" value="${escapeHtml(w.word || '')}" style="flex:1;min-width:120px" />
|
||
<input type="text" data-path="${wp}.phonetic" value="${escapeHtml(w.phonetic || '')}" style="flex:1;min-width:120px" />
|
||
<button class="speak-btn speak" title="朗读">🔊</button>`;
|
||
head.querySelector('.speak').addEventListener('click', () => {
|
||
speak(head.querySelector(`[data-path="${wp}.word"]`).value);
|
||
});
|
||
} else {
|
||
head.innerHTML = `
|
||
<span class="word-title">${escapeHtml(w.word || '')}</span>
|
||
<span class="phonetic">${escapeHtml(w.phonetic || '')}</span>
|
||
<button class="speak-btn" title="朗读">🔊</button>`;
|
||
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 = `<input type="text" data-path="${wp}.context_sense" value="${escapeHtml(w.context_sense || '')}" />`;
|
||
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 = `
|
||
<label>原句</label>
|
||
<div class="sentence-en">${escapeHtml(record.sentence || '')}</div>
|
||
${tr ? '<div class="sentence-zh">' + escapeHtml(tr) + '</div>' : ''}`;
|
||
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
|
||
? '<div class="history-empty">没有匹配的查询记录</div>'
|
||
: '<div class="history-empty">暂无查询记录,去查询页试试吧~</div>';
|
||
renderPager($('#history-pager'), data, () => {});
|
||
return;
|
||
}
|
||
list.forEach((r) => {
|
||
const item = document.createElement('div');
|
||
item.className = 'history-item';
|
||
item.innerHTML = `
|
||
<div class="history-sentence">${escapeHtml(r.sentence)}</div>
|
||
<div>
|
||
<div style="font-size:13px;color:var(--primary)">${r.words.map(escapeHtml).join('、')}</div>
|
||
<div class="history-meta">${escapeHtml(r.created_at)}</div>
|
||
</div>`;
|
||
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
|
||
? '<div class="history-empty">没有匹配的单词</div>'
|
||
: '<div class="history-empty">词库还是空的,去查询页添加单词吧~</div>';
|
||
renderPager($('#word-lib-pager'), data, () => {});
|
||
return;
|
||
}
|
||
list.forEach((r) => {
|
||
const item = document.createElement('div');
|
||
item.className = 'word-lib-item';
|
||
item.innerHTML = `
|
||
<div class="word-lib-main">
|
||
<span class="root-part">${escapeHtml(r.word)}</span>
|
||
<span class="root-phon">${escapeHtml(r.phonetic || '')}</span>
|
||
</div>
|
||
<div class="history-meta">${r.sense_count} 个释义 · 出现 ${r.occurrence_count} 次</div>`;
|
||
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 = `
|
||
<span class="word-title">${escapeHtml(w.word)}</span>
|
||
<span class="phonetic">${escapeHtml(w.phonetic || '')}</span>
|
||
<button class="speak-btn" title="朗读">🔊</button>`;
|
||
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 = `
|
||
<div class="example-en">${e.from_sentence ? '📌 ' : ''}${highlight(e.en, w.word)}</div>
|
||
<div class="example-zh">${escapeHtml(e.zh || '')}</div>`;
|
||
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 = `
|
||
<label>原句</label>
|
||
<textarea data-path="sentence" rows="3">${escapeHtml(currentRecord.sentence)}</textarea>
|
||
<label>原句翻译</label>
|
||
<textarea data-path="result.sentence_translation" rows="2">${escapeHtml(currentRecord.result?.sentence_translation || '')}</textarea>`;
|
||
} else {
|
||
const tr = currentRecord.result?.sentence_translation || '';
|
||
sc.innerHTML = `
|
||
<label>原句</label>
|
||
<div class="sentence-en">${escapeHtml(currentRecord.sentence)}</div>
|
||
${tr ? '<div class="sentence-zh">' + escapeHtml(tr) + '</div>' : ''}`;
|
||
}
|
||
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, '"').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) => `<b>${m}</b>`);
|
||
} catch {
|
||
return safe;
|
||
}
|
||
}
|