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
+5
View File
@@ -0,0 +1,5 @@
node_modules/
data/
npm-debug.log*
.DS_Store
*.log
+34
View File
@@ -0,0 +1,34 @@
# 查词学英语(MVP
粘贴英语句子、查询其中单词,AI 生成简单易懂的例句帮助学习。基于硅基流动 DeepSeek 模型。
## 启动
```bash
npm install
npm start # 打开 http://localhost:3000
```
API Key 默认内置在 `llm.js`,可通过环境变量覆盖:
```bash
SILICONFLOW_API_KEY=sk-xxx npm start
```
## 功能
- **查询**:粘贴句子 → 添加要查的单词(最多 5 个)→ 调用 LLM 返回:
- 单词的美式音标 + 点击 🔊 朗读(浏览器 TTS)
- 原句语境释义 + 5 条同义例句
- 其他释义各 5 条例句(例句词汇控制在简单水平,附中文翻译)
- **历史记录**:所有查询自动保存(SQLite,`data/app.db`),可查看、**编辑任意内容**(原句/音标/释义/例句,支持增删例句和释义)、删除。
## 技术
- 后端:Node.js + Express + better-sqlite3
- 前端:原生 HTML/CSS/JS(无构建步骤)
- LLM:硅基流动 默认 `Qwen/Qwen3.6-35B-A3B`(已关闭 thinking),可用 `LLM_MODEL` 环境变量切换
## 数据库结构
详见 [SCHEMA.md](SCHEMA.md)history / words / roots 三张表及合并逻辑)。
+173
View File
@@ -0,0 +1,173 @@
# 数据库表结构文档
> 数据库:SQLite`data/app.db`WAL 模式)
> 最后更新:2026-08-28(词根维度 + 搜索功能版本)
> 表结构维护方式:启动时 `CREATE TABLE IF NOT EXISTS` 自动建表 + 代码内增量迁移(`ALTER TABLE`),无需手工执行 SQL
## 总览
系统按三个维度存储学习数据:
| 表 | 维度 | 说明 |
|---|---|---|
| `history` | 查询次数 | 每次查询的原句与完整结果快照 |
| `words` | 单词 | 跨查询累积的单词档案(释义、词根拆解、原句记录) |
| `roots` | 词根 | 跨单词累积的词根档案(哪些学过的词包含它、各词中体现的含义) |
### 维度间关系
```
history (一次查询)
│ 查询完成后自动触发
words (每词一行) ──写入时同步──▶ roots (每词根一行)
```
- `words.word` 唯一;`roots.root`(规范化键)唯一
- 同一单词在 `roots` 下仅保留一条映射(重新拆解时旧映射自动摘除)
---
## 1. history — 查询历史(按次记录)
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `id` | INTEGER | PK, AUTOINCREMENT | 记录 ID |
| `sentence` | TEXT | NOT NULL | 用户粘贴的原句 |
| `words` | TEXT | NOT NULL | 查询的单词列表,JSON 数组:`["analysis", "analyze"]` |
| `result` | TEXT | NOT NULL | LLM 结构化结果快照,见下方 JSON 结构 |
| `raw_result` | TEXT | 可空 | LLM 原始输出(排查用) |
| `created_at` / `updated_at` | TEXT | NOT NULL | 本地时间,写入/最后编辑时间 |
### `result` JSON 结构
```json
{
"sentence_translation": "整个原句的中文翻译",
"words": [
{
"word": "analysis",
"phonetic": "/əˈnæləsɪs/",
"roots": [
{ "part": "ana-", "type": "前缀", "phonetic": "/əˈnæ/", "meaning": "向上、全面" }
],
"context_sense_id": 0,
"context_sense": "n. 分析;解析",
"context_examples": [
{ "en": "English sentence.", "zh": "中文翻译" }
],
"other_senses": [
{
"sense_id": -1,
"sense": "n. 化验",
"examples": [ { "en": "...", "zh": "..." } ]
}
]
}
]
}
```
- `context_sense_id`:本次语境命中的词库已有释义编号(0 起),`-1` 表示新释义
- `roots[].type`:前缀 / 词根 / 后缀 / 音节
- 已编辑的历史记录中 `roots` 支持手工维护(编辑界面按 `部分 | 类型 | 音标 | 含义` 行格式)
---
## 2. words — 词库(按单词维度,跨查询累积)
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `id` | INTEGER | PK, AUTOINCREMENT | |
| `word` | TEXT | NOT NULL, **UNIQUE**(自动索引) | 单词小写形式 |
| `phonetic` | TEXT | | 整词美式音标 |
| `roots` | TEXT | NOT NULL, 默认 `'[]'` | 词根拆解,JSON`[{part, type, phonetic, meaning}]` |
| `senses` | TEXT | NOT NULL, 默认 `'[]'` | 释义累积,见下方 |
| `root_keys` | TEXT | NOT NULL, 默认 `'[]'` | 该词当前映射到的词根规范化键,如 `["ana","naly","sis"]` |
| `search_text` | TEXT | NOT NULL, 默认 `''` | 检索文本(见下) |
| `created_at` / `updated_at` | TEXT | NOT NULL | |
### `senses` JSON 结构
```json
[
{
"sense": "n. 分析;解析",
"examples": [
{ "en": "原句本身", "zh": "原句翻译", "from_sentence": true },
{ "en": "AI 生成的同义例句", "zh": "翻译" }
]
}
]
```
- 同义义项合并规则:新查询语境命中已有义项(`context_sense_id`)→ 追加"原句 + 本次生成的同义例句";新语境 → 新增义项
- 例句按英文原文去重;`from_sentence: true` 标记来自真实原句(前端以 📌 展示)
### `search_text`(检索列)
内容 = `单词 + 各义项标题 + 词根 part/规范化键/含义`,统一小写。
**刻意不含例句正文**,避免例句里的无关词(如 "company" 含 "any")干扰检索。由 `buildWordSearchText()` 生成,在写入与启动迁移时刷新。
---
## 3. roots — 词根库(按词根维度,跨单词累积)
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `id` | INTEGER | PK, AUTOINCREMENT | |
| `root` | TEXT | NOT NULL, **UNIQUE**(自动索引) | 规范化键:去连字符/非字母、小写,如 `lysis` |
| `display` | TEXT | NOT NULL | 展示形(保留连字符),如 `-tion`,以最近出现为准 |
| `type` | TEXT | | 前缀 / 词根 / 后缀 / 音节 |
| `phonetic` | TEXT | | 词根发音(IPA |
| `words` | TEXT | NOT NULL, 默认 `'[]'` | 包含该词根的已学词,见下方 |
| `created_at` / `updated_at` | TEXT | NOT NULL | |
### `words` JSON 结构
```json
[
{
"word": "analysis",
"phonetic": "/əˈnæləsɪs/",
"meaning": "松开、解开",
"word_senses": ["n. 分析;解析", "n. 分解;剖析"]
}
]
```
- `meaning`:该词根**在此词中**体现的含义——刻意不做全局统一,多个词的含义变体并列展示,供学习者自行抽象
- `word_senses`:该单词自身的释义列表(词根面板点击单词揭晓时一并展示)
- 同一单词重复查询时条目整体覆盖;单词重新拆解后,从不再包含它的旧词根下自动摘除(词根下无词时整行删除)
---
## 4. 合并/维护逻辑(写入路径)
查询完成(流式返回 done 前)按顺序执行:
1. `upsertWordData(wordResult, sentence, sentenceTranslation)`
- 释义合并:`context_sense_id` 命中 → 并入原句 + 同义例句;否则新增义项
- `other_senses`:文案相同 → 例句并入已有义项;否则新增
- 词根同步:为 `roots` 中每个部分调用 `upsertRootData`,并按 `root_keys` 清理旧映射
- 刷新 `search_text`
2. `saveRecord(...)` 写入 `history`
## 5. 启动时自动迁移
- `words.root_keys``words.search_text`:缺失时 `ALTER TABLE` 补列
- `search_text` 每次启动对全表回填
- 历史版本说明:v1 仅 `history` 表;"按单词维度"版本引入 `words`(破坏性变更,经确认清除旧数据);当前版本追加 `roots`
## 6. 相关 API
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/history?page=&size=&q=` | 历史分页列表,`q` 搜原句/单词 |
| GET | `/api/history/:id` | 历史详情 |
| PUT | `/api/history/:id` | 编辑保存 |
| DELETE | `/api/history/:id` | 删除 |
| GET | `/api/words?page=&size=&q=` | 词库分页列表,`q` 搜单词/释义/词根(不含例句) |
| GET | `/api/words/:word` | 单词档案 |
| GET | `/api/roots/:root` | 词根档案(键为规范化形式) |
| POST | `/api/query/stream` | 流式查询(写入全部三表) |
+383
View File
@@ -0,0 +1,383 @@
import Database from 'better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dataDir = path.join(__dirname, 'data');
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
const db = new Database(path.join(dataDir, 'app.db'));
db.pragma('journal_mode = WAL');
// 破坏性变更:重建表结构(旧数据已清除)
db.exec(`
CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sentence TEXT NOT NULL,
words TEXT NOT NULL, -- 查询的单词,JSON 数组
result TEXT NOT NULL, -- LLM 结构化结果,JSON(含词根拆解)
raw_result TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);
-- 词库(按单词维度维护,跨句子累积)
CREATE TABLE IF NOT EXISTS words (
id INTEGER PRIMARY KEY AUTOINCREMENT,
word TEXT NOT NULL UNIQUE,
phonetic TEXT, -- 整词音标
roots TEXT NOT NULL DEFAULT '[]',
-- 词根拆解: [{part, type:前缀|词根|后缀|音节, phonetic, meaning}]
senses TEXT NOT NULL DEFAULT '[]',
-- 释义(同义项合并): [{sense, examples: [{en, zh, from_sentence}]}]
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);
-- 词根库(按词根维度维护,跨单词累积)
CREATE TABLE IF NOT EXISTS roots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
root TEXT NOT NULL UNIQUE, -- 规范化键:去连字符/空格、小写,如 'lysis'
display TEXT NOT NULL, -- 展示形(如 "lysis"、"-tion"),以最近出现为准
type TEXT, -- 前缀 / 词根 / 后缀 / 音节
phonetic TEXT, -- 发音
words TEXT NOT NULL DEFAULT '[]',
-- 包含该词根的词: [{word, phonetic, meaning}]
-- meaning = 该词中体现的词根含义(允许不同词里有差异,展示时并列,帮助大脑抽象)
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);
`);
// 迁移:words 表补充 root_keys、search_text 列
// search_text = 单词 + 义项标题 + 词根拆解(不含例句正文,避免例句里的无关词干扰检索)
function buildWordSearchText(word, senses, roots) {
return [
word,
...(senses || []).map((s) => s.sense),
...(roots || []).flatMap((r) => [r.part, normalizeRootKey(r.part), r.meaning]),
]
.filter(Boolean)
.join(' ')
.toLowerCase();
}
{
const cols = db.prepare("PRAGMA table_info(words)").all().map((c) => c.name);
if (!cols.includes('root_keys')) {
db.exec("ALTER TABLE words ADD COLUMN root_keys TEXT NOT NULL DEFAULT '[]'");
}
if (!cols.includes('search_text')) {
db.exec("ALTER TABLE words ADD COLUMN search_text TEXT NOT NULL DEFAULT ''");
}
// 回填历史数据的 search_text
const rows = db.prepare('SELECT word, senses, roots FROM words').all();
const upd = db.prepare('UPDATE words SET search_text = ? WHERE word = ?');
for (const r of rows) {
upd.run(
buildWordSearchText(
r.word,
JSON.parse(r.senses),
JSON.parse(r.roots)
),
r.word
);
}
}
const stmts = {
// ---- history ----
insert: db.prepare(
'INSERT INTO history (sentence, words, result, raw_result) VALUES (?, ?, ?, ?)'
),
get: db.prepare('SELECT * FROM history WHERE id = ?'),
update: db.prepare(
`UPDATE history SET sentence = @sentence, words = @words, result = @result,
updated_at = datetime('now', 'localtime') WHERE id = @id`
),
del: db.prepare('DELETE FROM history WHERE id = ?'),
// ---- words(词库)----
getWord: db.prepare('SELECT * FROM words WHERE word = ?'),
insertWord: db.prepare(
`INSERT INTO words (word, phonetic, roots, senses, root_keys, search_text) VALUES (@word, @phonetic, @roots, @senses, @root_keys, @search_text)`
),
updateWord: db.prepare(
`UPDATE words SET phonetic = @phonetic, roots = @roots, senses = @senses,
root_keys = @root_keys, search_text = @search_text,
updated_at = datetime('now', 'localtime') WHERE id = @id`
),
// ---- roots(词根库)----
getRoot: db.prepare('SELECT * FROM roots WHERE root = ?'),
insertRoot: db.prepare(
`INSERT INTO roots (root, display, type, phonetic, words) VALUES (@root, @display, @type, @phonetic, @words)`
),
updateRoot: db.prepare(
`UPDATE roots SET display = @display, type = @type, phonetic = @phonetic, words = @words,
updated_at = datetime('now', 'localtime') WHERE id = @id`
),
delRoot: db.prepare('DELETE FROM roots WHERE id = ?'),
};
/* ---------- history ---------- */
export function saveRecord({ sentence, words, result, raw_result }) {
const info = stmts.insert.run(
sentence,
JSON.stringify(words),
JSON.stringify(result),
raw_result ?? null
);
return getRecord(info.lastInsertRowid);
}
/* 搜索:LIKE 通配符转义(% _ 视为普通字符) */
function likeTerm(q) {
return '%' + String(q || '').replace(/[%_\\]/g, '\\$&') + '%';
}
function pagedQuery({ baseCols, from, orderBy = 'id DESC', whereCols, page, size, q, postProcess }) {
const hasSearch = q && String(q).trim();
const where = hasSearch
? 'WHERE ' + whereCols.map((c) => `${c} LIKE @q ESCAPE '\\'`).join(' OR ')
: '';
const total = db
.prepare(`SELECT COUNT(*) AS n FROM ${from} ${where}`)
.get(hasSearch ? { q: likeTerm(q) } : {}).n;
const items = db
.prepare(
`SELECT ${baseCols} FROM ${from} ${where} ORDER BY ${orderBy} LIMIT @limit OFFSET @offset`
)
.all({
...(hasSearch ? { q: likeTerm(q) } : {}),
limit: size,
offset: (page - 1) * size,
})
.map(postProcess);
return {
items,
total,
q: hasSearch ? String(q).trim() : '',
page,
size,
total_pages: Math.max(1, Math.ceil(total / size)),
};
}
export function listRecords(page = 1, size = 10, q = '') {
return pagedQuery({
baseCols: 'id, sentence, words, created_at, updated_at',
from: 'history',
whereCols: ['sentence', 'words'], // 匹配原句或查询的单词
page,
size,
q,
postProcess: (r) => ({ ...r, words: JSON.parse(r.words) }),
});
}
export function getRecord(id) {
const r = stmts.get.get(id);
if (!r) return null;
return { ...r, words: JSON.parse(r.words), result: JSON.parse(r.result) };
}
export function updateRecord(id, { sentence, words, result }) {
const info = stmts.update.run({
id,
sentence,
words: JSON.stringify(words),
result: JSON.stringify(result),
});
if (info.changes === 0) return null;
return getRecord(id);
}
export function deleteRecord(id) {
const info = stmts.del.run(id);
return info.changes > 0;
}
/* ---------- words(词库)---------- */
export function getWord(word) {
const r = stmts.getWord.get(word);
if (!r) return null;
return { ...r, roots: JSON.parse(r.roots), senses: JSON.parse(r.senses) };
}
export function listWords(page = 1, size = 10, q = '') {
return pagedQuery({
baseCols: 'word, phonetic, roots, senses, created_at, updated_at',
from: 'words',
orderBy: 'updated_at DESC',
whereCols: ['search_text'], // 匹配单词、释义或词根(不含例句正文)
page,
size,
q,
postProcess: (r) => {
const senses = JSON.parse(r.senses);
const occurrences = senses.reduce(
(n, s) => n + (s.examples || []).filter((e) => e.from_sentence).length,
0
);
return {
...r,
roots: JSON.parse(r.roots),
senses,
sense_count: senses.length,
occurrence_count: occurrences,
};
},
});
}
/**
* 按"单词维度"合并查询结果:
* - 词根拆解:首次创建时写入,之后保留(除非原来为空)
* - 原句语境对应已有释义(context_sense_id 有效)→ 该释义下追加"来自原句"的记录
* - 语境为新释义 → 新增义项;其他新释义也并入(按释义文案去重)
*/
export function upsertWordData(w, sentence, sentenceTranslation) {
if (!w?.word) return null;
const existing = stmts.getWord.get(w.word);
let senses = existing ? JSON.parse(existing.senses) : [];
// 原句记录
const occ = {
en: sentence,
zh: sentenceTranslation || '',
from_sentence: true,
};
// 查询时生成的同义例句:与原句一起并入词库
const generated = (w.context_examples || [])
.map((e) => ({ en: (e.en || '').trim(), zh: (e.zh || '').trim() }))
.filter((e) => e.en);
// 按英文句子去重(原句与例句可重复,避免同一句重复堆积)
const addUnique = (list, ex) => {
if (!ex.en) return;
if (!list.some((x) => x.en === ex.en)) list.push(ex);
};
const ctxId = w.context_sense_id;
if (
typeof ctxId === 'number' &&
Number.isInteger(ctxId) &&
ctxId >= 0 &&
ctxId < senses.length
) {
// 语境对应已有释义:原句 + 本次生成的同义例句一起并入
const s = senses[ctxId];
s.examples = s.examples || [];
addUnique(s.examples, occ);
generated.forEach((ex) => addUnique(s.examples, ex));
if (w.context_sense) s.sense = w.context_sense; // 释义文案以最新为准
} else if (w.context_sense) {
senses.push({ sense: w.context_sense, examples: [occ, ...generated] });
}
// 并入其他释义:已有同文案释义 → 例句并入;否则新增释义
for (const os of w.other_senses || []) {
if (!os?.sense) continue;
const osExamples = (os.examples || [])
.map((e) => ({ en: (e.en || '').trim(), zh: (e.zh || '').trim() }))
.filter((e) => e.en);
const matched = senses.find((s) => s.sense === os.sense);
if (matched) {
matched.examples = matched.examples || [];
osExamples.forEach((ex) => addUnique(matched.examples, ex));
} else {
senses.push({ sense: os.sense, examples: osExamples });
}
}
// 词根拆解:优先用最新结果;新词必写,旧词仅在原来为空时补写
// 词根拆解:优先用最新结果;新词必写,旧词仅在原来为空时补写
const oldRoots = existing ? JSON.parse(existing.roots) : [];
const roots = w.roots?.length ? w.roots : oldRoots;
// ---- 词根维度同步 ----
const newKeys = [...new Set(roots.map((r) => normalizeRootKey(r.part)).filter(Boolean))];
if (existing) {
// 单词重新拆解后,从不再包含的旧词根下摘除该词,避免幽灵条目
const oldKeys = JSON.parse(existing.root_keys || '[]');
for (const k of oldKeys) {
if (newKeys.includes(k)) continue;
const r = stmts.getRoot.get(k);
if (!r) continue;
const remaining = JSON.parse(r.words).filter((e) => e.word !== w.word);
if (remaining.length === 0) stmts.delRoot.run(r.id);
else stmts.updateRoot.run({ ...r, words: JSON.stringify(remaining) });
}
}
for (const r of roots) {
// 传入该词的全部释义,词根面板点击单词时一并揭晓
upsertRootData(r, w.word, w.phonetic || '', senses.map((s) => s.sense));
}
const row = {
word: w.word,
phonetic: w.phonetic || existing?.phonetic || '',
roots: JSON.stringify(roots),
senses: JSON.stringify(senses),
root_keys: JSON.stringify(newKeys),
search_text: buildWordSearchText(w.word, senses, roots),
id: existing?.id,
};
if (existing) stmts.updateWord.run(row);
else stmts.insertWord.run(row);
return getWord(w.word);
}
/* ---------- roots(词根库)---------- */
export function normalizeRootKey(part) {
return String(part || '').replace(/[^a-zA-Z]/g, '').toLowerCase();
}
export function getRoot(rootKey) {
const r = stmts.getRoot.get(rootKey);
if (!r) return null;
return { ...r, words: JSON.parse(r.words) };
}
/**
* 按词根维度累积:同一词根下追加包含它的单词(含该词中体现的含义)。
* 含义不强行统一——不同词中的含义变体并列展示,让学习者自行抽象。
*/
export function upsertRootData(
{ part, type, phonetic, meaning },
word,
wordPhonetic,
wordSenses = []
) {
const key = normalizeRootKey(part);
if (!key) return null;
const existing = stmts.getRoot.get(key);
let words = existing ? JSON.parse(existing.words) : [];
// word_senses: 该单词自身的释义列表(词根面板中点击单词时一并揭晓)
const entry = {
word,
phonetic: wordPhonetic || '',
meaning: meaning || '',
word_senses: (wordSenses || []).filter(Boolean),
};
const idx = words.findIndex((e) => e.word === word);
if (idx !== -1) words[idx] = entry; // 同一单词以最新拆解覆盖
else words.push(entry);
const row = {
id: existing?.id,
root: key,
display: part,
type: type || existing?.type || '',
phonetic: phonetic || existing?.phonetic || '',
words: JSON.stringify(words),
};
if (existing) stmts.updateRoot.run(row);
else stmts.insertRoot.run(row);
return getRoot(key);
}
+260
View File
@@ -0,0 +1,260 @@
const API_KEY =
process.env.SILICONFLOW_API_KEY ||
'sk-fyouaqowxseljgjwqrlkvbikczlnjtpdgswhvocoqfgvivds';
const API_URL = 'https://api.siliconflow.cn/v1/chat/completions';
const MODEL = process.env.LLM_MODEL || 'Qwen/Qwen3.6-35B-A3B';
// 流式请求的空闲超时:90 秒未收到任何新数据才判定连接卡死(总时长不限)
const IDLE_TIMEOUT_MS = 90_000;
function buildPrompt(sentence, words, existingWords = []) {
const wordList = words.join('、');
// 已有词库记录:让模型把新句子的语境对齐到已有释义,避免同一义项重复建档
const existingInfo = existingWords
.map(
(e) =>
`单词 "${e.word}" 已有记录:\n释义(按编号):\n${e.senses
.map((s, i) => ` ${i}. ${s}`)
.join('\n')}${e.roots?.length ? `\n已有词根拆解:\n${e.roots.map((r) => ` ${r.part} [${r.type}] ${r.phonetic}${r.meaning}`).join('\n')}` : ''}`
)
.join('\n\n');
return `你是一位面向中国英语学习者的词汇老师,擅长词源与拼读教学。
原句:${sentence}
请查询以下单词:${wordList}
${existingInfo ? '\n\n' + existingInfo : ''}
对每个单词返回:
1. 词根拆解(roots):把单词拆分为前缀/词根/后缀,每个部分给出:
- part: 该部分原文(如 "ana-"、"lysis"、"-tion",前缀带尾连字符、后缀带头连字符)
- type: 前缀 / 词根 / 后缀
- phonetic: 该部分单独的发音(美式 IPA,如 "/əˈnæ/"
- meaning: 该部分的中文含义
发音规则:各部分发音连起来应接近整词发音,便于通过发音训练拼写。
若单词无明显词根词缀结构(多为短词),按音节拆分(type 填"音节"),音节同样标注发音。
2. 在原句语境中的释义(context_sense
3. 该释义的 5 条例句(context_examples),例句必须使用相同词性、相近含义
4. 其他常见释义(other_senses),每个释义包含 5 条例句
5. 整个原句的中文翻译(sentence_translation
若上方提供了某单词的已有释义记录:
- context_sense_id:原句语境对应的已有释义编号(从 0 开始);对应不上任何已有释义时填 -1
- other_senses 只返回已有记录中不存在的新释义(文案应与已有释义明显不同);没有新释义时返回空数组 []
- 若提供了已有词根拆解,roots 原样沿用已有记录(保持同一单词的拆解稳定,便于跨单词对比词根);确有错误才修正
严格要求:
- 所有例句必须使用简单、常见的词汇(不超过初中词汇水平),句子简短(10-18 个单词),让学习者容易理解
- 每条例句附中文翻译
- 音标使用美式音标(IPA
- 释义用中文解释
- context_sense 若对应已有编号,文案与已有记录保持一致
只输出 JSON,不要输出任何其他文字或代码块标记。JSON 格式:
{
"sentence_translation": "整个原句的中文翻译",
"words": [
{
"word": "单词",
"phonetic": "/美式音标/",
"roots": [
{"part": "ana-", "type": "前缀", "phonetic": "/əˈnæ/", "meaning": "向上、全面"}
],
"context_sense_id": -1,
"context_sense": "在原句语境中的中文释义(注明词性)",
"context_examples": [
{"en": "English example sentence.", "zh": "中文翻译"}
],
"other_senses": [
{
"sense_id": -1,
"sense": "另一种中文释义(注明词性)",
"examples": [
{"en": "English example sentence.", "zh": "中文翻译"}
]
}
]
}
]
}`;
}
async function callApiOnce(sentence, words, existingWords = []) {
const res = await fetch(API_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: MODEL,
messages: [
{
role: 'system',
content:
'你是严谨的 JSON 输出机器,只输出合法 JSON,不要 markdown 代码块。',
},
{ role: 'user', content: buildPrompt(sentence, words, existingWords) },
],
temperature: 0.3,
max_tokens: 8000,
enable_thinking: false,
}),
signal: AbortSignal.timeout(120_000), // 网络级超时,避免请求挂起
});
if (!res.ok) {
const text = await res.text().catch(() => '');
const err = new Error(`LLM API 错误 ${res.status}: ${text.slice(0, 300)}`);
err.noRetry = true; // HTTP 业务错误(如鉴权/参数)不重试
throw err;
}
const data = await res.json();
const raw = data.choices?.[0]?.message?.content ?? '';
return { raw, parsed: parseResult(raw) };
}
function describeError(err) {
// undici 的网络错误真实原因在 err.cause 中(如 ECONNRESET / ETIMEDOUT / ENOTFOUND
const cause = err?.cause?.code || err?.cause?.message;
let msg;
if (err?.name === 'TimeoutError' || err?.name === 'AbortError') {
msg = '请求超时或中断';
} else if (err?.abortReason?.message || err?.message) {
msg = err.abortReason?.message || err.message;
} else {
msg = String(err);
}
if (cause && !msg.includes(String(cause))) msg += ` (原因: ${cause})`;
// 返回新 Error:某些错误(如 DOMException)的 message 是只读 getter,不能直接赋值
const e = new Error(msg);
e.noRetry = !!err?.noRetry;
return e;
}
export async function queryWords(sentence, words, existingWords = []) {
const maxAttempts = 3;
let lastErr;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await callApiOnce(sentence, words, existingWords);
} catch (err) {
lastErr = err;
if (err.noRetry || attempt === maxAttempts) {
throw describeError(err);
}
// 仅对网络类瞬时错误重试,指数退避
const waitMs = 1500 * attempt;
console.warn(`网络错误(第 ${attempt} 次),${waitMs}ms 后重试: ${describeError(err)}`);
await new Promise((r) => setTimeout(r, waitMs));
}
}
throw lastErr;
}
// 流式查询:通过 onDelta 回调实时输出增量文本
export async function queryWordsStream(sentence, words, onDelta, existingWords = []) {
const maxAttempts = 3;
let lastErr;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
let emitted = false;
// 空闲超时:只要数据持续到达就不限总时长;90 秒无新数据才判定卡死
const controller = new AbortController();
let idleTimer;
const resetIdle = () => {
clearTimeout(idleTimer);
idleTimer = setTimeout(
() => controller.abort(new Error('90 秒未收到新数据,判定为连接卡死')),
IDLE_TIMEOUT_MS
);
};
resetIdle();
try {
const res = await fetch(API_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: MODEL,
messages: [
{
role: 'system',
content:
'你是严谨的 JSON 输出机器,只输出合法 JSON,不要 markdown 代码块。',
},
{ role: 'user', content: buildPrompt(sentence, words, existingWords) },
],
temperature: 0.3,
max_tokens: 8000,
enable_thinking: false,
stream: true,
}),
signal: controller.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
const err = new Error(`LLM API 错误 ${res.status}: ${text.slice(0, 300)}`);
err.noRetry = true;
throw err;
}
const decoder = new TextDecoder();
let raw = '';
let buf = '';
for await (const chunk of res.body) {
resetIdle(); // 数据持续到达就续期空闲计时器
buf += decoder.decode(chunk, { stream: true });
const lines = buf.split('\n');
buf = lines.pop(); // 最后一段可能不完整,留到下一轮
for (const line of lines) {
const t = line.trim();
if (!t.startsWith('data:')) continue;
const payload = t.slice(5).trim();
if (payload === '[DONE]') continue;
try {
const j = JSON.parse(payload);
const delta = j.choices?.[0]?.delta?.content || '';
if (delta) {
emitted = true; // 已输出过增量,失败后不能重试(避免内容重复)
raw += delta;
onDelta(delta);
}
} catch { /* 忽略不完整行 */ }
}
}
clearTimeout(idleTimer);
return { raw, parsed: parseResult(raw) };
} catch (err) {
clearTimeout(idleTimer);
lastErr = err;
// 已经向用户输出过内容后失败,重试会导致文本重复,只能报错
if (err.noRetry || emitted || attempt === maxAttempts) {
throw describeError(err);
}
const waitMs = 1500 * attempt;
console.warn(`网络错误(第 ${attempt} 次),${waitMs}ms 后重试: ${describeError(err)}`);
await new Promise((r) => setTimeout(r, waitMs));
}
}
throw lastErr;
}
export function parseResult(raw) {
let text = raw.trim();
// 去掉可能存在的 markdown 代码块包裹
const m = text.match(/```(?:json)?\s*([\s\S]*?)```/);
if (m) text = m[1].trim();
// 截取第一个 { 到最后一个 }
const start = text.indexOf('{');
const end = text.lastIndexOf('}');
if (start === -1 || end === -1) throw new Error('LLM 返回中未找到 JSON');
const parsed = JSON.parse(text.slice(start, end + 1));
if (!Array.isArray(parsed.words) || parsed.words.length === 0) {
throw new Error('LLM 返回的 JSON 缺少 words 数组');
}
return parsed;
}
+908
View File
@@ -0,0 +1,908 @@
{
"name": "learnlanguage",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "learnlanguage",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"better-sqlite3": "^13.0.3",
"express": "^5.2.1"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/better-sqlite3": {
"version": "13.0.3",
"resolved": "https://registry.npmmirror.com/better-sqlite3/-/better-sqlite3-13.0.3.tgz",
"integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==",
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.0.0"
},
"engines": {
"node": ">=22"
}
},
"node_modules/body-parser": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^2.0.0",
"debug": "^4.4.3",
"http-errors": "^2.0.1",
"iconv-lite": "^0.7.2",
"on-finished": "^2.4.1",
"qs": "^6.15.2",
"raw-body": "^3.0.2",
"type-is": "^2.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-1.1.0.tgz",
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmmirror.com/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.7.3",
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-1.1.1.tgz",
"integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-1.1.0.tgz",
"integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==",
"license": "MIT",
"dependencies": {
"content-type": "^2.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/negotiator/node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/node-addon-api": {
"version": "8.9.2",
"resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-8.9.2.tgz",
"integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.3.0.tgz",
"integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT",
"dependencies": {
"content-type": "^2.0.0",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/type-is/node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "learnlanguage",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"better-sqlite3": "^13.0.3",
"express": "^5.2.1"
},
"type": "module"
}
+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;
}
+170
View File
@@ -0,0 +1,170 @@
import express from 'express';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
saveRecord,
listRecords,
getRecord,
updateRecord,
deleteRecord,
getWord,
listWords,
upsertWordData,
getRoot,
} from './db.js';
import { queryWords, queryWordsStream } from './llm.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
app.use(express.json({ limit: '2mb' }));
app.use(express.static(path.join(__dirname, 'public')));
// 发起查询
app.post('/api/query', async (req, res) => {
const sentence = (req.body.sentence || '').trim();
const words = Array.isArray(req.body.words)
? req.body.words.map((w) => String(w).trim()).filter(Boolean)
: [];
if (!sentence) return res.status(400).json({ error: '请提供英语句子' });
if (words.length === 0)
return res.status(400).json({ error: '请提供至少一个要查询的单词' });
if (words.length > 5)
return res.status(400).json({ error: '一次最多查询 5 个单词' });
try {
const { raw, parsed } = await queryWords(sentence, words);
const record = saveRecord({
sentence,
words,
result: parsed,
raw_result: raw,
});
res.json(record);
} catch (err) {
console.error('查询失败:', err.message);
res.status(502).json({ error: `查询失败:${err.message}` });
}
});
// 流式查询:SSE 增量推送 LLM 原始输出,完成后推送已保存的记录
app.post('/api/query/stream', async (req, res) => {
const sentence = (req.body.sentence || '').trim();
const words = Array.isArray(req.body.words)
? req.body.words.map((w) => String(w).trim()).filter(Boolean)
: [];
// 校验失败直接返回普通 JSON 错误
if (!sentence) return res.status(400).json({ error: '请提供英语句子' });
if (words.length === 0)
return res.status(400).json({ error: '请提供至少一个要查询的单词' });
if (words.length > 5)
return res.status(400).json({ error: '一次最多查询 5 个单词' });
res.set({
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.flushHeaders();
const send = (obj) => res.write(`data: ${JSON.stringify(obj)}\n\n`);
try {
// 查询已在词库中的单词:把已有释义带给 LLM,用于同义项合并
const existingWords = words
.map((w) => getWord(w))
.filter(Boolean)
.map((r) => ({ word: r.word, senses: r.senses.map((s) => s.sense), roots: r.roots }));
const { raw, parsed } = await queryWordsStream(sentence, words, (delta) => {
send({ type: 'delta', text: delta });
}, existingWords);
// 按单词维度并入词库(同义项合并、原句计入该单词记录)
for (const w of parsed.words || []) {
try {
upsertWordData(w, sentence, parsed.sentence_translation || '');
} catch (e) {
console.error('词库更新失败:', e.message);
}
}
const record = saveRecord({
sentence,
words,
result: parsed,
raw_result: raw,
});
send({ type: 'done', record });
} catch (err) {
console.error('流式查询失败:', err.message);
send({ type: 'error', message: `查询失败:${err.message}` });
} finally {
res.end();
}
});
// 词库列表
app.get('/api/words', (req, res) => {
const { page, size } = pagerParams(req);
res.json(listWords(page, size, req.query.q || ''));
});
// 单词详情(含全部释义与例句)
app.get('/api/words/:word', (req, res) => {
const record = getWord(req.params.word.toLowerCase());
if (!record) return res.status(404).json({ error: '词库中不存在该单词' });
res.json(record);
});
// 词根详情(该词根下的所有学过的词,按含义变体并列)
app.get('/api/roots/:root', (req, res) => {
const record = getRoot(req.params.root.toLowerCase());
if (!record) return res.status(404).json({ error: '词根库中不存在' });
res.json(record);
});
// 历史列表
app.get('/api/history', (req, res) => {
const { page, size } = pagerParams(req);
res.json(listRecords(page, size, req.query.q || ''));
});
// 历史详情
app.get('/api/history/:id', (req, res) => {
const record = getRecord(req.params.id);
if (!record) return res.status(404).json({ error: '记录不存在' });
res.json(record);
});
// 编辑保存
app.put('/api/history/:id', (req, res) => {
const { sentence, words, result } = req.body;
if (!sentence || !Array.isArray(words) || !result) {
return res.status(400).json({ error: '字段不完整' });
}
const record = updateRecord(req.params.id, { sentence, words, result });
if (!record) return res.status(404).json({ error: '记录不存在' });
res.json(record);
});
// 删除
app.delete('/api/history/:id', (req, res) => {
const ok = deleteRecord(req.params.id);
if (!ok) return res.status(404).json({ error: '记录不存在' });
res.json({ ok: true });
});
// 分页参数解析(默认第 1 页、每页 10 条,上限 50)
function pagerParams(req) {
const page = Math.max(1, parseInt(req.query.page) || 1);
const size = Math.min(50, Math.max(1, parseInt(req.query.size) || 10));
return { page, size };
}
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`✅ 服务已启动: http://localhost:${PORT}`);
});