Initial commit: 查词学英语 MVP
- 流式查询(SSE 实时展示生成过程) - 三维度数据模型:history(查询)/ words(单词档案)/ roots(词根档案) - 词根拆解与发音拼读训练(词根点击揭晓历史词例) - 词义/同义例句跨查询累积,同义项自动合并 - 历史与词库分页、多维度搜索 - 模型:Qwen3.6-35B-A3B(硅基流动,可 LLM_MODEL 切换)
This commit is contained in:
+807
@@ -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, '&').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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user