Initial commit: 查词学英语 MVP

- 流式查询(SSE 实时展示生成过程)
- 三维度数据模型:history(查询)/ words(单词档案)/ roots(词根档案)
- 词根拆解与发音拼读训练(词根点击揭晓历史词例)
- 词义/同义例句跨查询累积,同义项自动合并
- 历史与词库分页、多维度搜索
- 模型:Qwen3.6-35B-A3B(硅基流动,可 LLM_MODEL 切换)
This commit is contained in:
2026-08-29 08:44:43 +08:00
commit 37b75c2d21
11 changed files with 3160 additions and 0 deletions
+807
View File
@@ -0,0 +1,807 @@
/* 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
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;
}
}