Initial commit: 查词学英语 MVP
- 流式查询(SSE 实时展示生成过程) - 三维度数据模型:history(查询)/ words(单词档案)/ roots(词根档案) - 词根拆解与发音拼读训练(词根点击揭晓历史词例) - 词义/同义例句跨查询累积,同义项自动合并 - 历史与词库分页、多维度搜索 - 模型:Qwen3.6-35B-A3B(硅基流动,可 LLM_MODEL 切换)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
data/
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
*.log
|
||||
@@ -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 三张表及合并逻辑)。
|
||||
@@ -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` | 流式查询(写入全部三表) |
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Generated
+908
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user