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;
}
}
+58
View File
@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>查词学英语</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header>
<h1>📖 查词学英语</h1>
<nav>
<button id="nav-query" class="nav-btn active">查询</button>
<button id="nav-history" class="nav-btn">历史记录</button>
</nav>
</header>
<!-- 查询视图 -->
<main id="view-query" class="view">
<section class="card">
<label for="sentence">粘贴英语句子</label>
<textarea id="sentence" rows="3"
placeholder="例如: The scientist conducted a thorough analysis of the data."></textarea>
<label>句子分词(点击选择要查询的单词,最多 5 个)</label>
<div id="word-tokens" class="word-tokens">
<span class="tokens-empty">输入句子后自动分词</span>
</div>
<button id="query-btn" class="btn primary">🔍 查询</button>
<div id="query-status" class="status hidden"></div>
<div id="stream-box" class="stream-box hidden">
<div class="stream-label">🤖 AI 生成中(实时输出)</div>
<pre id="stream-text"></pre>
</div>
</section>
<section id="result-area"></section>
</main>
<!-- 历史视图 -->
<main id="view-history" class="view hidden">
<section class="card">
<h2>历史查询</h2>
<input id="history-search" class="search-box" type="text" placeholder="🔍 搜索原句或单词…" />
<div id="history-list" class="history-list"></div>
<div id="history-pager" class="pager"></div>
</section>
<section class="card">
<h2>我的词库</h2>
<input id="word-lib-search" class="search-box" type="text" placeholder="🔍 搜索单词、释义或词根…" />
<div id="word-lib" class="word-lib"></div>
<div id="word-lib-pager" class="pager"></div>
</section>
<section id="word-detail-area" class="hidden"></section>
<section id="detail-area" class="hidden"></section>
</main>
<script src="app.js"></script>
</body>
</html>
+344
View File
@@ -0,0 +1,344 @@
:root {
--bg: #f4f6fa;
--card: #ffffff;
--primary: #3b6ef5;
--primary-dark: #2c55c7;
--text: #1f2733;
--muted: #6b7686;
--border: #e2e7ef;
--tag-bg: #eaf0ff;
--danger: #e5484d;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, "PingFang SC", "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 24px;
background: var(--card);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
}
header h1 { font-size: 20px; margin: 0; }
.nav-btn {
border: 1px solid var(--border);
background: transparent;
padding: 8px 18px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
margin-left: 8px;
}
.nav-btn.active {
background: var(--primary);
color: #fff;
border-color: var(--primary);
}
.view { max-width: 860px; margin: 0 auto; padding: 20px 16px 60px; }
.hidden { display: none !important; }
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
label { display: block; font-size: 14px; font-weight: 600; margin: 12px 0 6px; }
label:first-child { margin-top: 0; }
textarea, input[type="text"] {
width: 100%;
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
font-size: 15px;
font-family: inherit;
resize: vertical;
}
textarea:focus, input:focus { outline: 2px solid var(--primary); border-color: transparent; }
.word-input-row { display: flex; gap: 8px; }
.word-input-row input { flex: 1; }
.btn {
border: none;
border-radius: 8px;
padding: 10px 18px;
font-size: 14px;
cursor: pointer;
white-space: nowrap;
}
.btn.primary { background: var(--primary); color: #fff; margin-top: 16px; width: 100%; font-size: 15px; }
.btn.primary:hover { background: var(--primary-dark); }
.btn.primary:disabled { background: #a9befa; cursor: wait; }
.btn.secondary { background: var(--tag-bg); color: var(--primary); }
.btn.ghost { background: transparent; border: 1px solid var(--border); }
.btn.danger { background: transparent; border: 1px solid var(--danger); color: var(--danger); }
.btn.small { padding: 6px 12px; font-size: 13px; }
.word-tags { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; min-height: 10px; }
.word-tag {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--tag-bg);
color: var(--primary);
padding: 4px 12px;
border-radius: 999px;
font-size: 14px;
}
.word-tag button {
border: none;
background: none;
color: var(--primary);
cursor: pointer;
font-size: 14px;
padding: 0;
}
.status { margin-top: 12px; font-size: 14px; }
.status.error { color: var(--danger); }
.status.info { color: var(--muted); }
/* 单词结果卡片 */
.word-card { border: 1px solid var(--border); border-radius: 12px; margin-bottom: 20px; overflow: hidden; }
.word-card .word-head {
background: linear-gradient(135deg, #3b6ef5, #6a8dff);
color: #fff;
padding: 18px 20px;
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.word-head .word-title { font-size: 24px; font-weight: 700; }
.word-head .phonetic { opacity: 0.9; font-size: 16px; }
.speak-btn {
border: none;
background: rgba(255,255,255,0.2);
color: #fff;
border-radius: 999px;
width: 34px; height: 34px;
cursor: pointer;
font-size: 15px;
}
.speak-btn:hover { background: rgba(255,255,255,0.35); }
.word-body { padding: 16px 20px; }
.sense-block { margin-bottom: 20px; }
.sense-block:last-child { margin-bottom: 0; }
.sense-title {
font-size: 15px;
font-weight: 700;
color: var(--primary);
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 2px solid var(--tag-bg);
}
.example-list { margin: 0; padding: 0; list-style: none; }
.example-list li { padding: 8px 0; border-bottom: 1px dashed var(--border); }
.example-list li:last-child { border-bottom: none; }
.example-en { font-size: 15px; }
.example-en b { color: var(--primary); }
.example-zh { font-size: 13px; color: var(--muted); margin-top: 2px; }
/* 历史 */
.history-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 12px 4px;
border-bottom: 1px solid var(--border);
cursor: pointer;
}
.history-item:hover { background: #f8faff; }
.history-sentence { flex: 1; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.history-meta { font-size: 12px; color: var(--muted); white-space: nowrap; }
.history-empty { color: var(--muted); text-align: center; padding: 30px 0; }
/* 编辑态 */
.editing textarea, .editing input[type="text"] { font-size: 14px; background: #fbfcff; }
.detail-toolbar { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
/* 固定在顶部的原句卡(header 高度约 61px */
.sentence-card {
position: sticky;
top: 62px;
z-index: 5;
box-shadow: 0 2px 10px rgba(31, 39, 51, 0.08);
}
.sentence-en { font-size: 15px; font-weight: 600; }
.sentence-zh { font-size: 13px; color: var(--muted); margin-top: 6px; }
.back-btn { margin-bottom: 12px; }
/* AI 流式生成窗口 */
.stream-box {
margin-top: 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: #0f1622;
overflow: auto;
max-height: 180px;
min-height: 80px;
}
.stream-box .stream-label {
position: sticky;
top: 0;
background: #0f1622;
color: #7ee787;
font-size: 12px;
padding: 6px 12px 4px;
border-bottom: 1px solid #26303f;
}
.stream-box pre {
margin: 0;
padding: 8px 12px 12px;
color: #9fb3c8;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
}
/* 句子分词选词区 */
.word-tokens {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px;
border: 1px dashed var(--border);
border-radius: 10px;
background: #fafbfe;
min-height: 20px;
}
.tokens-empty { color: var(--muted); font-size: 13px; }
.word-token {
border: 1px solid var(--border);
background: #fff;
color: var(--text);
border-radius: 999px;
padding: 5px 14px;
font-size: 14px;
cursor: pointer;
transition: all 0.12s ease;
}
.word-token:hover { border-color: var(--primary); color: var(--primary); }
.word-token.selected {
background: var(--primary);
border-color: var(--primary);
color: #fff;
}
.word-token.selected::after { content: " ✓"; }
/* 词根拆解 */
.roots-row { display: flex; flex-wrap: wrap; gap: 8px; }
.root-chip {
display: inline-flex;
align-items: baseline;
gap: 6px;
background: #fff7e6;
border: 1px solid #f2dfb8;
border-radius: 8px;
padding: 6px 10px;
cursor: pointer;
line-height: 1.3;
}
.root-chip:hover { border-color: #e5a53b; }
.root-part { font-size: 15px; font-weight: 700; color: #b06f0e; }
.root-phon { font-size: 12px; color: var(--muted); font-family: ui-monospace, Menlo, monospace; }
.root-mean { font-size: 12px; color: var(--text); }
.root-type { font-size: 11px; color: #fff; background: #e5a53b; border-radius: 4px; padding: 1px 5px; align-self: center; }
.roots-edit { font-family: ui-monospace, Menlo, monospace; font-size: 13px; }
/* 我的词库 */
.word-lib-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 10px 4px;
border-bottom: 1px solid var(--border);
cursor: pointer;
}
.word-lib-item:hover { background: #fffaf0; }
.word-lib-main { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
/* 词根历史面板(点击词根芯片触发) */
.root-panel {
margin-top: 10px;
background: #fffaf0;
border: 1px solid #f2dfb8;
border-radius: 10px;
padding: 12px 14px;
}
.root-panel-title { font-size: 14px; font-weight: 600; margin-bottom: 8px; }
.root-panel-loading { font-size: 13px; color: var(--muted); }
.root-group { margin-bottom: 10px; }
.root-group:last-of-type { margin-bottom: 4px; }
.root-group-label { font-size: 12px; color: #b06f0e; font-weight: 600; margin-bottom: 4px; }
.root-group-words { display: flex; flex-wrap: wrap; gap: 6px; }
.root-word {
border: 1px solid var(--border);
background: #fff;
border-radius: 999px;
padding: 4px 12px;
font-size: 13px;
cursor: pointer;
display: inline-flex;
align-items: baseline;
gap: 6px;
}
.root-word:hover { border-color: #e5a53b; background: #fff7e6; }
.root-panel-note { font-size: 12px; color: var(--muted); margin-top: 6px; }
.root-speak { border: none; background: none; cursor: pointer; padding: 0 2px; align-self: center; }
/* 词根面板:含义点击揭晓 */
.root-word .root-word-mean {
display: none;
font-size: 12px;
color: #b06f0e;
font-weight: 600;
}
.root-word.revealed { background: #fff7e6; border-color: #e5a53b; }
.root-word.revealed .root-word-mean { display: inline; }
/* 揭晓区:词根含义 + 单词词义 */
.root-word-senses {
display: block;
font-size: 11px;
color: var(--muted);
font-weight: 400;
margin-top: 2px;
}
/* 分页 */
.pager {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
}
.pager-info { font-size: 13px; color: var(--muted); margin-right: auto; }
.pager .btn[disabled] { opacity: 0.4; cursor: default; }
/* 搜索框 */
.search-box {
margin: 10px 0 4px;
}