Files
learnLanguage/public/app.js
T
cat-shark 37b75c2d21 Initial commit: 查词学英语 MVP
- 流式查询(SSE 实时展示生成过程)
- 三维度数据模型:history(查询)/ words(单词档案)/ roots(词根档案)
- 词根拆解与发音拼读训练(词根点击揭晓历史词例)
- 词义/同义例句跨查询累积,同义项自动合并
- 历史与词库分页、多维度搜索
- 模型:Qwen3.6-35B-A3B(硅基流动,可 LLM_MODEL 切换)
2026-08-29 08:44:43 +08:00

808 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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;
}
}