This commit is contained in:
eddy
2026-07-19 21:09:58 +08:00
commit 5558482479
63 changed files with 41626 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
## AI 英语助教
我想记牢这些单词 (words-2025-08-26.json),帮我用 html 写一个 WebSite。
- index.html
- styles.css
- script.js
### 核心功能
1. 导入 JSON 单词(支持文件选择和粘贴,自动去重)
2. 添加 AI 模型(OpenAI 兼容 Chat Completions API
3. 邮件提词(粘贴英文邮件,智能提取单词,过滤停用词/人名,AI 批量翻译后加入词库)
4. 邮件学习(全文阅读模式、AI 分析、全文翻译、高亮词库单词)
5. AI 测试(英译中 / 中翻英,分组出题,支持错题测试和收藏测试)
6. 错题复习(卡片学习模式,认识/模糊/不认识三档评价)
7. 艾宾浩斯遗忘曲线复习(6 阶段间隔:1/2/4/7/15/30 天)
8. 学习数据统计(Chart.js 柱状图,错题 Top 10,掌握分布)
9. 单词收藏(星标收藏,支持按收藏筛选和测试)
### 设置页面
- API Keypassword 输入框)
- API URL(格式:`https://tohub.com/v1`
- Model(默认 `gemini-3-flash`
- 内置词库自动加载开关
- 数据管理(导出/导入备份、清空数据)
### 技术要求
- 不用数据库,数据存放在浏览器 localStorage
- 纯前端,无需构建工具
- 响应式设计,支持移动端
- 明暗主题切换
+92
View File
@@ -0,0 +1,92 @@
# AI 英语助教
一个纯前端的英语单词学习应用。无需后端,学习数据保存在浏览器 `localStorage`;可导入词库、从邮件提词、进行测试和错题复习,并支持可选的 OpenAI 兼容 AI 服务。
仓库包含两个可独立运行的版本:
- `src/`:当前单文件式实现(`index.html``styles.css``script.js`)。
- `refactor/`:ES Modules 重构版;按页面、服务、UI 与样式拆分,保持原有功能和数据兼容。
## 启动
应用需要通过 HTTP 服务运行,不能直接打开 HTML 文件。
```powershell
# 在仓库根目录运行
python -m http.server 8000
```
开发原版 `src/` 时,Windows 10 可用仓库根目录的脚本一键控制服务:
```powershell
python serve.py start # 后台启动,访问 http://localhost:8000/
python serve.py status # 查看运行状态
python serve.py stop # 停止服务
```
脚本不生成 PID 文件,而是通过 8000 端口识别 `src/` 的本地 HTTP 服务;当端口被其他程序占用时,可在提示后选择强制关闭该进程并启动。
打开:
- 原版:<http://localhost:8000/src/>(使用上述根目录命令)或 <http://localhost:8000/>(使用 `serve.py`
- 重构版:<http://localhost:8000/refactor/>
也可启动重构版预设脚本:
```powershell
cd refactor
npm run serve
```
该脚本会将仓库根目录作为 HTTP 根目录,因此可正确读取根目录的 `data/` 词库。两版若要共享已有学习数据,必须使用相同的协议、主机和端口;`localStorage` 按 origin 隔离。
## 功能概览
- 单词库:JSON 导入与导出、搜索、分类与收藏筛选、详情和发音。
- 学习与复习:基于 `[1, 2, 4, 7, 15, 30]` 天间隔的复习计划、错题卡片复习与学习统计。
- 测试:英译中、中译英、本地或 AI 出题、错题与收藏范围测试。
- 邮件学习:从英文邮件提取词汇、批量翻译入库、全文阅读、高亮、朗读与 AI 分析。
- 设置:主题、AI 配置、词库自动加载、完整数据备份与恢复。
## 技术与外部服务
- 原生 HTML、CSS、JavaScript;重构版使用浏览器原生 ES Modules,无构建步骤。
- Font Awesome、Chart.js。
- Free Dictionary API、Azure TTS、有道 TTS 与浏览器 Web Speech(网络语音不可用时回退到浏览器朗读)。
- OpenAI 兼容 Chat Completions API(可选,用于出题、翻译和邮件分析)。
CDN 或第三方 API 被网络、离线环境或 CSP 阻止时,相应的图标、图表、AI 或网络语音功能会受限。
## 数据与兼容性
核心数据包括单词、练习记录、复习计划、收藏、邮件、主题与 AI 设置。重构版沿用原版的数据结构,并支持将默认词库的旧键(如 `words``records``schedule``favorites`)迁移为按词库分组的存储键。
重构版的主要目录:
- `refactor/js/core/`:状态、存储、路由与事件委托。
- `refactor/js/services/`:TTS、AI、复习算法、统计、出题、提词与词库服务。
- `refactor/js/pages/`:各页面和学习流程。
- `refactor/js/ui/`:主题、侧边栏、Toast、Modal 与 DOM 工具。
- `refactor/styles/`:按原样式级联顺序拆分的样式文件。
重构版不使用内联事件处理器;交互通过命名空间化的 `data-action` 和统一事件委托处理。
## 验收
重构版的手工验收清单位于 [`refactor/docs/smoke-test.md`](refactor/docs/smoke-test.md)。验证时请在开发者工具中检查 Console,并确认两版使用同一 origin 后再检查历史数据。
## 发布与缓存
Linux 服务器可使用 `deploy.sh` 一键下载并部署 `main` 分支的最新版本:
- 保留 deploy 参数,以便下载脚本后直接执行部署,而不是进入交互菜单。
```bash
bash <(curl -fsSL https://raw.githubusercontent.com/ghuang-top/AI_English/main/deploy.sh) deploy
```
脚本会将 `src/index.html``src/js/``src/styles/` 部署到 `/root/data/docker_data/Nginx/html/english/ai/`,清理目标目录中的其他内容,但保留已有的 `data/` 目录。运行前请确认目标路径符合服务器配置;脚本需要 root 权限以及 `curl``tar``find` 命令。
`src/index.html` 通过 CSS 和 JavaScript URL 的 `v` 查询参数控制静态资源缓存。每次发布修改了 `src/styles.css``src/script.js`,请同步递增对应版本号。服务器应优先为 `index.html` 设置 `Cache-Control: no-cache`;带版本号的静态资源可长期缓存。
设置页的“获取最新版本”仅清理应用资源缓存,不应改为清除 `localStorage`,以免丢失学习数据和 AI 配置。
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env bash
set -Eeuo pipefail
REPO_ARCHIVE_URL="https://github.com/ghuang-top/AI_English/archive/refs/heads/main.tar.gz"
TARGET_DIR="/root/data/docker_data/Nginx/html/english/ai"
PRESERVED_NAME="data"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
print_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
print_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1" >&2
}
check_root() {
if [[ ${EUID} -ne 0 ]]; then
print_error "此脚本需要 root 权限运行"
print_info "请切换到 root 用户,或使用 sudo bash 执行脚本"
exit 1
fi
}
check_dependencies() {
local command_name
for command_name in curl tar find; do
if ! command -v "$command_name" >/dev/null 2>&1; then
print_error "缺少必要命令: $command_name"
exit 1
fi
done
}
prepare_target() {
if [[ -e "$TARGET_DIR" && ! -d "$TARGET_DIR" ]]; then
print_error "目标路径存在,但不是文件夹: $TARGET_DIR"
exit 1
fi
mkdir -p "$TARGET_DIR"
}
clear_target() {
prepare_target
print_info "正在清空目标目录(保留 $TARGET_DIR/$PRESERVED_NAME/..."
find "$TARGET_DIR" -mindepth 1 -maxdepth 1 ! -name "$PRESERVED_NAME" \
-exec rm -rf -- {} +
print_success "目标目录已清空,$PRESERVED_NAME/ 中的内容未改动"
}
deploy() (
local temp_dir archive_file source_dir
temp_dir=$(mktemp -d)
archive_file="$temp_dir/repository.tar.gz"
trap 'rm -rf -- "$temp_dir"' EXIT
print_info "正在从 GitHub 下载最新版本..."
if ! curl -fL --retry 3 --connect-timeout 15 "$REPO_ARCHIVE_URL" -o "$archive_file"; then
print_error "仓库下载失败,请检查网络连接后重试"
return 1
fi
print_info "正在解压并验证部署文件..."
if ! tar -xzf "$archive_file" -C "$temp_dir"; then
print_error "仓库压缩包解压失败"
return 1
fi
source_dir=$(find "$temp_dir" -mindepth 2 -maxdepth 2 -type d -name src -print -quit)
if [[ -z "$source_dir" || ! -f "$source_dir/index.html" || ! -d "$source_dir/js" || ! -d "$source_dir/styles" ]]; then
print_error "仓库中缺少 src/index.html、src/js 或 src/styles,已取消部署"
return 1
fi
clear_target
print_info "正在拷贝网站文件到 $TARGET_DIR ..."
cp -a "$source_dir/index.html" "$source_dir/js" "$source_dir/styles" "$TARGET_DIR/"
print_success "部署完成"
print_info "已部署: index.html、js/、styles/"
print_info "已保留: $TARGET_DIR/$PRESERVED_NAME/"
)
confirm_clear() {
local answer
print_warning "将清空 $TARGET_DIR 中除 $PRESERVED_NAME/ 外的所有内容"
read -r -p "确认继续?[y/N]: " answer
if [[ "$answer" =~ ^[Yy]$ ]]; then
clear_target
else
print_info "已取消清空"
fi
}
show_menu() {
clear 2>/dev/null || true
echo -e "${CYAN}============================================${NC}"
echo " AI English 一键部署脚本"
echo -e "${CYAN}============================================${NC}"
echo -e "${GREEN}1.${NC} 下载并部署最新版本"
echo -e "${GREEN}2.${NC} 清空网站文件(保留 data/"
echo -e "${GREEN}0.${NC} 退出"
echo ""
}
run_menu() {
local choice
while true; do
show_menu
read -r -p "请选择操作 [0-2]: " choice
case "$choice" in
1)
# 部署失败时回到菜单,而不是因 set -e 直接退出脚本
deploy || print_warning "部署失败,可稍后重试"
read -r -p "按 Enter 键继续..."
;;
2)
confirm_clear
read -r -p "按 Enter 键继续..."
;;
0)
print_info "退出脚本"
return 0
;;
*)
print_error "无效选择,请重新输入"
sleep 1
;;
esac
done
}
main() {
check_root
check_dependencies
case "${1:-menu}" in
deploy)
deploy
;;
clear)
confirm_clear
;;
menu)
run_menu
;;
*)
print_error "未知参数: $1"
echo "用法: $0 [deploy|clear|menu]" >&2
exit 1
;;
esac
}
main "$@"
+49
View File
@@ -0,0 +1,49 @@
# 手工冒烟测试
## 测试准备
1. 用 HTTP 静态服务器启动 `refactor/`,打开开发者工具并保持 Console 可见。
2. 在原版设置页导出完整备份;确认重构版与原版使用相同 origin 后刷新重构版。
3. 记录测试前 localStorage 的键名与关键数据数量,避免误把端口不同导致的空数据判断为兼容失败。
## 路由与全局 UI
- [ ] 依次进入 `#home``#words``#quiz``#review``#extract``#mail-learn``#stats``#settings`,前进/后退正常。
- [ ] 深浅主题切换后刷新仍保持;统计图在两种主题下可重绘。
- [ ] 移动端菜单、遮罩关闭、Toast、Modal 遮罩与关闭按钮正常;Esc 可关 Modal/侧栏。
- [ ] 全程 Console 无 `ReferenceError`、未知 action、模块加载或 404 错误。
## 数据兼容
- [ ]`words/records/schedule/favorites` 能迁移到默认词库前缀键,结构和值不变。
- [ ] 各词库的 `words:<id>``records:<id>``schedule:<id>``favorites:<id>` 可切换并恢复。
- [ ] 设置、邮件、主题、过滤词、自动加载和自动播放设置刷新后恢复。
- [ ] 导出全部数据,再导入该备份,单词/记录/计划/收藏/邮件数量一致;API Key 不进入导出文件。
## 单词库
- [ ] 搜索输入防抖、分类/收藏/错题/频次筛选、分页及滚动正常。
- [ ] JSON 导入、导出、重复过滤、详情、编辑释义、收藏、朗读和删除正常。
- [ ] 自动播放可启停,重复次数/间隔/滚动设置生效;快速点击多个发音只保留最后请求。
## 测验与复习
- [ ] 本地英译中、中翻英;50/100 分组、全部/错题/收藏范围、随机/顺序、题量均正常。
- [ ] 答题、上一题、下一题、结束、结果、再测一次正常,记录与 schedule 更新。
- [ ] 配置 AI 后可出题;失败或 429 时提示并回退本地题库。
- [ ] 错题列表及开始复习正常;翻卡、认识/模糊/不认识、进度跳转、收藏和朗读正常。
- [ ] 复习快捷键 ←、→、Space、1/2/3 仅在复习会话且无 Modal 时生效。
## 邮件提词与学习
- [ ] 粘贴提词、专有名词过滤、chips 全选/反选、AI 翻译入库及保存邮件正常。
- [ ] 批量模式可读取 txt/md/html/eml,多文件移除、频次提取、去重和入库正常。
- [ ] 已保存邮件可载入/删除;AI 分析、全文翻译、高亮词库单词正常。
- [ ] 阅读模式逐句显示、句子朗读/翻译、全部翻译、词库词 tooltip、生词查询与加入词库正常。
## 设置与词库
- [ ] AI、Azure TTS 配置保存与连接测试正常;Azure 失败后语音回退正常。
- [ ] 内置词库切换、自动加载、手动加载、过滤词增删/搜索/复制正常。
- [ ] 清除收藏、清空数据的双重确认及保留收藏单词行为与原版一致。
- [ ] “获取最新版本”只清应用缓存并刷新,不清 localStorage。
+119
View File
@@ -0,0 +1,119 @@
"""Windows development server controls for the src application."""
from __future__ import annotations
import argparse
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent
SOURCE_DIR = ROOT / "src"
PORT = 8000
def listener_pid() -> int | None:
result = subprocess.run(
["powershell", "-NoProfile", "-Command", f"(Get-NetTCPConnection -LocalPort {PORT} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty OwningProcess)"],
capture_output=True,
text=True,
check=False,
)
try:
return int(result.stdout.strip())
except ValueError:
return None
def command_line(pid: int) -> str:
result = subprocess.run(
["powershell", "-NoProfile", "-Command", f"(Get-CimInstance Win32_Process -Filter 'ProcessId = {pid}').CommandLine"],
capture_output=True,
text=True,
check=False,
)
return result.stdout.strip()
def is_src_server(pid: int) -> bool:
command = command_line(pid).lower()
return "-m http.server" in command and str(SOURCE_DIR).lower() in command
def terminate_process(pid: int) -> bool:
result = subprocess.run(
["taskkill", "/PID", str(pid), "/T", "/F"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
return result.returncode == 0
def start() -> None:
listener = listener_pid()
if listener:
if is_src_server(listener):
print(f"服务已运行:http://localhost:{PORT}/PID {listener}")
return
answer = input(
f"端口 {PORT} 已被其他程序使用(PID {listener})。"
"是否关闭该进程并强制启动?[y/N]: "
).strip().lower()
if answer not in ("y", "yes"):
print("已取消启动。")
return
if not terminate_process(listener):
print(f"无法关闭 PID {listener},请以管理员身份运行终端后重试。")
return
time.sleep(0.3)
if listener_pid():
print(f"端口 {PORT} 仍被占用,无法启动服务。")
return
print(f"已关闭 PID {listener},正在启动服务。")
process = subprocess.Popen(
[sys.executable, "-m", "http.server", str(PORT), "--directory", str(SOURCE_DIR)],
cwd=ROOT,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
time.sleep(0.3)
if listener_pid() == process.pid:
print(f"服务已启动:http://localhost:{PORT}/PID {process.pid}")
else:
print("服务启动失败,请确认 Python 可用且端口未被占用。")
def stop() -> None:
listener = listener_pid()
if not listener or not is_src_server(listener):
print("src 开发服务器未启动。")
return
if terminate_process(listener):
print("服务已停止。")
else:
print(f"无法停止 PID {listener},请以管理员身份运行终端后重试。")
def status() -> None:
listener = listener_pid()
if listener and is_src_server(listener):
print(f"服务运行中:http://localhost:{PORT}/PID {listener}")
else:
print("src 开发服务器未启动。")
def main() -> None:
parser = argparse.ArgumentParser(description="控制 src 的本地 HTTP 开发服务器。")
parser.add_argument("command", choices=("start", "stop", "status"), help="执行的操作")
args = parser.parse_args()
{"start": start, "stop": stop, "status": status}[args.command]()
if __name__ == "__main__":
main()
+108
View File
@@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="theme-color" content="#6366f1">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>AI 英语助教</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%236366f1'/%3E%3Ctext x='32' y='42' text-anchor='middle' font-size='34' font-family='Arial, sans-serif' fill='white'%3EAI%3C/text%3E%3C/svg%3E">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g"
crossorigin="anonymous" referrerpolicy="no-referrer">
<script>
const ASSET_VERSION = '20260719-2';
const stylesheets = [
'styles/variables.css',
'styles/base.css',
'styles/layout.css',
'styles/components.css',
'styles/pages/home.css',
'styles/pages/words.css',
'styles/pages/learn.css',
'styles/pages/quiz.css',
'styles/pages/review.css',
'styles/pages/stats.css',
'styles/pages/settings.css',
'styles/responsive.css',
'styles/pages/extract.css',
'styles/pages/mail-learn.css',
'styles/pages/reader.css'
];
document.write(stylesheets
.map(path => `<link rel="stylesheet" href="${path}?v=${ASSET_VERSION}">`)
.join(''));
</script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"
integrity="sha384-vsrfeLOOY6KuIYKDlmVH5UiBmgIdB1oEf7p01YgWHuqmOHfZr374+odEv96n9tNC"
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
</head>
<body>
<div id="app">
<header id="mobile-header">
<button id="menu-btn" aria-label="菜单">
<i class="fas fa-bars"></i>
</button>
<h1>AI 英语助教</h1>
<button id="theme-toggle-mobile" aria-label="切换主题"><i class="fas fa-moon"></i></button>
</header>
<aside id="sidebar">
<div class="sidebar-header">
<span class="logo"><i class="fas fa-graduation-cap"></i></span>
<h2>AI 英语助教</h2>
</div>
<nav class="sidebar-nav">
<a href="#home" class="nav-item active" data-page="home">
<span class="nav-icon"><i class="fas fa-chart-pie"></i></span><span class="nav-text">仪表盘</span>
</a>
<a href="#words" class="nav-item" data-page="words">
<span class="nav-icon"><i class="fas fa-book"></i></span><span class="nav-text">单词库</span>
</a>
<a href="#quiz" class="nav-item" data-page="quiz">
<span class="nav-icon"><i class="fas fa-robot"></i></span><span class="nav-text">AI 测试</span>
</a>
<a href="#review" class="nav-item" data-page="review">
<span class="nav-icon"><i class="fas fa-redo-alt"></i></span><span class="nav-text">错题复习</span>
</a>
<a href="#extract" class="nav-item" data-page="extract">
<span class="nav-icon"><i class="fas fa-envelope"></i></span><span class="nav-text">邮件提词</span>
</a>
<a href="#mail-learn" class="nav-item" data-page="mail-learn">
<span class="nav-icon"><i class="fas fa-graduation-cap"></i></span><span class="nav-text">邮件学习</span>
</a>
<a href="#stats" class="nav-item" data-page="stats">
<span class="nav-icon"><i class="fas fa-chart-line"></i></span><span class="nav-text">统计</span>
</a>
<a href="#settings" class="nav-item" data-page="settings">
<span class="nav-icon"><i class="fas fa-cog"></i></span><span class="nav-text">设置</span>
</a>
</nav>
<div class="sidebar-footer">
<button id="theme-toggle">
<span id="theme-icon"><i class="fas fa-moon"></i></span>
<span>切换主题</span>
</button>
</div>
</aside>
<div id="overlay"></div>
<main id="main-content">
<div id="page-content"></div>
</main>
<div id="toast-container"></div>
</div>
<div id="modal-root"></div>
<script>
document.write(`<script type="module" src="js/main.js?v=${ASSET_VERSION}"><\/script>`);
</script>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
// ==================== Constants ====================
export const EBBINGHAUS_INTERVALS = [1, 2, 4, 7, 15, 30];
export const STAGE_LABELS = ['第1次', '第2次', '第3次', '第4次', '第5次', '第6次'];
export const WORDS_PER_PAGE = 30;
export const WORD_TRANSLATE_BATCH_SIZE = 20;
export const LETTERS = ['A', 'B', 'C', 'D'];
// ==================== App State ====================
export const WORD_LIBRARIES = [
{ id: 'words-2025-08-26', name: '基础词库(2025-08-26', file: 'words-2025-08-26.json' },
{ id: 'words-2026-07-12', name: '新版核心词库(2026-07-12', file: 'words-2026-07-12.json' },
{ id: 'gpt5.6-2026-07-12', name: 'GPT-5.6 词库(2026-07-12', file: 'gpt5.6-2026-07-12.json' }
];
export const DEFAULT_WORD_LIBRARY = WORD_LIBRARIES[0].id;
+25
View File
@@ -0,0 +1,25 @@
const actions = new Map();
const pendingElements = new WeakSet();
export function registerActions(namespace, ...modules) {
// 逐个合并模块,越靠后的模块优先级越高(页面/包装实现可覆盖共享实现)。
// 显式合并才能在同名但实现不同的情况下发出告警,而不是被对象展开静默去重。
const handlers = {};
for (const module of modules) {
for (const [name, fn] of Object.entries(module)) {
if (typeof fn !== 'function') continue;
if (name in handlers && handlers[name] !== fn) {
console.warn(`[actions] "${namespace}" 命名空间内 "${name}" 存在多个实现,采用后注册者`);
}
handlers[name] = fn;
}
}
for (const [name, fn] of Object.entries(handlers)) {
const action = `${namespace}.${name}`;
if (actions.has(action)) throw new Error(`Duplicate action: ${action}`);
actions.set(action, fn);
}
}
function parseArg(value) { if (value == null) return value; if (/^-?\d+(?:\.\d+)?$/.test(value)) return Number(value); if (value === 'true') return true; if (value === 'false') return false; return value; }
async function dispatch(event) { const el = event.target.closest('[data-action]'); if (!el || !event.currentTarget.contains(el)) return; const expected = el.dataset.actionEvent || 'click'; if (expected !== event.type) return; if (el.classList.contains('modal-overlay') && event.target !== el) return; event.preventDefault(); event.stopPropagation(); const fn = actions.get(el.dataset.action); if (!fn) { console.error('Unknown action:', el.dataset.action); return; } if (pendingElements.has(el)) return; const args = []; Object.keys(el.dataset).filter(k => /^arg\d+$/.test(k)).sort((a,b) => Number(a.slice(3))-Number(b.slice(3))).forEach(k => args[Number(k.slice(3))] = parseArg(el.dataset[k])); if (el.dataset.valueArg != null) args[Number(el.dataset.valueArg)] = el.dataset.valueNumber === 'true' ? Number(el.value) : el.value; if (el.dataset.elementArg != null) args[Number(el.dataset.elementArg)] = el; if (el.dataset.passElement === 'true') args.unshift(el); pendingElements.add(el); try { await fn(...args); } catch (error) { console.error('Action failed:', el.dataset.action, error); } finally { pendingElements.delete(el); } }
export function bindActionDelegates() { ['page-content','modal-root'].forEach(id => { const root = document.getElementById(id); ['click','input','change','keydown'].forEach(type => root.addEventListener(type, dispatch)); }); }
+38
View File
@@ -0,0 +1,38 @@
import { state } from './state.js';
import { closeSidebar } from '../ui/sidebar.js';
// ==================== Router ====================
export function navigate(page) {
window.location.hash = '#' + page;
}
export function onHashChange() {
const requestedPage = window.location.hash.slice(1) || 'home';
const page = Object.prototype.hasOwnProperty.call(pageRegistry, requestedPage) ? requestedPage : 'home';
if (page !== requestedPage) history.replaceState(null, '', `${window.location.pathname}${window.location.search}#${page}`);
state.currentPage = page;
updateNav(page);
renderPage(page);
closeSidebar();
}
export function updateNav(page) {
document.querySelectorAll('.nav-item').forEach(item => {
item.classList.toggle('active', item.dataset.page === page);
});
}
const pageRegistry = {};
let beforeRender = () => {};
export function registerPage(page, renderFn) { pageRegistry[page] = renderFn; }
export function setBeforeRender(callback) { beforeRender = callback; }
export function renderPage(page) {
beforeRender();
const el = document.getElementById('page-content');
el.classList.toggle('settings-page', page === 'settings');
el.classList.remove('fade-in-up');
void el.offsetWidth;
el.classList.add('fade-in-up');
(pageRegistry[page] || pageRegistry.home)(el);
}
+28
View File
@@ -0,0 +1,28 @@
import { DEFAULT_WORD_LIBRARY } from '../constants.js';
export const state = {
words: [],
records: [],
schedule: {},
favorites: [],
settings: { apiKey: '', apiUrl: '', model: '' },
currentPage: 'home',
learnSession: null,
quizSession: null,
wordPage: 1,
wordSearch: '',
wordCategory: '',
learnAutoDisplay: true,
quizBatchSize: 50,
quizBatchIndex: -1,
quizMode: 'en2zh',
quizRemoveCorrectErrors: false,
mailEmails: [],
activeWordLibrary: DEFAULT_WORD_LIBRARY,
autoLoadEnabled: true,
batchExtractEnabled: false,
autoPlayRepeat: 1,
autoPlayInterval: 0,
autoPlayRepeatDelay: 0,
autoPlayScrollMode: 'center'
};
+182
View File
@@ -0,0 +1,182 @@
import { DEFAULT_WORD_LIBRARY, WORD_LIBRARIES } from '../constants.js';
import { state } from './state.js';
import { showToast } from '../ui/toast.js';
import { invalidateErrorWordsCache } from '../services/stats.js';
// ==================== Storage ====================
export const MAX_RECORDS = 5000;
export const STORAGE_KEYS = Object.freeze({
words: 'words',
records: 'records',
schedule: 'schedule',
favorites: 'favorites',
settings: 'settings',
mailEmails: 'mailEmails',
activeWordLibrary: 'activeWordLibrary',
learnAutoDisplay: 'learnAutoDisplay',
autoLoadEnabled: 'autoLoadEnabled',
batchExtractEnabled: 'batchExtractEnabled',
autoPlayRepeat: 'autoPlayRepeat',
autoPlayInterval: 'autoPlayInterval',
autoPlayRepeatDelay: 'autoPlayRepeatDelay',
autoPlayScrollMode: 'autoPlayScrollMode',
customFilterWords: 'customFilterWords',
theme: 'theme'
});
export function getStorageItem(key) {
try {
return localStorage.getItem(key);
} catch (e) {
console.warn(`Failed to read localStorage key "${key}"`, e);
return null;
}
}
export function snapshotStorageItems(keys) {
try {
return keys.map(key => ({ key, value: localStorage.getItem(key) }));
} catch (e) {
console.error('Failed to snapshot localStorage items', e);
return null;
}
}
export function restoreStorageItems(snapshot) {
if (!Array.isArray(snapshot)) return false;
let restored = true;
snapshot.forEach(({ key, value }) => {
try {
if (value === null) localStorage.removeItem(key);
else localStorage.setItem(key, value);
} catch (e) {
restored = false;
console.warn(`Failed to restore localStorage key "${key}"`, e);
}
});
return restored;
}
export function saveStringSetting(key, value) {
try {
localStorage.setItem(key, String(value));
return true;
} catch (e) {
console.error(`Failed to save localStorage key "${key}"`, e);
showToast('本地存储空间不足,部分数据可能未保存', 'error');
return false;
}
}
function removeStorageItem(key) {
try {
localStorage.removeItem(key);
return true;
} catch (e) {
console.warn(`Failed to remove localStorage key "${key}"`, e);
return false;
}
}
export function loadJsonSetting(key, fallback) {
try {
const raw = getStorageItem(key);
if (raw == null) return fallback;
return JSON.parse(raw);
} catch (e) {
console.warn(`Failed to parse localStorage key "${key}"`, e);
return fallback;
}
}
export function saveJsonSetting(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (e) {
console.error(`Failed to save localStorage key "${key}"`, e);
showToast('本地存储空间不足,部分数据可能未保存', 'error');
return false;
}
}
export function loadBooleanSetting(key, fallback) {
const raw = getStorageItem(key);
if (raw == null) return fallback;
if (raw === 'true' || raw === 'false') return raw === 'true';
const parsed = loadJsonSetting(key, fallback);
if (typeof parsed === 'boolean') return parsed;
if (parsed === 'true' || parsed === 'false') return parsed === 'true';
return fallback;
}
export function loadNumberSetting(key, fallback) {
const raw = getStorageItem(key);
if (raw == null || raw.trim() === '') return fallback;
const parsed = Number(raw);
if (Number.isFinite(parsed)) return parsed;
const jsonValue = loadJsonSetting(key, fallback);
return typeof jsonValue === 'number' && Number.isFinite(jsonValue) ? jsonValue : fallback;
}
export function getLibraryStorageKey(key, libraryId = state.activeWordLibrary) {
return `${key}:${libraryId}`;
}
export function loadLibraryState(libraryId, useLegacyFallback = false) {
const loadLibraryValue = (key, fallback) => {
const storageKey = getLibraryStorageKey(key, libraryId);
if (getStorageItem(storageKey) != null) return loadJsonSetting(storageKey, fallback);
if (!useLegacyFallback || getStorageItem(key) == null) return fallback;
const legacyValue = loadJsonSetting(key, fallback);
if (saveJsonSetting(storageKey, legacyValue)) removeStorageItem(key);
return legacyValue;
};
const words = loadLibraryValue(STORAGE_KEYS.words, []);
const records = loadLibraryValue(STORAGE_KEYS.records, []);
const schedule = loadLibraryValue(STORAGE_KEYS.schedule, {});
const favorites = loadLibraryValue(STORAGE_KEYS.favorites, []);
state.words = Array.isArray(words) ? words : [];
state.records = Array.isArray(records) ? records : [];
state.schedule = schedule !== null && typeof schedule === 'object' && !Array.isArray(schedule) ? schedule : {};
state.favorites = Array.isArray(favorites) ? favorites : [];
invalidateErrorWordsCache(); // 切换/加载词库后清空错题缓存
}
export function loadState() {
state.activeWordLibrary = getStorageItem(STORAGE_KEYS.activeWordLibrary) || DEFAULT_WORD_LIBRARY;
if (!WORD_LIBRARIES.some(library => library.id === state.activeWordLibrary)) state.activeWordLibrary = DEFAULT_WORD_LIBRARY;
loadLibraryState(state.activeWordLibrary, state.activeWordLibrary === DEFAULT_WORD_LIBRARY);
const settings = loadJsonSetting(STORAGE_KEYS.settings, {});
state.learnAutoDisplay = loadBooleanSetting(STORAGE_KEYS.learnAutoDisplay, true);
const mailEmails = loadJsonSetting(STORAGE_KEYS.mailEmails, []);
state.autoLoadEnabled = loadBooleanSetting(STORAGE_KEYS.autoLoadEnabled, true);
state.batchExtractEnabled = loadBooleanSetting(STORAGE_KEYS.batchExtractEnabled, false);
state.autoPlayRepeat = loadNumberSetting(STORAGE_KEYS.autoPlayRepeat, 1) || 1;
state.autoPlayInterval = loadNumberSetting(STORAGE_KEYS.autoPlayInterval, 0) || 0;
state.autoPlayRepeatDelay = loadNumberSetting(STORAGE_KEYS.autoPlayRepeatDelay, 0) || 0;
state.autoPlayScrollMode = loadJsonSetting(STORAGE_KEYS.autoPlayScrollMode, 'center') === 'visible' ? 'visible' : 'center';
state.settings = {
apiKey: '',
apiUrl: '',
model: '',
ttsEnabled: true,
ttsProvider: 'azure',
ttsApiKey: '',
ttsEndpoint: '',
ttsRegion: 'eastus',
ttsVoice: 'en-US-JennyNeural',
ttsOutputFormat: 'audio-24khz-48kbitrate-mono-mp3',
ttsPrefetchEnabled: false,
...(settings !== null && typeof settings === 'object' && !Array.isArray(settings) ? settings : {})
};
state.mailEmails = Array.isArray(mailEmails) ? mailEmails : [];
}
export function saveWords() { invalidateErrorWordsCache(); return saveJsonSetting(getLibraryStorageKey(STORAGE_KEYS.words), state.words); }
export function saveRecords() { invalidateErrorWordsCache(); return saveJsonSetting(getLibraryStorageKey(STORAGE_KEYS.records), state.records); }
export function saveSchedule() { return saveJsonSetting(getLibraryStorageKey(STORAGE_KEYS.schedule), state.schedule); }
export function saveFavorites() { return saveJsonSetting(getLibraryStorageKey(STORAGE_KEYS.favorites), state.favorites); }
export function saveSettings() { return saveJsonSetting(STORAGE_KEYS.settings, state.settings); }
export function saveEmails() { return saveJsonSetting(STORAGE_KEYS.mailEmails, state.mailEmails); }
+76
View File
@@ -0,0 +1,76 @@
export const STOP_WORDS = new Set([
'a','an','the','and','or','but','in','on','at','to','for','of','with','by',
'from','up','about','into','through','during','before','after','above','below',
'between','out','off','over','under','again','further','then','once','here',
'there','when','where','why','how','all','both','each','few','more','most',
'other','some','such','no','not','only','own','same','so','than','too','very',
's','t','can','will','just','don','should','now','i','me','my','myself','we',
'our','ours','ourselves','you','your','yours','yourself','yourselves','he','him',
'his','himself','she','her','hers','herself','it','its','itself','they','them',
'their','theirs','themselves','what','which','who','whom','this','that','these',
'those','am','is','are','was','were','be','been','being','have','has','had',
'having','do','does','did','doing','would','could','ought','might','shall',
'may','need','dare','if','as','while','because','until','also','although',
're','ve','ll','d','m','o','ain','aren','couldn','didn','doesn','hadn','hasn',
'haven','isn','ma','mightn','mustn','needn','shan','shouldn','wasn','weren',
'won','wouldn','dear','hi','hello','thanks','thank','regards','best','sincerely',
'sent','subject','cc','bcc','fwd','fw','pm','please',
'like','get','got','make','made','go','going','gone','come','take','took',
'know','knew','known','see','saw','seen','think','thought','say','said','tell',
'told','give','gave','given','find','found','let','put','keep','kept','set',
'seem','try','tried','left','call','called','must',
'us','ok','okay','yes','well','much','many','still','even','back','new','old',
'good','bad','great','little','big','long','right','last','first','next','sure',
'really','quite','enough','already','yet','since','ago','however','therefore',
'thus','hence','else','anyway','anything','everything','something','nothing',
'someone','anyone','everyone',
'monday','tuesday','wednesday','thursday','friday','saturday','sunday',
'january','february','march','april','june','july','august','september',
'october','november','december','today','tomorrow','yesterday','week','month','year',
'time','day','way','thing','man','woman','child','world','life','hand','part',
'case','point','company','number','group','problem','fact',
'mm','cm','km','kg','lb','oz','ft','ml','dl','mg','gb','mb','kb','tb',
'hz','mhz','ghz','px','pt','em','vh','vw','hp','rpm','mph','kph','psi',
'am','vs','id','ip','url','http','https','www','html','css','pdf','doc',
'etc','eg','ie','no','na','bc','ad','lf'
]);
export const COMMON_NAMES = new Set([
'christoph','john','robert','michael','david','william','richard','joseph','thomas','charles',
'christopher','daniel','matthew','anthony','mark','donald','steven','steve','paul','andrew',
'joshua','kenneth','kevin','brian','george','timothy','ronald','edward','jason','jeffrey',
'ryan','jacob','gary','nicholas','nick','eric','jonathan','stephen','larry','justin','scott',
'brandon','benjamin','samuel','raymond','gregory','greg','frank','alexander','alex','patrick',
'jack','dennis','jerry','tyler','aaron','jose','adam','nathan','henry','peter','zachary',
'douglas','harold','johnny','ralph','eugene','russell','randy','philip','harry','vincent',
'bobby','dylan','billy','albert','bruce','willie','gabriel','logan','alan','wayne',
'roy','louis','carl','roger','keith','lawrence','terry',
'sean','austin','jesse','joe','howard','victor','chris','jordan',
'mary','patricia','jennifer','linda','barbara','elizabeth','susan','jessica','sarah','karen',
'lisa','nancy','betty','margaret','sandra','ashley','dorothy','kimberly','emily','donna',
'michelle','carol','amanda','melissa','deborah','stephanie','rebecca','sharon','laura','cynthia',
'kathleen','amy','angela','shirley','anna','brenda','pamela','emma','nicole','helen',
'samantha','katherine','christine','debra','rachel','carolyn','janet','catherine','maria','heather',
'diane','ruth','julie','olivia','joyce','virginia','victoria','kelly','lauren','christina',
'joan','evelyn','judith','megan','andrea','cheryl','hannah','jacqueline','martha','gloria',
'teresa','ann','sara','madison','frances','kathryn','janice','jean','abigail','alice',
'julia','judy','grace','amber','denise','crystal','diana','brittany','natalie','sophia',
'rose','alexis','kayla','charlotte','tony','mike','dave','bob','jim','tim','tom','dan',
'ben','sam','matt','rob','jeff','ted','ed','rick','ray','lee','ken','don',
'smith','johnson','williams','brown','jones','garcia','miller','davis','rodriguez','martinez',
'hernandez','lopez','gonzalez','wilson','anderson','taylor','moore','jackson','martin',
'perez','thompson','white','harris','sanchez','clark','ramirez','lewis','robinson','walker',
'young','allen','king','wright','torres','nguyen','hill','flores','green','adams','nelson',
'baker','hall','rivera','campbell','mitchell','carter','roberts','gomez','phillips','evans',
'turner','diaz','parker','cruz','edwards','collins','reyes','stewart','morris','morales',
'murphy','cook','rogers','gutierrez','ortiz','morgan','cooper','peterson','bailey','reed',
'howard','ramos','kim','cox','ward','richardson','watson','brooks','chavez','wood','bennett',
'gray','mendoza','ruiz','hughes','price','alvarez','castillo','sanders','patel','myers',
'long','ross','foster','jimenez','powell','jenkins','perry','sullivan','bell',
'coleman','butler','henderson','barnes','gonzales','fisher','vasquez','simmons','graham',
'murray','freeman','wells','webb','simpson','stevens','tucker','porter','hunter','hicks',
'crawford','boyd','mason','kennedy','warren','dixon','rivas','wagner',
'pena','peters','burns','gordon','shaw','holmes','rice','robertson','hunt','black','daniels',
'palmer','mills','nichols','grant','knight','ferguson','stone','hawkins','dunn',
'perkins','hudson','spencer','gardner','stephens','payne','pierce','berry','matthews','arnold'
]);
+61
View File
@@ -0,0 +1,61 @@
import * as m0 from './constants.js';
import * as m1 from './data/stopwords.js';
import * as m2 from './core/state.js';
import * as m3 from './core/storage.js';
import * as m4 from './services/favorites.js';
import * as m5 from './core/router.js';
import * as m6 from './ui/theme.js';
import * as m7 from './ui/sidebar.js';
import * as m8 from './ui/toast.js';
import * as m9 from './ui/modal.js';
import * as m10 from './services/tts.js';
import * as m11 from './services/ai.js';
import * as m12 from './services/ebbinghaus.js';
import * as m13 from './services/stats.js';
import * as m14 from './services/quiz-generator.js';
import * as m15 from './ui/dom.js';
import * as m16 from './services/extractor.js';
import * as m17 from './services/mime.js';
import * as m18 from './pages/home.js';
import * as m19 from './pages/words.js';
import * as m20 from './pages/learn.js';
import * as m21 from './pages/quiz.js';
import * as m22 from './pages/review.js';
import * as m23 from './pages/stats.js';
import * as m24 from './pages/extract.js';
import * as m25 from './pages/mail-learn.js';
import * as m26 from './pages/reader.js';
import * as m27 from './pages/settings.js';
import * as m28 from './pages/filter-words.js';
import * as m29 from './services/library.js';
import { bindActionDelegates, registerActions } from './core/actions.js';
const shared = [m5, m9, m10];
registerActions('home', ...shared, m18);
registerActions('words', ...shared, m4, m19);
registerActions('learn', ...shared, m4, m20);
registerActions('quiz', ...shared, m4, m21);
registerActions('review', ...shared, m22);
registerActions('extract', ...shared, m24);
registerActions('mailLearn', ...shared, m25, m26);
registerActions('reader', ...shared, m26);
registerActions('settings', ...shared, m11, m24, m27, m28, m29);
registerActions('filterWords', ...shared, m28);
registerActions('modal', m9);
m5.registerPage('home', m18.renderHome);
m5.registerPage('words', m19.renderWords);
m5.registerPage('quiz', m21.renderQuiz);
m5.registerPage('review', m22.renderReview);
m5.registerPage('extract', m24.renderExtract);
m5.registerPage('mail-learn', m25.renderMailLearn);
m5.registerPage('stats', m23.renderStats);
m5.registerPage('settings', m27.renderSettings);
m5.setBeforeRender(() => {
m19.stopAutoPlay();
m23.destroyStudyChart();
});
function cleanupUpdateParam() { const url = new URL(window.location.href); if (url.searchParams.has('_app_update')) { url.searchParams.delete('_app_update'); history.replaceState(null, '', url.pathname + url.search + url.hash); } }
function init() { m3.loadState(); m28.loadCustomFilterWords(); m6.initTheme(); bindActionDelegates(); document.getElementById('menu-btn').addEventListener('click', m7.toggleSidebar); document.getElementById('overlay').addEventListener('click', m7.closeSidebar); document.getElementById('theme-toggle').addEventListener('click', m6.toggleTheme); document.getElementById('theme-toggle-mobile').addEventListener('click', m6.toggleTheme); document.addEventListener('themechange', m23.refreshStudyChart); window.addEventListener('hashchange', m5.onHashChange); m20.bindLearnKeyboard(); m5.onHashChange(); cleanupUpdateParam(); m29.autoLoadWords().catch(error => console.error('Automatic word loading failed:', error)); }
try { init(); } catch (error) { console.error('Application initialization failed:', error); }
+103
View File
@@ -0,0 +1,103 @@
import { state } from '../core/state.js';
import { extractEmlBody } from '../services/mime.js';
import { escapeHtml } from '../ui/dom.js';
import { showToast } from '../ui/toast.js';
export let batchUploadedTexts = [];
let batchFileReadGeneration = 0;
function extractHtmlText(html) {
const documentFragment = new DOMParser().parseFromString(html, 'text/html');
documentFragment.querySelectorAll('script, style, template, noscript').forEach(element => element.remove());
return documentFragment.body?.textContent || '';
}
export function handleBatchFiles(input) {
const generation = ++batchFileReadGeneration;
const files = Array.from(input.files);
if (!files.length) {
batchUploadedTexts = [];
renderBatchFileList();
return;
}
const listElement = document.getElementById('batch-file-list');
if (listElement) {
listElement.innerHTML = '<span style="font-size:12px;color:var(--text-muted)"><i class="fas fa-spinner fa-spin"></i> 读取文件中...</span>';
}
batchUploadedTexts = [];
const loadedTexts = new Array(files.length);
let loaded = 0;
function finishFile() {
if (generation !== batchFileReadGeneration) return;
loaded++;
if (loaded === files.length) {
batchUploadedTexts = loadedTexts.filter(Boolean);
renderBatchFileList();
}
}
files.forEach((file, index) => {
const reader = new FileReader();
const name = file.name.toLowerCase();
reader.onload = event => {
if (generation !== batchFileReadGeneration) return;
let text = event.target.result;
if (name.endsWith('.eml')) {
text = extractEmlBody(text);
if (!text.trim()) showToast(`邮件「${file.name}」未解析到可读正文`, 'warning');
} else if (name.endsWith('.html') || name.endsWith('.htm')) {
text = extractHtmlText(text);
}
loadedTexts[index] = { name: file.name, content: text };
finishFile();
};
reader.onerror = () => {
if (generation !== batchFileReadGeneration) return;
showToast(`文件「${file.name}」读取失败,已跳过`, 'warning');
finishFile();
};
if (name.endsWith('.eml')) reader.readAsArrayBuffer(file);
else reader.readAsText(file);
});
}
export function renderBatchFileList() {
const listElement = document.getElementById('batch-file-list');
if (!listElement) return;
if (batchUploadedTexts.length === 0) {
listElement.innerHTML = '';
return;
}
listElement.innerHTML = `
<div style="padding:10px 12px;background:var(--bg);border-radius:var(--radius-sm);border:1px solid var(--border)">
<div style="font-size:12px;color:var(--text-muted);margin-bottom:6px">已加载 ${batchUploadedTexts.length} 个文件:</div>
${batchUploadedTexts.map((item, index) => `<div style="display:flex;align-items:center;gap:8px;font-size:13px;padding:3px 0">
<i class="fas fa-file-alt" style="color:var(--primary)"></i>
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml(item.name)}</span>
<span style="font-size:11px;color:var(--text-muted)">${(item.content.length / 1024).toFixed(1)}KB</span>
<button class="btn btn-sm btn-ghost" data-action="extract.removeBatchFile" data-arg0="${index}" title="移除" style="padding:2px 6px;color:var(--error)"><i class="fas fa-times"></i></button>
</div>`).join('')}
</div>`;
updateBatchSourceInfo();
}
export function removeBatchFile(index) {
batchUploadedTexts.splice(index, 1);
renderBatchFileList();
}
export function updateBatchSourceInfo() {
const infoElement = document.getElementById('batch-source-info');
if (!infoElement) return;
const uploadCount = batchUploadedTexts.length;
const savedCount = state.mailEmails.length;
const parts = [];
if (uploadCount > 0) parts.push(`${uploadCount} 个上传文件`);
if (savedCount > 0) parts.push(`${savedCount} 封已保存邮件`);
infoElement.textContent = parts.length > 0 ? parts.join(' + ') : '暂无来源';
}
+408
View File
@@ -0,0 +1,408 @@
import { WORD_TRANSLATE_BATCH_SIZE } from '../constants.js';
import { COMMON_NAMES } from '../data/stopwords.js';
import { state } from '../core/state.js';
import { saveEmails, saveJsonSetting, saveSchedule, saveWords, STORAGE_KEYS } from '../core/storage.js';
import { renderPage } from '../core/router.js';
import { showToast } from '../ui/toast.js';
import { deduplicateBasic, deduplicateWithAI, filterNamesWithAI, translateWords } from '../services/ai.js';
import { getToday, initWordSchedule } from '../services/ebbinghaus.js';
import { autoResizeTextarea, escapeHtml } from '../ui/dom.js';
import { detectProperNouns, extractEnglishWords, extractWithFrequency, findWordContext } from '../services/extractor.js';
import { getNextId } from './words.js';
import { batchUploadedTexts } from './extract-batch-files.js';
export { batchUploadedTexts, handleBatchFiles, removeBatchFile, renderBatchFileList, updateBatchSourceInfo } from './extract-batch-files.js';
export let batchMode = false;
export let batchResults = [];
let extractGeneration = 0;
function beginExtractGeneration() {
return ++extractGeneration;
}
function isCurrentExtractGeneration(generation, ...elements) {
return generation === extractGeneration
&& state.currentPage === 'extract'
&& elements.every(element => element?.isConnected);
}
// ==================== Page: Extract (邮件提词) ====================
export function renderExtract(el) {
beginExtractGeneration();
const batchSection = state.batchExtractEnabled ? `
<div class="card" style="margin-top:20px">
<h3><i class="fas fa-layer-group"></i> 批量提词</h3>
<p style="font-size:13px;color:var(--text-secondary);margin-bottom:12px">上传邮件文件或使用已保存邮件,批量提取单词并统计出现频次。</p>
<div style="margin-bottom:12px">
<label style="font-size:13px;font-weight:600;color:var(--text);display:block;margin-bottom:6px"><i class="fas fa-file-upload"></i> 上传邮件文件</label>
<input type="file" id="batch-file-input" accept=".md,.txt,.eml,.html,.htm" multiple data-action="extract.handleBatchFiles" data-action-event="change" data-element-arg="0" style="font-size:13px">
<p class="hint" style="margin-top:4px">支持 .txt / .md / .eml / .html / .htm 格式,可多选</p>
</div>
<div id="batch-file-list" style="margin-bottom:12px"></div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<button class="btn btn-primary" id="batch-extract-btn" data-action="extract.doBatchExtract"><i class="fas fa-search-plus"></i> 批量提取</button>
<span style="font-size:12px;color:var(--text-muted)" id="batch-source-info">已保存 ${state.mailEmails.length} 封邮件</span>
<span id="batch-result"></span>
</div>
</div>` : '';
el.innerHTML = `
<div class="page-header">
<div>
<h1>邮件提词</h1>
<p class="page-desc">粘贴英文邮件,智能提取有学习价值的单词</p>
</div>
</div>
<div class="extract-layout">
<div>
<div class="card">
<h3><i class="fas fa-envelope"></i> 粘贴邮件内容</h3>
<textarea id="email-input" placeholder="将英文邮件内容粘贴到此处...&#10;&#10;系统会自动提取英文单词(过滤常见停用词),&#10;您可以选择需要的单词加入学习列表。" style="min-height:200px"></textarea>
<div style="display:flex;gap:8px;margin-top:16px;flex-wrap:wrap">
<button class="btn btn-primary" data-action="extract.doExtractFromEmail"><i class="fas fa-search"></i> 提取单词</button>
<button class="btn btn-secondary" data-action="extract.clearEmailExtract"><i class="fas fa-trash-alt"></i> 清空</button>
<button class="btn btn-secondary" data-action="extract.saveEmailForLearn"><i class="fas fa-save"></i> 保存邮件到学习</button>
</div>
<p style="font-size:12px;color:var(--text-muted);margin-top:8px">自动过滤停用词和人名/专有名词${state.settings.apiKey ? '(已启用 AI 识别)' : '(配置 AI 可增强识别)'},只保留有学习价值的单词</p>
</div>
${batchSection}
</div>
<div>
<div class="card">
<h3><i class="fas fa-tags"></i> 选择要添加的单词</h3>
<div id="word-chips" class="word-chips"></div>
<div id="extract-info" style="font-size:13px;color:var(--text-muted);margin-top:12px"></div>
<div id="extract-actions" class="extract-actions" style="display:none">
<button class="btn btn-sm btn-secondary" data-action="extract.selectAllChips">全选</button>
<button class="btn btn-sm btn-secondary" data-action="extract.deselectAllChips">取消全选</button>
<button class="btn btn-sm btn-primary" id="add-selected-btn" data-action="extract.addSelectedWords"><i class="fas fa-plus"></i> 翻译并添加选中 (0)</button>
</div>
<div id="extract-progress" style="margin-top:12px;font-size:13px;color:var(--text-muted);display:none"></div>
<div style="margin-top:16px;padding:16px;background:var(--bg);border-radius:var(--radius-sm);font-size:13px;color:var(--text-secondary);line-height:1.6">
<strong>使用说明:</strong><br>
1. 在左侧粘贴邮件原文<br>
2. 点击「提取单词」<br>
3. 点击单词选中/取消(灰色划线表示已在词库中)<br>
4. 点击「翻译并添加选中」批量加入词库
</div>
</div>
</div>
</div>
`;
const ta = document.getElementById('email-input');
ta.addEventListener('input', () => autoResizeTextarea(ta));
ta.addEventListener('paste', () => setTimeout(() => autoResizeTextarea(ta), 0));
}
export async function doExtractFromEmail() {
const generation = beginExtractGeneration();
const inputEl = document.getElementById('email-input');
const chipsContainer = document.getElementById('word-chips');
const extractBtn = document.querySelector('[data-action="extract.doExtractFromEmail"]');
const text = inputEl?.value.trim() || '';
if (!text) { showToast('请粘贴邮件内容', 'error'); return; }
batchMode = false;
if (extractBtn) { extractBtn.disabled = true; extractBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 提取中...'; }
const extracted = extractEnglishWords(text);
if (!extracted.length) {
if (extractBtn?.isConnected) { extractBtn.disabled = false; extractBtn.innerHTML = '<i class="fas fa-search"></i> 提取单词'; }
showToast('未提取到有效英文单词', 'info');
return;
}
const properNouns = detectProperNouns(text);
let filtered = extracted.filter(w => !COMMON_NAMES.has(w) && !properNouns.has(w));
let nameCount = extracted.length - filtered.length;
const hasAI = state.settings.apiKey && state.settings.apiUrl && state.settings.model;
if (hasAI && filtered.length > 0) {
showToast('正在使用 AI 识别人名...', 'info');
const aiNames = await filterNamesWithAI(filtered);
if (!isCurrentExtractGeneration(generation, inputEl, chipsContainer)) return;
if (aiNames.length > 0) {
const aiNameSet = new Set(aiNames);
filtered = filtered.filter(w => !aiNameSet.has(w));
nameCount += aiNames.length;
}
}
let dedupCount = 0;
if (filtered.length > 0) {
if (hasAI) showToast('正在使用 AI 去重词形变化...', 'info');
const dedupMapping = hasAI ? await deduplicateWithAI(filtered) : deduplicateBasic(filtered);
if (!isCurrentExtractGeneration(generation, inputEl, chipsContainer)) return;
dedupCount = Object.keys(dedupMapping).length;
if (dedupCount > 0) filtered = filtered.filter(w => !dedupMapping[w]);
}
if (!isCurrentExtractGeneration(generation, inputEl, chipsContainer)) return;
if (extractBtn?.isConnected) { extractBtn.disabled = false; extractBtn.innerHTML = '<i class="fas fa-search"></i> 提取单词'; }
renderWordChips(filtered);
const nameMsg = nameCount > 0 ? `,过滤了 ${nameCount} 个人名/专有名词` : '';
const dedupMsg = dedupCount > 0 ? `,合并了 ${dedupCount} 个词形变化` : '';
showToast(`提取到 ${filtered.length} 个单词${nameMsg}${dedupMsg}`, 'success');
}
export function renderWordChips(wordList) {
const container = document.getElementById('word-chips');
const existingWords = new Set(state.words.map(w => w.english.toLowerCase()));
container.innerHTML = wordList.map(w => {
const isExist = existingWords.has(w);
return `<span class="word-chip ${isExist ? 'exists' : ''}" data-word="${escapeHtml(w)}"
${isExist ? '' : 'data-action="extract.toggleChip" data-pass-element="true"'}
title="${isExist ? '已在单词列表中' : '点击选中'}">${escapeHtml(w)}${isExist ? ' \u2713' : ''}</span>`;
}).join('');
const selectable = wordList.filter(w => !existingWords.has(w)).length;
const existing = wordList.length - selectable;
document.getElementById('extract-info').textContent = `${wordList.length} 个单词,${existing} 个已存在,${selectable} 个可添加`;
document.getElementById('extract-actions').style.display = selectable > 0 ? 'flex' : 'none';
}
export function toggleChip(el) { el.classList.toggle('selected'); updateSelectedCount(); }
export function selectAllChips() { document.querySelectorAll('.word-chip:not(.exists)').forEach(c => c.classList.add('selected')); updateSelectedCount(); }
export function deselectAllChips() { document.querySelectorAll('.word-chip.selected').forEach(c => c.classList.remove('selected')); updateSelectedCount(); }
export function updateSelectedCount() {
const count = document.querySelectorAll('.word-chip.selected').length;
const btn = document.getElementById('add-selected-btn');
if (btn) btn.innerHTML = `<i class="fas fa-plus"></i> 翻译并添加选中 (${count})`;
}
export async function addSelectedWords() {
const generation = extractGeneration;
const libraryId = state.activeWordLibrary;
const chipsContainer = document.getElementById('word-chips');
const chips = [...document.querySelectorAll('.word-chip.selected')];
const selected = chips.map(c => ({ word: c.dataset.word, freq: parseInt(c.dataset.freq) || 0 }));
if (!selected.length) { showToast('请先选择要添加的单词', 'error'); return; }
const isBatch = !!batchMode;
let emailText, emailTitle;
if (isBatch) {
const uploadedTexts = (batchUploadedTexts || []).map(t => t.content);
const savedTexts = state.mailEmails.map(e => e.content);
emailText = [...uploadedTexts, ...savedTexts].join('\n\n');
emailTitle = '批量提取';
} else {
emailText = document.getElementById('email-input').value.trim();
emailTitle = emailText.slice(0, 40).replace(/\n/g, ' ') + '...';
}
const progress = document.getElementById('extract-progress');
progress.style.display = 'block';
document.getElementById('add-selected-btn').disabled = true;
let success = 0, fail = 0, skipped = 0;
const addedWordIds = [];
const isCurrentOperation = () => state.activeWordLibrary === libraryId
&& isCurrentExtractGeneration(generation, chipsContainer, progress);
const discardAddedWords = () => {
if (state.activeWordLibrary !== libraryId || addedWordIds.length === 0) return;
const addedIdSet = new Set(addedWordIds);
state.words = state.words.filter(word => !addedIdSet.has(word.id));
state.schedule = { ...state.schedule };
addedWordIds.forEach(id => delete state.schedule[id]);
saveWords();
saveSchedule();
};
const existing = new Set(state.words.map(w => String(w.english || '').trim().toLowerCase()).filter(Boolean));
const handled = new Set();
let nextId = getNextId(); // 循环外取一次基准 id,循环内自增,避免逐词全量重扫
for (let i = 0; i < selected.length; i += WORD_TRANSLATE_BATCH_SIZE) {
const batch = selected.slice(i, i + WORD_TRANSLATE_BATCH_SIZE);
const end = Math.min(i + batch.length, selected.length);
progress.textContent = `翻译中 ${i + 1}-${end}/${selected.length},一次请求 ${batch.length} 个单词...`;
try {
const results = await translateWords(batch.map(item => item.word));
if (!isCurrentOperation()) { discardAddedWords(); return; }
// 翻译等待期间用户可能在别处手动加词,刷新基准防止 id 冲突
nextId = Math.max(nextId, getNextId());
const resultMap = new Map(results.map(item => [String(item.english || '').trim().toLowerCase(), item]));
batch.forEach(item => {
const key = item.word.trim().toLowerCase();
if (!key || handled.has(key)) return;
handled.add(key);
if (existing.has(key)) {
skipped++;
return;
}
const base = resultMap.get(key);
if (!base || !String(base.chinese || '').trim()) {
// 缺少中文释义的脏数据不入库,否则本地出题会因缺字段崩溃
fail++;
return;
}
const contextSentence = findWordContext(item.word, emailText);
const frequency = item.freq > 0 ? Math.max(base.frequency || 0, item.freq) : base.frequency;
state.words.push({
id: nextId++,
...base,
frequency,
sourceContext: contextSentence ? { emailTitle, sentence: contextSentence } : null
});
const addedWordId = state.words[state.words.length - 1].id;
initWordSchedule(addedWordId, false);
addedWordIds.push(addedWordId);
existing.add(key);
success++;
});
} catch (err) {
if (!isCurrentOperation()) { discardAddedWords(); return; }
batch.forEach(item => {
const key = item.word.trim().toLowerCase();
if (!key || handled.has(key)) return;
handled.add(key);
if (existing.has(key)) { skipped++; return; }
fail++;
});
}
}
if (success > 0 && !(saveWords() && saveSchedule())) {
discardAddedWords();
fail += success;
success = 0;
}
document.getElementById('add-selected-btn').disabled = false;
const skippedText = skipped > 0 ? `,跳过 ${skipped} 个(已在词库)` : '';
progress.textContent = `完成!成功 ${success} 个,失败 ${fail}${skippedText}`;
showToast(`批量添加完成:${success} 成功,${fail} 失败${skippedText}`, success > 0 ? 'success' : 'error');
if (success > 0) {
if (isBatch) {
doBatchExtract();
} else {
const proper = detectProperNouns(emailText);
const refreshed = extractEnglishWords(emailText).filter(w => !COMMON_NAMES.has(w) && !proper.has(w));
renderWordChips(refreshed);
}
}
}
export function clearEmailExtract() {
beginExtractGeneration();
batchMode = false;
batchResults = [];
const ta = document.getElementById('email-input');
ta.value = '';
ta.style.height = '200px';
document.getElementById('word-chips').innerHTML = '';
document.getElementById('extract-info').textContent = '';
document.getElementById('extract-actions').style.display = 'none';
document.getElementById('extract-progress').style.display = 'none';
const statusEl = document.getElementById('batch-result');
if (statusEl) statusEl.innerHTML = '';
}
export function saveEmailForLearn() {
const text = document.getElementById('email-input').value.trim();
if (!text) { showToast('请先粘贴邮件内容', 'error'); return; }
const email = { id: Date.now(), content: text, date: getToday(), title: text.slice(0, 50) + '...' };
state.mailEmails.push(email);
if (!saveEmails()) {
const index = state.mailEmails.indexOf(email);
if (index >= 0) state.mailEmails.splice(index, 1);
return;
}
showToast('邮件已保存,可在「邮件学习」中查看', 'success');
}
export async function doBatchExtract() {
const generation = beginExtractGeneration();
const uploadedTexts = (batchUploadedTexts || []).map(t => t.content);
const savedTexts = state.mailEmails.map(e => e.content);
const allEmails = [...uploadedTexts, ...savedTexts];
if (allEmails.length === 0) { showToast('请上传文件或先保存邮件', 'warning'); return; }
const batchBtn = document.getElementById('batch-extract-btn');
const chipsContainer = document.getElementById('word-chips');
if (batchBtn) { batchBtn.disabled = true; batchBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 提取中...'; }
let results = extractWithFrequency(allEmails);
if (results.length === 0) {
if (batchBtn) { batchBtn.disabled = false; batchBtn.innerHTML = '<i class="fas fa-search-plus"></i> 批量提取'; }
showToast('未提取到有效单词', 'info');
return;
}
const words = results.map(r => r.word);
const hasAI = state.settings.apiKey && state.settings.apiUrl && state.settings.model;
let dedupCount = 0;
if (words.length > 0) {
if (hasAI) showToast('正在使用 AI 去重词形变化...', 'info');
const dedupMapping = hasAI ? await deduplicateWithAI(words) : deduplicateBasic(words);
if (!isCurrentExtractGeneration(generation, chipsContainer)) return;
dedupCount = Object.keys(dedupMapping).length;
if (dedupCount > 0) {
const resultMap = {};
for (const r of results) resultMap[r.word] = r;
for (const [variant, base] of Object.entries(dedupMapping)) {
if (resultMap[variant] && resultMap[base]) {
resultMap[base].emailCount = Math.max(resultMap[base].emailCount, resultMap[variant].emailCount);
resultMap[base].totalCount += resultMap[variant].totalCount;
}
}
results = results.filter(r => !dedupMapping[r.word]);
}
}
if (!isCurrentExtractGeneration(generation, chipsContainer)) return;
if (batchBtn?.isConnected) { batchBtn.disabled = false; batchBtn.innerHTML = '<i class="fas fa-search-plus"></i> 批量提取'; }
batchResults = results;
batchMode = true;
const existingWords = new Set(state.words.map(w => w.english.toLowerCase()));
const infoEl = document.getElementById('extract-info');
const actionsEl = document.getElementById('extract-actions');
if (!chipsContainer) return;
chipsContainer.innerHTML = results.map(r => {
const isExist = existingWords.has(r.word);
return `<span class="word-chip ${isExist ? 'exists' : ''}" data-word="${escapeHtml(r.word)}" data-freq="${r.emailCount}"
${isExist ? '' : 'data-action="extract.toggleChip" data-pass-element="true"'}
title="${isExist ? '已在词库中' : '点击选中'} | 出现在 ${r.emailCount} 封邮件中,共 ${r.totalCount} 次">${escapeHtml(r.word)} <sup style="font-size:10px;color:var(--primary);font-weight:700">${r.emailCount}</sup>${isExist ? ' \u2713' : ''}</span>`;
}).join('');
const selectable = results.filter(r => !existingWords.has(r.word)).length;
const existing = results.length - selectable;
const dedupMsg = dedupCount > 0 ? `,合并 ${dedupCount} 个词形变化` : '';
if (infoEl) infoEl.textContent = `${allEmails.length} 个来源中提取到 ${results.length} 个单词,${existing} 个已存在,${selectable} 个可添加${dedupMsg}(频次排序)`;
if (actionsEl) actionsEl.style.display = selectable > 0 ? 'flex' : 'none';
const statusEl = document.getElementById('batch-result');
if (statusEl) statusEl.innerHTML = `<span style="font-size:12px;color:var(--success)"><i class="fas fa-check-circle"></i> 已提取到右侧面板</span>`;
updateSelectedCount();
showToast(`批量提取完成:${results.length} 个单词${dedupMsg}`, 'success');
}
export function toggleBatchExtract(enabled) {
const previousValue = state.batchExtractEnabled;
state.batchExtractEnabled = !!enabled;
if (!saveJsonSetting(STORAGE_KEYS.batchExtractEnabled, state.batchExtractEnabled)) {
state.batchExtractEnabled = previousValue;
return false;
}
showToast(enabled ? '已开启批量提词模式' : '已关闭批量提词模式', 'info');
if (state.currentPage === 'settings') renderPage('settings');
return true;
}
+149
View File
@@ -0,0 +1,149 @@
import { COMMON_NAMES, STOP_WORDS } from '../data/stopwords.js';
import { loadJsonSetting, saveJsonSetting, STORAGE_KEYS } from '../core/storage.js';
import { showToast } from '../ui/toast.js';
import { closeModal, showModal } from '../ui/modal.js';
import { escapeHtml } from '../ui/dom.js';
// ==================== Filter Words Management ====================
function normalizeCustomFilterWords(value) {
const data = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
const normalizeList = list => Array.isArray(list)
? [...new Set(list.filter(word => typeof word === 'string').map(word => word.trim().toLowerCase()).filter(Boolean))]
: [];
return {
addedStopWords: normalizeList(data.addedStopWords),
removedStopWords: normalizeList(data.removedStopWords),
addedCommonNames: normalizeList(data.addedCommonNames),
removedCommonNames: normalizeList(data.removedCommonNames)
};
}
export function loadCustomFilterWords() {
const data = normalizeCustomFilterWords(loadJsonSetting(STORAGE_KEYS.customFilterWords, {}));
data.addedStopWords.forEach(w => STOP_WORDS.add(w));
data.removedStopWords.forEach(w => STOP_WORDS.delete(w));
data.addedCommonNames.forEach(w => COMMON_NAMES.add(w));
data.removedCommonNames.forEach(w => COMMON_NAMES.delete(w));
}
export function saveCustomFilterWords(action, type, word) {
const data = normalizeCustomFilterWords(loadJsonSetting(STORAGE_KEYS.customFilterWords, {}));
if (type === 'stop') {
if (action === 'add') {
data.addedStopWords = [...new Set([...data.addedStopWords, word])];
data.removedStopWords = data.removedStopWords.filter(w => w !== word);
} else {
data.removedStopWords = [...new Set([...data.removedStopWords, word])];
data.addedStopWords = data.addedStopWords.filter(w => w !== word);
}
} else if (type === 'names') {
if (action === 'add') {
data.addedCommonNames = [...new Set([...data.addedCommonNames, word])];
data.removedCommonNames = data.removedCommonNames.filter(w => w !== word);
} else {
data.removedCommonNames = [...new Set([...data.removedCommonNames, word])];
data.addedCommonNames = data.addedCommonNames.filter(w => w !== word);
}
} else {
return false;
}
if (!saveJsonSetting(STORAGE_KEYS.customFilterWords, data)) return false;
const targetSet = type === 'stop' ? STOP_WORDS : COMMON_NAMES;
if (action === 'add') targetSet.add(word);
else targetSet.delete(word);
return true;
}
export function showFilterWordsModal(type) {
const isStop = type === 'stop';
const title = isStop ? '<i class="fas fa-ban" style="color:var(--error)"></i> 管理停用词' : '<i class="fas fa-user-slash" style="color:var(--primary)"></i> 管理常见人名';
const targetSet = isStop ? STOP_WORDS : COMMON_NAMES;
const words = [...targetSet].sort();
showModal(title, `
<div style="margin-bottom:12px">
<div style="display:flex;gap:8px;align-items:center">
<input type="text" id="filter-word-input" placeholder="输入要添加的${isStop ? '停用词' : '人名'}..." style="flex:1;padding:8px 12px;border:1px solid var(--border);border-radius:var(--radius-sm);font-size:14px;background:var(--card);color:var(--text)">
<button class="btn btn-primary btn-sm" data-action="filterWords.addFilterWord" data-arg0="${type}"><i class="fas fa-plus"></i> 添加</button>
</div>
<p class="hint" style="margin-top:4px">多个词用空格或逗号分隔</p>
</div>
<div style="display:flex;gap:8px;margin-bottom:12px">
<input type="text" id="filter-word-search" placeholder="搜索..." data-action="filterWords.filterWordSearch" data-action-event="input" data-arg0="${type}" style="flex:1;padding:6px 10px;border:1px solid var(--border);border-radius:var(--radius-sm);font-size:13px;background:var(--card);color:var(--text)">
<button class="btn btn-sm btn-secondary" data-action="filterWords.copyFilterWords" data-arg0="${type}"><i class="fas fa-copy"></i> 复制全部</button>
</div>
<div style="font-size:12px;color:var(--text-muted);margin-bottom:8px">共 ${words.length} 个词,点击词语可删除</div>
<div id="filter-words-container" style="max-height:360px;overflow-y:auto;display:flex;flex-wrap:wrap;gap:4px;padding:8px;background:var(--bg);border-radius:var(--radius-sm);border:1px solid var(--border)">
${words.map(w => `<span class="word-chip" data-action="filterWords.removeFilterWord" data-arg0="${escapeHtml(type)}" data-arg1="${escapeHtml(w)}" title="点击删除" style="cursor:pointer;font-size:12px;padding:3px 8px">${escapeHtml(w)}</span>`).join('')}
</div>
`);
const input = document.getElementById('filter-word-input');
if (input) {
input.addEventListener('keydown', e => {
if (e.key === 'Enter') addFilterWord(type);
});
}
}
export function addFilterWord(type) {
const input = document.getElementById('filter-word-input');
if (!input) return;
const raw = input.value.trim().toLowerCase();
if (!raw) return;
const newWords = raw.split(/[\s,]+/).filter(w => w.length > 0);
if (newWords.length === 0) return;
let added = 0;
const targetSet = type === 'stop' ? STOP_WORDS : COMMON_NAMES;
for (const w of newWords) {
if (!targetSet.has(w) && saveCustomFilterWords('add', type, w)) added++;
}
input.value = '';
showToast(`已添加 ${added} 个词`, 'success');
showFilterWordsModal(type);
}
export function removeFilterWord(type, word) {
if (!confirm(`确定要删除「${word}」吗?`)) return false;
if (!saveCustomFilterWords('remove', type, word)) return false;
showToast(`已删除「${word}`, 'info');
showFilterWordsModal(type);
return true;
}
export function filterWordSearch(type) {
const q = (document.getElementById('filter-word-search')?.value || '').toLowerCase();
const container = document.getElementById('filter-words-container');
if (!container) return;
container.querySelectorAll('.word-chip').forEach(chip => {
chip.style.display = chip.textContent.toLowerCase().includes(q) ? '' : 'none';
});
}
export function copyFilterWords(type) {
const targetSet = type === 'stop' ? STOP_WORDS : COMMON_NAMES;
const name = type === 'stop' ? 'STOP_WORDS' : 'COMMON_NAMES';
const words = [...targetSet].sort();
const lines = [];
for (let i = 0; i < words.length; i += 10) {
const chunk = words.slice(i, i + 10).map(w => `'${w}'`).join(',');
lines.push(' ' + chunk + (i + 10 < words.length ? ',' : ''));
}
const code = `const ${name} = new Set([\n${lines.join('\n')}\n]);`;
navigator.clipboard.writeText(code).then(() => {
showToast('已复制到剪贴板,可直接用来修改代码', 'success');
}).catch(() => {
showModal('复制内容', `<textarea style="width:100%;height:300px;font-family:monospace;font-size:12px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:var(--radius-sm);padding:12px" readonly>${escapeHtml(code)}</textarea>`, `
<button class="btn btn-secondary" data-action="filterWords.closeModal">关闭</button>
`);
});
}
+91
View File
@@ -0,0 +1,91 @@
import { state } from '../core/state.js';
import { navigate } from '../core/router.js';
import { getQuizErrorWords, getStats, getStreak } from '../services/stats.js';
import { escapeHtml } from '../ui/dom.js';
// ==================== Page: Home ====================
export function renderHome(el) {
const s = getStats();
const streak = getStreak();
const errorWords = getQuizErrorWords();
const hour = new Date().getHours();
const greeting = hour < 6 ? '夜深了,注意休息' : hour < 12 ? '早上好,元气满满' : hour < 18 ? '下午好,继续加油' : '晚上好,坚持学习';
el.innerHTML = `
<div class="page-header">
<div>
<h1>仪表盘</h1>
<p class="page-desc">${greeting}!今天也要加油学习 💪</p>
</div>
${streak > 0 ? `<div class="streak-display"><i class="fas fa-fire"></i> <span class="streak-num">${streak}</span> 天连续学习</div>` : ''}
</div>
<div class="stat-grid">
<div class="stat-card" style="border-left:3px solid var(--primary)">
<div class="stat-icon" style="background:var(--primary-bg);color:var(--primary)"><i class="fas fa-database"></i></div>
<div class="stat-info">
<div class="stat-value">${s.total}</div>
<div class="stat-label">总单词</div>
</div>
</div>
<div class="stat-card" style="border-left:3px solid var(--success)">
<div class="stat-icon" style="background:var(--success-light);color:var(--success)"><i class="fas fa-check-circle"></i></div>
<div class="stat-info">
<div class="stat-value">${s.mastered}</div>
<div class="stat-label">已掌握</div>
</div>
</div>
<div class="stat-card" style="border-left:3px solid var(--warning)">
<div class="stat-icon" style="background:var(--warning-light);color:var(--warning)"><i class="fas fa-spinner"></i></div>
<div class="stat-info">
<div class="stat-value">${s.learning}</div>
<div class="stat-label">学习中</div>
</div>
</div>
<div class="stat-card" style="border-left:3px solid var(--error)">
<div class="stat-icon" style="background:var(--error-light);color:var(--error)"><i class="fas fa-redo"></i></div>
<div class="stat-info">
<div class="stat-value">${s.due}</div>
<div class="stat-label">错题数</div>
</div>
</div>
</div>
<div class="home-actions">
<div class="card action-card" data-action="home.navigate" data-arg0="extract">
<div class="action-icon" style="background:rgba(59,130,246,0.1);color:#3b82f6"><i class="fas fa-envelope"></i></div>
<h3>邮件提词</h3>
<p class="action-count">粘贴邮件,智能提取英文单词</p>
<button class="btn btn-primary btn-lg">开始提取</button>
</div>
<div class="card action-card" data-action="home.navigate" data-arg0="quiz">
<div class="action-icon" style="background:rgba(168,85,247,0.1);color:#a855f7"><i class="fas fa-robot"></i></div>
<h3>AI 测试</h3>
<p class="action-count">智能出题,检测你的掌握程度</p>
<button class="btn btn-primary btn-lg">开始测试</button>
</div>
<div class="card action-card" data-action="home.navigate" data-arg0="mail-learn">
<div class="action-icon" style="background:rgba(34,197,94,0.1);color:#22c55e"><i class="fas fa-graduation-cap"></i></div>
<h3>邮件学习</h3>
<p class="action-count">${state.mailEmails.length > 0 ? state.mailEmails.length + ' 封已保存邮件' : 'AI 分析邮件,全文学习'}</p>
<button class="btn btn-primary btn-lg">开始学习</button>
</div>
</div>
${errorWords.length > 0 ? `
<div class="card">
<h3><i class="fas fa-exclamation-circle"></i> 错题单词预览</h3>
<div class="review-list">
${errorWords.slice(0, 12).map(w => `
<div class="review-word-item">
<div class="rw-en">${escapeHtml(w.english)}</div>
<div class="rw-zh">${escapeHtml(w.chinese)}</div>
<div class="rw-stage">正确率 ${w.correctRate}%</div>
</div>`).join('')}
</div>
${errorWords.length > 12 ? `<p style="text-align:center;color:var(--text-muted);margin-top:12px">还有 ${errorWords.length - 12} 个...</p>` : ''}
</div>` : ''}
`;
}
+350
View File
@@ -0,0 +1,350 @@
import { state } from '../core/state.js';
import { saveJsonSetting, STORAGE_KEYS } from '../core/storage.js';
import { isFavorite, toggleFavorite } from '../services/favorites.js';
import { renderPage } from '../core/router.js';
import { prefetchAudio, speak, speakSlow } from '../services/tts.js';
import { recordAndUpdateSchedule } from '../services/ebbinghaus.js';
import { getMastery } from '../services/stats.js';
import { escapeHtml } from '../ui/dom.js';
import { closeModal } from '../ui/modal.js';
import { closeSidebar } from '../ui/sidebar.js';
import { startReviewSession } from './review.js';
// ==================== Page: Learn Session (shared by Review) ====================
export function startLearnSession(cards) {
if (!Array.isArray(cards) || cards.length === 0) return false;
state.learnSession = {
active: true,
isReview: true,
cards,
index: 0,
flipped: false,
autoDisplay: state.learnAutoDisplay,
results: { know: 0, fuzzy: 0, unknown: 0 },
rated: []
};
renderPage('review');
setTimeout(() => {
const word = state.learnSession?.cards[0];
if (word) speak(word.english);
}, 300);
return true;
}
export function getWeakWords() {
return state.words.filter(w => {
const sch = state.schedule[w.id];
if (!sch) return false;
const total = sch.correctCount + sch.incorrectCount;
return total > 0 && (sch.incorrectCount / total) > 0.4;
});
}
export function renderLearnSession(el) {
const s = state.learnSession;
// 目前仅 startReviewSession 创建会话(均为 review)。非 review 会话不渲染会白屏,此处兜底结束会话
if (!s || !s.isReview || !Array.isArray(s.cards)) {
console.warn('renderLearnSession: 收到非 review 的 learnSession,已结束该会话');
endLearnSession();
return;
}
if (s.index >= s.cards.length) {
renderLearnComplete(el);
return;
}
const w = s.cards[s.index];
const progress = ((s.index + 1) / s.cards.length * 100).toFixed(1);
const catMap = { high_frequency: 'HIGH FREQUENCY', medium_frequency: 'MEDIUM FREQUENCY', low_frequency: 'LOW FREQUENCY' };
const catText = catMap[w.category] || (w.category || '').toUpperCase().replace(/_/g, ' ');
const catClass = w.category === 'high_frequency' ? 'high' : w.category === 'medium_frequency' ? 'mid' : 'low';
const autoShow = s.autoDisplay !== false;
const isRevealed = autoShow || s.flipped;
const isReview = !!s.isReview;
const alreadyRated = isReview && s.rated && s.rated[s.index];
let formsHtml = '';
if (w.forms) {
formsHtml = Object.entries(w.forms).map(([key, val]) => {
const v = Array.isArray(val) ? val.join(', ') : val;
return `<div class="lv2-gcard-form-item"><span class="lv2-gcard-form-key">${escapeHtml(key)}:</span> ${escapeHtml(v)}</div>`;
}).join('');
}
const mastery = getMastery(w.id);
const masteryMap = { mastered: '已掌握', learning: '学习中', new: '新词' };
const safeWord = escapeHtml(w.english);
const cardHtml = `
<div class="lv2-gcard" ${!autoShow ? 'data-action="learn.flipLearnCard"' : ''} style="${!autoShow ? 'cursor:pointer' : 'cursor:default'}">
<div class="lv2-gcard-inner">
<div class="lv2-gcard-word">${escapeHtml(w.english)}</div>
<div class="lv2-gcard-prow">
<span class="lv2-gcard-ph">${escapeHtml(w.phonetic || '')}</span>
${w.frequency != null ? `<span class="lv2-gcard-freq">频次: ${w.frequency}</span>` : ''}
</div>
${catText ? `<span class="lv2-gcard-cat lv2-gcard-cat-${catClass}">${escapeHtml(catText)}</span>` : ''}
${isRevealed ? `
<div class="lv2-gcard-divider"></div>
<div class="lv2-gcard-zh">${escapeHtml(w.chinese)}</div>
${formsHtml ? `<div class="lv2-gcard-forms">${formsHtml}</div>` : ''}
<div class="lv2-gcard-speak-row">
<button class="lv2-gcard-speak" data-action="learn.speak" data-arg0="${safeWord}" title="正常语速"><i class="fas fa-volume-up"></i></button>
<button class="lv2-gcard-speak lv2-gcard-speak-slow" data-action="learn.speakSlow" data-arg0="${safeWord}" title="慢速"><i class="fas fa-walking"></i></button>
<button class="lv2-gcard-speak" id="learn-fav-btn" data-action="learn.toggleLearnFavorite" data-arg0="${w.id}" title="${isFavorite(w.id) ? '取消收藏' : '收藏'}" style="color:${isFavorite(w.id) ? 'var(--warning)' : 'var(--text-muted)'}"><i class="fas fa-star"></i></button>
</div>
${w.example ? `
<div class="lv2-gcard-ex">
<div class="lv2-gcard-ex-en">${escapeHtml(w.example.en)}</div>
<div class="lv2-gcard-ex-cn">${escapeHtml(w.example.cn)}</div>
</div>` : ''}
` : `
<div class="lv2-gcard-speak-row" style="margin-top:20px">
<button class="lv2-gcard-speak" data-action="learn.speak" data-arg0="${safeWord}" title="正常语速"><i class="fas fa-volume-up"></i></button>
<button class="lv2-gcard-speak lv2-gcard-speak-slow" data-action="learn.speakSlow" data-arg0="${safeWord}" title="慢速"><i class="fas fa-walking"></i></button>
<button class="lv2-gcard-speak" id="learn-fav-btn" data-action="learn.toggleLearnFavorite" data-arg0="${w.id}" title="${isFavorite(w.id) ? '取消收藏' : '收藏'}" style="color:${isFavorite(w.id) ? 'var(--warning)' : 'var(--text-muted)'}"><i class="fas fa-star"></i></button>
</div>
<div class="lv2-gcard-hint"><i class="fas fa-hand-pointer"></i> 点击卡片查看释义</div>
`}
</div>
<div class="lv2-gcard-badge">${masteryMap[mastery] || '新词'}</div>
</div>`;
const rateHtml = isReview ? `
<div class="review-rate-card">
${isRevealed ? `
<div class="lv2-rate-row">
<button class="lv2-rate lv2-rate-know ${alreadyRated === 'know' ? 'lv2-rate-selected' : ''}" data-action="learn.rateLearnCard" data-arg0="know"><i class="fas fa-check"></i> 认识</button>
<button class="lv2-rate lv2-rate-fuzzy ${alreadyRated === 'fuzzy' ? 'lv2-rate-selected' : ''}" data-action="learn.rateLearnCard" data-arg0="fuzzy"><i class="fas fa-question"></i> 模糊</button>
<button class="lv2-rate lv2-rate-unknown ${alreadyRated === 'unknown' ? 'lv2-rate-selected' : ''}" data-action="learn.rateLearnCard" data-arg0="unknown"><i class="fas fa-times"></i> 不认识</button>
</div>` : `<div class="review-rate-placeholder"><i class="fas fa-eye-slash" style="margin-right:6px"></i>查看释义后评价</div>`}
</div>` : '';
if (isReview) {
const statusItems = s.cards.map((c, idx) => {
const rated = s.rated && s.rated[idx];
const isCurrent = idx === s.index;
let icon = '<i class="far fa-circle" style="color:var(--text-muted)"></i>';
let labelText = '';
if (rated === 'know') { icon = '<i class="fas fa-check-circle" style="color:var(--success)"></i>'; labelText = '认识'; }
else if (rated === 'fuzzy') { icon = '<i class="fas fa-question-circle" style="color:var(--warning)"></i>'; labelText = '模糊'; }
else if (rated === 'unknown') { icon = '<i class="fas fa-times-circle" style="color:var(--error)"></i>'; labelText = '不认识'; }
const activeClass = isCurrent ? 'review-status-active' : '';
return `<div class="review-status-item ${activeClass}" data-action="learn.jumpToReviewCard" data-arg0="${idx}">
<span class="review-status-icon">${icon}</span>
<span class="review-status-word">${escapeHtml(c.english)}</span>
${labelText ? `<span class="review-status-label">${labelText}</span>` : ''}
</div>`;
}).join('');
const cardIndexChanged = state.lastReviewIndex !== s.index;
state.lastReviewIndex = s.index;
el.innerHTML = `
<div class="page-header" style="margin-bottom:12px">
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<h1>错题复习</h1>
<span class="badge" style="background:var(--success-light);color:var(--success)">${s.results.know} 认识</span>
<span class="badge" style="background:var(--warning-light);color:var(--warning)">${s.results.fuzzy + s.results.unknown} 待加强</span>
</div>
<div style="display:flex;align-items:center;gap:8px">
<button class="lv2-tb-btn ${autoShow ? 'lv2-tb-active' : ''}" data-action="learn.toggleLearnAutoDisplay"><i class="fas ${autoShow ? 'fa-book-open' : 'fa-sync-alt'}"></i> ${autoShow ? '自动显示' : '翻转模式'}</button>
<button class="btn btn-ghost" data-action="learn.endLearnSession">结束复习</button>
</div>
</div>
<div class="lv2-pbar"><div class="lv2-pfill" style="width:${progress}%"></div></div>
<div class="review-session-grid">
<div class="review-grid-footer">
${rateHtml}
</div>
<div class="review-grid-card">
<div class="lv2-card-area">${cardHtml}</div>
</div>
<div class="review-grid-panel">
<div class="review-status-panel">
<div class="review-status-header">
<h4><i class="fas fa-list-ol" style="margin-right:6px"></i>错题进度 <span style="font-weight:400;color:var(--text-muted);font-size:12px">${s.index + 1} / ${s.cards.length}</span></h4>
</div>
<div class="review-status-list" id="review-status-list">${statusItems}</div>
</div>
</div>
</div>
`;
if (cardIndexChanged) {
const card = el.querySelector('.lv2-gcard');
if (card) { card.classList.add('lv2-gcard-anim'); }
}
const list = document.getElementById('review-status-list');
const activeEl = list?.querySelector('.review-status-active');
if (activeEl && list) {
const top = activeEl.offsetTop - list.offsetTop;
if (top < list.scrollTop || top + activeEl.offsetHeight > list.scrollTop + list.clientHeight) {
list.scrollTop = top - list.clientHeight / 2 + activeEl.offsetHeight / 2;
}
}
}
prefetchAudio(w.english);
const nextW = s.cards[s.index + 1];
if (nextW) prefetchAudio(nextW.english);
}
export function jumpToReviewCard(idx) {
if (!state.learnSession) return;
state.learnSession.index = idx;
state.learnSession.flipped = false;
rerenderLearnSession();
const s = state.learnSession;
if (s.cards[idx]) {
speak(s.cards[idx].english);
}
}
export function toggleLearnFavorite(wordId) {
if (!toggleFavorite(wordId)) return false;
const faved = isFavorite(wordId);
const btn = document.getElementById('learn-fav-btn');
if (btn) {
btn.style.color = faved ? 'var(--warning)' : 'var(--text-muted)';
btn.title = faved ? '取消收藏' : '收藏';
}
}
export function rerenderLearnSession() {
const el = document.getElementById('page-content');
if (el && state.learnSession && state.learnSession.active) {
renderLearnSession(el);
} else {
renderPage(state.currentPage);
}
}
export function flipLearnCard() {
if (!state.learnSession) return;
state.learnSession.flipped = !state.learnSession.flipped;
rerenderLearnSession();
}
export function rateLearnCard(rating) {
const s = state.learnSession;
if (!s) return;
if (!s.rated) s.rated = [];
const oldRating = s.rated[s.index];
if (oldRating || !['know', 'fuzzy', 'unknown'].includes(rating)) return;
const w = s.cards[s.index];
const isCorrect = rating === 'know';
if (!recordAndUpdateSchedule(w.id, isCorrect, 'review', rating)) return;
s.results[rating]++;
s.rated[s.index] = rating;
s.index++;
s.flipped = false;
rerenderLearnSession();
if (s.index < s.cards.length) {
speak(s.cards[s.index].english);
}
}
export function renderLearnComplete(el) {
const s = state.learnSession;
const total = s.cards.length;
el.innerHTML = `
<div class="lv2-complete">
<div class="lv2-complete-icon"><i class="fas fa-trophy"></i></div>
<h2 class="lv2-complete-title">复习完成!</h2>
<p class="lv2-complete-desc">本组共 ${total} 个单词</p>
<div class="lv2-complete-stats">
<div class="lv2-cs-item">
<div class="lv2-cs-val" style="color:var(--success)">${s.results.know}</div>
<div class="lv2-cs-lbl">认识</div>
</div>
<div class="lv2-cs-item">
<div class="lv2-cs-val" style="color:var(--warning)">${s.results.fuzzy}</div>
<div class="lv2-cs-lbl">模糊</div>
</div>
<div class="lv2-cs-item">
<div class="lv2-cs-val" style="color:var(--error)">${s.results.unknown}</div>
<div class="lv2-cs-lbl">不认识</div>
</div>
</div>
<div class="lv2-complete-actions">
<button class="lv2-nav-btn" data-action="learn.startLearnAgain">再复习一组</button>
<button class="lv2-nav-btn lv2-nav-center" data-action="learn.endLearnSession">返回</button>
</div>
</div>
`;
}
export function startLearnAgain() {
state.learnSession = null;
startReviewSession();
}
export function endLearnSession() {
state.learnSession = null;
renderPage(state.currentPage);
}
export function prevLearnCard() {
if (!state.learnSession || state.learnSession.index <= 0) return;
state.learnSession.index--;
state.learnSession.flipped = false;
rerenderLearnSession();
const w = state.learnSession.cards[state.learnSession.index];
if (w) speak(w.english);
}
export function nextLearnCardDirect() {
const s = state.learnSession;
if (!s || !s.rated?.[s.index]) return;
s.index++;
s.flipped = false;
rerenderLearnSession();
const w = s.cards[s.index];
if (w) speak(w.english);
}
export function toggleLearnAutoDisplay() {
if (!state.learnSession) return false;
const newVal = state.learnSession.autoDisplay === false;
if (!saveJsonSetting(STORAGE_KEYS.learnAutoDisplay, newVal)) return false;
state.learnSession.autoDisplay = newVal;
state.learnAutoDisplay = newVal;
state.learnSession.flipped = false;
rerenderLearnSession();
return true;
}
export function bindLearnKeyboard() {
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
closeModal();
closeSidebar();
return;
}
if (e.target instanceof HTMLElement && (e.target.isContentEditable || /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(e.target.tagName))) return;
const s = state.learnSession;
if (!s?.active || s.index >= s.cards.length || document.querySelector('.modal-overlay') || state.currentPage !== 'review') return;
const revealed = s.autoDisplay !== false || s.flipped;
if (e.key === 'ArrowLeft') {
e.preventDefault();
prevLearnCard();
} else if (e.key === 'ArrowRight') {
e.preventDefault();
nextLearnCardDirect();
} else if (e.key === ' ') {
e.preventDefault();
if (s.autoDisplay === false && !s.flipped) flipLearnCard();
else nextLearnCardDirect();
} else if (e.key === '1' && revealed) {
rateLearnCard('know');
} else if (e.key === '2' && revealed) {
rateLearnCard('fuzzy');
} else if (e.key === '3' && revealed) {
rateLearnCard('unknown');
}
});
}
+192
View File
@@ -0,0 +1,192 @@
import { state } from '../core/state.js';
import { saveEmails } from '../core/storage.js';
import { renderPage } from '../core/router.js';
import { showToast } from '../ui/toast.js';
import { speak } from '../services/tts.js';
import { callAI } from '../services/ai.js';
import { autoResizeTextarea, escapeHtml, formatMarkdown } from '../ui/dom.js';
let mailLearnRequestSeq = 0;
export function beginMailLearnRequest(resultEl) {
return { seq: ++mailLearnRequestSeq, resultEl };
}
export function isCurrentMailLearnRequest(request) {
return request.seq === mailLearnRequestSeq
&& request.resultEl.isConnected
&& document.getElementById('mail-learn-result') === request.resultEl;
}
// ==================== Page: Mail Learn (邮件学习) ====================
export function renderMailLearn(el) {
el.innerHTML = `
<div class="page-header">
<div>
<h1>邮件学习</h1>
<p class="page-desc">学习完单词后,进行整封邮件的学习,借助 AI 分析邮件内容</p>
</div>
</div>
<div class="card" style="margin-bottom:24px">
<h3><i class="fas fa-paste"></i> 粘贴邮件或选择已保存邮件</h3>
<textarea id="mail-learn-input" placeholder="粘贴英文邮件内容,AI 将帮你分析邮件中的重点词汇、句型和语法..." style="min-height:160px"></textarea>
<div style="display:flex;gap:8px;margin-top:16px;flex-wrap:wrap">
<button class="btn btn-primary" data-action="mailLearn.startFullReading"><i class="fas fa-book-reader"></i> 全文阅读</button>
<button class="btn btn-secondary" data-action="mailLearn.analyzeEmail"><i class="fas fa-magic"></i> AI 分析</button>
<button class="btn btn-secondary" data-action="mailLearn.translateFullEmail"><i class="fas fa-language"></i> 全文翻译</button>
<button class="btn btn-secondary" data-action="mailLearn.highlightWordsInMail"><i class="fas fa-highlighter"></i> 高亮词库单词</button>
</div>
</div>
${state.mailEmails.length > 0 ? `
<div class="card" style="margin-bottom:24px">
<h3><i class="fas fa-history"></i> 已保存的邮件 (${state.mailEmails.length})</h3>
<div style="display:flex;flex-direction:column;gap:8px">
${state.mailEmails.map((m, i) => `
<div style="display:flex;align-items:center;gap:12px;padding:12px 16px;background:var(--bg);border-radius:var(--radius-sm);border:1px solid var(--border);cursor:pointer;transition:all 0.2s" data-action="mailLearn.loadSavedEmail" data-arg0="${i}" class="saved-email-item">
<i class="fas fa-envelope" style="color:var(--primary)"></i>
<div style="flex:1;min-width:0">
<div style="font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml(m.title)}</div>
<div style="font-size:12px;color:var(--text-muted)">${escapeHtml(m.date)}</div>
</div>
<button class="btn btn-sm btn-ghost" data-action="mailLearn.deleteSavedEmail" data-arg0="${i}" title="删除"><i class="fas fa-trash-alt" style="color:var(--error)"></i></button>
</div>`).join('')}
</div>
</div>` : ''}
<div id="mail-learn-result"></div>
`;
const ta = document.getElementById('mail-learn-input');
ta.addEventListener('input', () => autoResizeTextarea(ta));
ta.addEventListener('paste', () => setTimeout(() => autoResizeTextarea(ta), 0));
}
export function loadSavedEmail(idx) {
const email = state.mailEmails[idx];
if (email) {
document.getElementById('mail-learn-input').value = email.content;
autoResizeTextarea(document.getElementById('mail-learn-input'));
}
}
export function deleteSavedEmail(idx) {
if (!confirm('确定删除这封保存的邮件?')) return false;
if (!Number.isInteger(idx) || idx < 0 || idx >= state.mailEmails.length) return false;
const [removedEmail] = state.mailEmails.splice(idx, 1);
if (!saveEmails()) {
state.mailEmails.splice(idx, 0, removedEmail);
return false;
}
renderPage('mail-learn');
return true;
}
export async function analyzeEmail() {
const text = document.getElementById('mail-learn-input').value.trim();
if (!text) { showToast('请粘贴邮件内容', 'error'); return; }
const resultEl = document.getElementById('mail-learn-result');
const request = beginMailLearnRequest(resultEl);
resultEl.innerHTML = '<div class="loading"><div class="spinner"></div> AI 正在分析邮件...</div>';
try {
const knownWords = state.words.map(w => w.english).join(', ');
const prompt = `你是英语邮件学习助手。请分析以下英文邮件,帮助学生学习:
邮件内容:
"""
${text}
"""
${knownWords ? `学生已学过的单词:${knownWords}` : ''}
请从以下方面进行分析(用中文回答):
1. **邮件主题概述**:简要说明邮件的主要内容和目的(2-3句话)
2. **重点词汇**:列出邮件中 5-8 个重要/高级的词汇,包含音标、词性、中文释义和在邮件中的含义
3. **关键句型**:找出 3-5 个值得学习的句型/表达方式,给出中文翻译和用法说明
4. **语法要点**:指出邮件中涉及的 2-3 个语法点
5. **商务/邮件礼仪**:如果是商务邮件,说明邮件中的礼貌用语和写作技巧
6. **全文翻译**:给出邮件的完整中文翻译`;
const content = await callAI([
{ role: 'system', content: '你是专业的英语邮件教学助手,分析详细且易于理解。' },
{ role: 'user', content: prompt }
]);
if (!isCurrentMailLearnRequest(request)) return;
resultEl.innerHTML = `
<div class="card">
<h3><i class="fas fa-brain"></i> AI 分析结果</h3>
<div class="ai-analysis-content">${formatMarkdown(content)}</div>
</div>
`;
} catch (err) {
if (!isCurrentMailLearnRequest(request)) return;
resultEl.innerHTML = `<div class="card"><div style="color:var(--error);padding:20px"><i class="fas fa-exclamation-circle"></i> 分析失败:${escapeHtml(err.message)}</div></div>`;
}
}
export async function translateFullEmail() {
const text = document.getElementById('mail-learn-input').value.trim();
if (!text) { showToast('请粘贴邮件内容', 'error'); return; }
const resultEl = document.getElementById('mail-learn-result');
const request = beginMailLearnRequest(resultEl);
resultEl.innerHTML = '<div class="loading"><div class="spinner"></div> AI 正在翻译...</div>';
try {
const content = await callAI([
{ role: 'system', content: '你是翻译助手,翻译准确自然。' },
{ role: 'user', content: `请将以下英文邮件翻译为中文,保持格式:\n\n${text}` }
]);
if (!isCurrentMailLearnRequest(request)) return;
resultEl.innerHTML = `
<div class="mail-learn-layout">
<div class="card"><h3><i class="fas fa-file-alt"></i> 原文</h3><div class="mail-original" style="white-space:pre-wrap">${escapeHtml(text)}</div></div>
<div class="card"><h3><i class="fas fa-language"></i> 翻译</h3><div class="ai-analysis-content" style="white-space:pre-wrap">${escapeHtml(content)}</div></div>
</div>
`;
} catch (err) {
if (!isCurrentMailLearnRequest(request)) return;
resultEl.innerHTML = `<div class="card"><div style="color:var(--error);padding:20px"><i class="fas fa-exclamation-circle"></i> 翻译失败:${escapeHtml(err.message)}</div></div>`;
}
}
export function highlightWordsInMail() {
const text = document.getElementById('mail-learn-input').value.trim();
if (!text) { showToast('请粘贴邮件内容', 'error'); return; }
const knownSet = new Set(state.words.map(w => w.english.toLowerCase()));
const words = text.split(/(\s+|[,.;:!?'"()\[\]{}<>\/\\—–-])/);
let highlighted = '';
let matchCount = 0;
words.forEach(w => {
const clean = w.toLowerCase().replace(/[^a-z]/g, '');
if (knownSet.has(clean) && clean.length >= 2) {
highlighted += `<span class="highlight-word" data-action="mailLearn.speak" data-arg0="${escapeHtml(clean)}" title="点击发音">${escapeHtml(w)}</span>`;
matchCount++;
} else {
highlighted += escapeHtml(w);
}
});
const resultEl = document.getElementById('mail-learn-result');
resultEl.innerHTML = `
<div class="card">
<h3><i class="fas fa-highlighter"></i> 高亮结果 <span class="badge badge-primary">${matchCount} 个词库单词</span></h3>
<div class="mail-original" style="white-space:pre-wrap">${highlighted}</div>
<p style="font-size:12px;color:var(--text-muted);margin-top:12px">紫色高亮的单词来自你的词库,点击可以发音</p>
</div>
`;
}
+64
View File
@@ -0,0 +1,64 @@
import { state } from '../core/state.js';
import {
QUIZ_AUDIO_PREFETCH_NEXT_THRESHOLD,
QUIZ_AUDIO_PREFETCH_WINDOW_SIZE,
logTts,
prefetchAudio
} from '../services/tts.js';
export function getQuizAudioTexts(questions) {
if (!Array.isArray(questions)) return [];
return questions.map(getQuizAudioText).filter(Boolean);
}
export function getQuizAudioText(question) {
if (!question) return '';
if (question.displayWord) return question.displayWord;
if (question.word?.english) return question.word.english;
if (question.wordId) {
const word = state.words.find(w => w.id === question.wordId);
if (word?.english) return word.english;
}
return question.question || '';
}
export function createQuizAudioPrefetchState(questions) {
const texts = getQuizAudioTexts(questions);
return {
texts,
nextStart: 0,
prefetchingStarts: new Set()
};
}
export function prefetchQuizAudioWindow(session, startIndex = 0) {
if (!session || session.mode !== 'en2zh' || !session.audioPrefetch) return;
const prefetch = session.audioPrefetch;
const start = Math.max(0, startIndex);
if (start >= prefetch.texts.length || prefetch.prefetchingStarts.has(start)) return;
const end = Math.min(start + QUIZ_AUDIO_PREFETCH_WINDOW_SIZE, prefetch.texts.length);
const windowTexts = prefetch.texts.slice(start, end);
prefetch.prefetchingStarts.add(start);
void prefetchAudio(windowTexts, 0.92, { allowCloud: true })
.then(() => {
prefetch.nextStart = Math.max(prefetch.nextStart, end);
})
.catch(err => {
logTts('quiz audio prefetch failed', err);
prefetch.nextStart = Math.max(prefetch.nextStart, end);
})
.finally(() => {
prefetch.prefetchingStarts.delete(start);
});
}
export function maybePrefetchNextQuizAudioWindow(session) {
if (!session || session.mode !== 'en2zh' || !session.audioPrefetch) return;
const prefetch = session.audioPrefetch;
if (prefetch.nextStart >= prefetch.texts.length) return;
if (prefetch.nextStart - session.index <= QUIZ_AUDIO_PREFETCH_NEXT_THRESHOLD) {
prefetchQuizAudioWindow(session, prefetch.nextStart);
}
}
+259
View File
@@ -0,0 +1,259 @@
import { LETTERS } from '../constants.js';
import { state } from '../core/state.js';
import { renderPage } from '../core/router.js';
import { isFavorite, toggleFavorite } from '../services/favorites.js';
import { recordAndUpdateSchedule } from '../services/ebbinghaus.js';
import { speak } from '../services/tts.js';
import { escapeHtml } from '../ui/dom.js';
import { getQuizAudioText, maybePrefetchNextQuizAudioWindow } from './quiz-audio.js';
export function renderQuizQuestion(el) {
const s = state.quizSession;
const q = s.questions[s.index];
const answered = s.answers[s.index] !== undefined;
const word = q.word || state.words.find(w => w.id === q.wordId);
const catMap = { high_frequency: 'HIGH FREQUENCY', medium_frequency: 'MEDIUM FREQUENCY', low_frequency: 'LOW FREQUENCY' };
const catText = word ? (catMap[word.category] || (word.category || '').toUpperCase().replace(/_/g, ' ')) : '';
const catClass = word ? (word.category === 'high_frequency' ? 'high' : word.category === 'medium_frequency' ? 'mid' : 'low') : '';
let formsHtml = '';
if (word && word.forms) {
formsHtml = Object.entries(word.forms).map(([key, val]) => {
const v = Array.isArray(val) ? val.join(', ') : val;
return `<div class="lv2-gcard-form-item"><span class="lv2-gcard-form-key">${escapeHtml(key)}:</span> ${escapeHtml(v)}</div>`;
}).join('');
}
const wordId = q.wordId || (word ? word.id : null);
const faved = wordId ? isFavorite(wordId) : false;
const safeQuizAudioText = escapeHtml(getQuizAudioText(q));
const safeWord = escapeHtml(word?.english || '');
el.innerHTML = `
<div class="page-header quiz-session-header">
<div class="quiz-session-meta">
<h1>AI 测试</h1>
<span class="badge badge-primary">${escapeHtml(s.sourceLabel)}</span>
<span class="badge" style="background:var(--bg);border:1px solid var(--border);color:var(--text-secondary)">${escapeHtml(s.modeLabel)}</span>
${s.isAI
? '<span class="badge" style="background:var(--success);color:#fff"><i class="fas fa-robot" style="margin-right:4px"></i>AI 生成</span>'
: '<span class="badge" style="background:var(--bg);border:1px solid var(--border);color:var(--text-muted)"><i class="fas fa-database" style="margin-right:4px"></i>本地题库</span>'}
</div>
<button class="btn btn-ghost quiz-end-btn" type="button" data-action="quiz.endQuiz" aria-label="结束测试" title="结束测试"><i class="fas fa-flag-checkered" aria-hidden="true"></i><span>结束测试</span></button>
</div>
<div class="quiz-area">
<div class="quiz-header">
<div class="quiz-counter">第 ${s.index + 1} / ${s.questions.length} 题</div>
<div class="learn-progress" style="flex:1;margin:0 20px">
<div class="progress-bar"><div class="progress-fill" style="width:${(s.index / s.questions.length * 100)}%"></div></div>
</div>
<div class="quiz-counter" style="color:var(--success)"><i class="fas fa-check-circle"></i> ${s.correct}</div>
${wordId ? `<button class="btn btn-ghost" id="quiz-fav-btn" data-action="quiz.toggleQuizFavorite" data-arg0="${wordId}" title="${faved ? '取消收藏' : '收藏'}" style="margin-left:4px;color:${faved ? 'var(--warning)' : 'var(--text-muted)'}"><i class="fas fa-star"></i></button>` : ''}
</div>
<div class="quiz-layout">
<div class="quiz-left">
<div class="quiz-question-card">
<div class="quiz-prompt">${q.displayWord && q.displayWord !== q.question ? escapeHtml(q.displayWord) : escapeHtml(q.question)}</div>
${q.phonetic ? `<div class="quiz-prompt-hint">${escapeHtml(q.phonetic)} <button class="btn-ghost btn-sm" data-action="quiz.speak" data-arg0="${safeQuizAudioText}" style="border:none;background:none;cursor:pointer"><i class="fas fa-volume-up"></i></button></div>` : ''}
</div>
<div class="quiz-options">
${q.options.map((opt, i) => {
let cls = '';
if (answered) {
cls = 'disabled';
if (i === q.answer) cls += ' correct';
else if (i === s.answers[s.index] && i !== q.answer) cls += ' wrong';
}
return `
<button class="quiz-option ${cls}" data-action="quiz.answerQuiz" data-arg0="${i}">
<span class="option-letter">${LETTERS[i]}</span>
<span>${escapeHtml(opt)}</span>
</button>`;
}).join('')}
</div>
${answered ? `
<div class="quiz-nav-row">
<button class="btn btn-secondary" data-action="quiz.prevQuizQuestion" ${s.index === 0 ? 'disabled' : ''}><i class="fas fa-arrow-left"></i> 上一题</button>
<button class="btn btn-primary" data-action="quiz.nextQuizQuestion">
${s.index < s.questions.length - 1 ? '下一题 <i class="fas fa-arrow-right"></i>' : '<i class="fas fa-chart-bar"></i> 查看结果'}
</button>
</div>` : ''}
</div>
<div class="quiz-right">
<div class="quiz-word-panel ${answered ? 'revealed' : 'locked'}">
${!answered ? `<div class="quiz-word-cover"><i class="fas fa-lock"></i><span>回答后显示</span></div>` : ''}
${word ? `
<div class="quiz-word-detail">
<div class="lv2-gcard-inner">
<div class="lv2-gcard-word">${escapeHtml(word.english)}</div>
<div class="lv2-gcard-prow">
<span class="lv2-gcard-ph">${escapeHtml(word.phonetic || '')}</span>
${word.frequency != null ? `<span class="lv2-gcard-freq">频次: ${word.frequency}</span>` : ''}
</div>
${catText ? `<span class="lv2-gcard-cat lv2-gcard-cat-${catClass}">${escapeHtml(catText)}</span>` : ''}
<div class="lv2-gcard-divider"></div>
<div class="lv2-gcard-zh">${escapeHtml(word.chinese)}</div>
${formsHtml ? `<div class="lv2-gcard-forms">${formsHtml}</div>` : ''}
<div class="lv2-gcard-speak-row">
<button class="lv2-gcard-speak" data-action="quiz.speak" data-arg0="${safeWord}" title="正常语速"><i class="fas fa-volume-up"></i></button>
<button class="lv2-gcard-speak lv2-gcard-speak-slow" data-action="quiz.speakSlow" data-arg0="${safeWord}" title="慢速"><i class="fas fa-walking"></i></button>
<button class="lv2-gcard-speak" id="quiz-detail-fav-btn" data-action="quiz.toggleQuizFavorite" data-arg0="${word.id}" title="${isFavorite(word.id) ? '取消收藏' : '收藏'}" style="color:${isFavorite(word.id) ? 'var(--warning)' : 'var(--text-muted)'}"><i class="fas fa-star"></i></button>
</div>
${word.example ? `
<div class="lv2-gcard-ex">
<div class="lv2-gcard-ex-en">${escapeHtml(word.example.en)}</div>
<div class="lv2-gcard-ex-cn">${escapeHtml(word.example.cn)}</div>
</div>` : ''}
</div>
</div>` : ''}
</div>
</div>
</div>
</div>
`;
}
export function toggleQuizFavorite(wordId) {
if (!toggleFavorite(wordId)) return false;
const faved = isFavorite(wordId);
const headerBtn = document.getElementById('quiz-fav-btn');
if (headerBtn) {
headerBtn.style.color = faved ? 'var(--warning)' : 'var(--text-muted)';
headerBtn.title = faved ? '取消收藏' : '收藏';
}
const detailBtn = document.getElementById('quiz-detail-fav-btn');
if (detailBtn) {
detailBtn.style.color = faved ? 'var(--warning)' : 'var(--text-muted)';
detailBtn.title = faved ? '取消收藏' : '收藏';
}
}
export function answerQuiz(optionIndex) {
const s = state.quizSession;
if (!s || s.answers[s.index] !== undefined) return;
const q = s.questions[s.index];
const isCorrect = optionIndex === q.answer;
if (q.wordId && !recordAndUpdateSchedule(q.wordId, isCorrect, 'quiz', s.mode, {
removeIncorrectQuizRecords: s.removeCorrectErrors
})) return;
s.answers[s.index] = optionIndex;
if (isCorrect) s.correct++;
renderPage('quiz');
}
export function nextQuizQuestion() {
const s = state.quizSession;
s.index++;
if (s.index >= s.questions.length) {
s.finished = true;
}
renderPage('quiz');
maybePrefetchNextQuizAudioWindow(s);
if (!s.finished && s.mode === 'en2zh') {
const q = s.questions[s.index];
speak(getQuizAudioText(q));
}
}
export function prevQuizQuestion() {
const s = state.quizSession;
if (!s || s.index <= 0) return;
s.index--;
renderPage('quiz');
maybePrefetchNextQuizAudioWindow(s);
if (s.mode === 'en2zh') {
const q = s.questions[s.index];
speak(getQuizAudioText(q));
}
}
export function renderQuizResults(el) {
const s = state.quizSession;
const total = s.questions.length;
const pct = Math.round(s.correct / total * 100);
const elapsed = Math.round((Date.now() - s.startTime) / 1000);
const mins = Math.floor(elapsed / 60);
const secs = elapsed % 60;
const circumference = 2 * Math.PI * 70;
const dashOffset = circumference * (1 - pct / 100);
const ringColor = pct >= 80 ? 'var(--success)' : pct >= 60 ? 'var(--warning)' : 'var(--error)';
el.innerHTML = `
<div class="page-header">
<div>
<h1>测试结果</h1>
<p class="page-desc"> ${total} 用时 ${mins}:${secs.toString().padStart(2, '0')} ${s.isAI ? '· <i class="fas fa-robot"></i> AI 生成' : '· <i class="fas fa-database"></i> 本地题库'}</p>
</div>
</div>
<div class="quiz-results">
<div class="card">
<div class="quiz-ring-chart">
<svg viewBox="0 0 160 160">
<circle class="ring-bg" cx="80" cy="80" r="70" />
<circle class="ring-fill" cx="80" cy="80" r="70" stroke="${ringColor}" stroke-dasharray="${circumference}" stroke-dashoffset="${circumference}" data-target-offset="${dashOffset}" />
</svg>
<div class="ring-text">
<span class="ring-value" style="color:${ringColor}">${pct}%</span>
<span class="ring-label">正确率</span>
</div>
</div>
<h2 style="font-size:24px;font-weight:700;margin-bottom:24px">
${pct >= 80 ? '<i class="fas fa-trophy" style="color:var(--warning);margin-right:6px"></i> 优秀!'
: pct >= 60 ? '<i class="fas fa-thumbs-up" style="color:var(--warning);margin-right:6px"></i> 不错!'
: '<i class="fas fa-redo" style="color:var(--error);margin-right:6px"></i> 继续努力!'}
</h2>
<div class="quiz-result-stats">
<div class="quiz-result-stat">
<div class="value" style="color:var(--success)">${s.correct}</div>
<div class="label">正确</div>
</div>
<div class="quiz-result-stat">
<div class="value" style="color:var(--error)">${total - s.correct}</div>
<div class="label">错误</div>
</div>
<div class="quiz-result-stat">
<div class="value">${mins}:${secs.toString().padStart(2, '0')}</div>
<div class="label">用时</div>
</div>
</div>
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap">
<button class="btn btn-primary" data-action="quiz.restartQuiz"><i class="fas fa-redo"></i> </button>
<button class="btn btn-secondary" data-action="quiz.navigate" data-arg0="home"><i class="fas fa-home"></i> </button>
</div>
</div>
</div>
`;
requestAnimationFrame(() => {
const ring = el.querySelector('.ring-fill');
if (ring) ring.setAttribute('stroke-dashoffset', ring.dataset.targetOffset);
});
}
export function selectQuizMode(btn, mode) {
document.querySelectorAll('.mode-toggle-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.getElementById('quiz-mode-select').value = mode;
state.quizMode = mode;
}
export function endQuiz() {
state.quizSession = null;
renderPage('quiz');
}
export function restartQuiz() {
state.quizSession = null;
renderPage('quiz');
}
+373
View File
@@ -0,0 +1,373 @@
import { state } from '../core/state.js';
import { getFavoriteWords } from '../services/favorites.js';
import { renderPage } from '../core/router.js';
import { showToast } from '../ui/toast.js';
import { speak } from '../services/tts.js';
import { generateAIQuiz, getAiCooldownRemaining } from '../services/ai.js';
import { getQuizErrorWords } from '../services/stats.js';
import { generateLocalQuiz, shuffle } from '../services/quiz-generator.js';
import {
createQuizAudioPrefetchState,
getQuizAudioText,
prefetchQuizAudioWindow
} from './quiz-audio.js';
import { renderQuizQuestion, renderQuizResults } from './quiz-session.js';
export {
createQuizAudioPrefetchState,
getQuizAudioText,
getQuizAudioTexts,
maybePrefetchNextQuizAudioWindow,
prefetchQuizAudioWindow
} from './quiz-audio.js';
export {
answerQuiz,
endQuiz,
nextQuizQuestion,
prevQuizQuestion,
renderQuizQuestion,
renderQuizResults,
restartQuiz,
selectQuizMode,
toggleQuizFavorite
} from './quiz-session.js';
let quizGeneration = 0;
function isCurrentQuizGeneration(generation, libraryId) {
return generation === quizGeneration
&& state.currentPage === 'quiz'
&& state.activeWordLibrary === libraryId;
}
// ==================== Batch Helpers ====================
export function getWordBatches(batchSize) {
const batches = [];
for (let i = 0; i < state.words.length; i += batchSize) {
const end = Math.min(i + batchSize, state.words.length);
batches.push({
index: batches.length,
start: i,
end,
label: `${batches.length + 1}`,
range: `${i + 1}-${end}`,
words: state.words.slice(i, end)
});
}
return batches;
}
export function getBatchWords(batchSize, batchIndex) {
if (batchIndex < 0) return [...state.words];
const start = batchIndex * batchSize;
return state.words.slice(start, start + batchSize);
}
export function onQuizBatchSizeChange(size) {
state.quizBatchSize = size;
state.quizBatchIndex = -1;
rerenderQuizSetup();
}
export function selectQuizBatch(index) {
state.quizBatchIndex = index;
rerenderQuizSetup();
}
export function rerenderQuizSetup() {
const el = document.getElementById('page-content');
if (el && state.currentPage === 'quiz' && !(state.quizSession && state.quizSession.active)) {
renderQuiz(el);
} else {
renderPage('quiz');
}
}
// ==================== Page: Quiz ====================
export function renderQuiz(el) {
quizGeneration++;
if (state.words.length < 4) {
el.innerHTML = `
<div class="page-header">
<div>
<h1>AI 测试</h1>
<p class="page-desc">通过选择题测试你对单词的掌握程度</p>
</div>
</div>
<div class="empty-state">
<div class="empty-icon"><i class="fas fa-brain"></i></div>
<h3>单词不足</h3>
<p>至少需要 4 个单词才能开始测试</p>
<button class="btn btn-primary" data-action="quiz.navigate" data-arg0="words">去导入</button>
</div>`;
return;
}
if (state.quizSession && state.quizSession.active) {
if (state.quizSession.finished) {
renderQuizResults(el);
} else {
renderQuizQuestion(el);
}
return;
}
const hasAI = state.settings.apiKey && state.settings.apiUrl && state.settings.model;
const batchWords = getBatchWords(state.quizBatchSize, state.quizBatchIndex);
el.innerHTML = `
<div class="page-header">
<div>
<h1>AI 测试</h1>
<p class="page-desc">通过选择题测试你对单词的掌握程度</p>
</div>
</div>
<div class="quiz-setup-layout">
<div class="card quiz-setup-left">
<h3><i class="fas fa-sliders-h"></i> 测试设置</h3>
<div class="form-group">
<label>出题模式</label>
<div class="mode-toggle">
<button type="button" class="mode-toggle-btn ${state.quizMode === 'en2zh' ? 'active' : ''}" data-mode="en2zh" data-action="quiz.selectQuizMode" data-element-arg="0" data-arg1="en2zh">
<div class="mode-toggle-label">ENG → 中文</div>
<div class="mode-toggle-hint">看词选意</div>
</button>
<button type="button" class="mode-toggle-btn ${state.quizMode === 'zh2en' ? 'active' : ''}" data-mode="zh2en" data-action="quiz.selectQuizMode" data-element-arg="0" data-arg1="zh2en">
<div class="mode-toggle-label">中文 → ENG</div>
<div class="mode-toggle-hint">看意选词</div>
</button>
</div>
<input type="hidden" id="quiz-mode-select" value="${state.quizMode}">
</div>
<div class="form-group">
<label>题量</label>
<select id="quiz-count-select">
<option value="5">5 题</option>
<option value="10">10 题</option>
<option value="20">20 题</option>
<option value="all" selected>整组全部</option>
</select>
</div>
<div class="form-group">
<label>出题顺序</label>
<div class="setup-radio-group">
<label class="setup-radio"><input type="radio" name="quiz-order" value="random" checked> 随机乱序</label>
<label class="setup-radio"><input type="radio" name="quiz-order" value="order"> 按顺序</label>
</div>
</div>
<div class="setup-checkbox">
<label>
<input type="checkbox" id="use-ai" ${hasAI ? 'checked' : ''} ${!hasAI ? 'disabled' : ''}>
使用 AI 生成题目 ${!hasAI ? '<span style="color:var(--text-muted);font-size:12px">(请先配置AI)</span>' : ''}
</label>
</div>
${state.quizBatchIndex === -2 ? `
<div class="setup-checkbox" style="margin-bottom:16px">
<label>
<input type="checkbox" id="quiz-remove-correct-errors" ${state.quizRemoveCorrectErrors ? 'checked' : ''}>
错题答对移除 <span style="color:var(--text-muted);font-size:12px">(答对后从错题中删除)</span>
</label>
</div>` : ''}
<div style="padding:16px;background:var(--bg);border-radius:var(--radius-sm);border:1px solid var(--border);margin-bottom:20px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">
<span style="font-size:13px;color:var(--text-secondary)">当前范围</span>
<span style="font-size:13px;font-weight:700;color:${state.quizBatchIndex === -2 ? 'var(--error)' : state.quizBatchIndex === -3 ? 'var(--warning)' : 'var(--primary)'}">${getQuizPoolCount()} 个单词</span>
</div>
<div style="font-size:12px;color:var(--text-muted)">${getQuizPoolLabel()}</div>
</div>
<button class="btn btn-primary btn-lg" style="width:100%" data-action="quiz.startQuiz"><i class="fas fa-play"></i> 开始生成试卷</button>
</div>
<div class="card quiz-setup-right">
<h3><i class="fas fa-th"></i> 出题范围</h3>
${renderQuizBatchSelector()}
</div>
</div>
`;
}
export function renderQuizBatchSelector() {
const errorCount = getQuizErrorWords().length;
const favCount = getFavoriteWords().length;
const batches = getWordBatches(state.quizBatchSize);
const bi = state.quizBatchIndex;
const isError = bi === -2, isFav = bi === -3, isGroup = bi >= -1;
return `
<div class="form-group">
<label>快捷范围</label>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button type="button" class="batch-group-btn ${isError ? 'active' : ''}" style="flex:1;min-width:0;display:flex;align-items:center;gap:6px;justify-content:center;padding:10px 8px;${errorCount > 0 ? '' : 'opacity:0.45;cursor:not-allowed'}" ${errorCount > 0 ? 'data-action="quiz.selectQuizBatch" data-arg0="-2"' : ''}>
<i class="fas fa-redo-alt" style="color:${isError ? 'var(--primary)' : 'var(--error)'}"></i>
<span>错题测试</span>
<span class="badge ${isError ? 'badge-primary' : 'badge-error'}" style="font-size:11px;padding:2px 8px">${errorCount}</span>
</button>
<button type="button" class="batch-group-btn ${isFav ? 'active' : ''}" style="flex:1;min-width:0;display:flex;align-items:center;gap:6px;justify-content:center;padding:10px 8px;${favCount > 0 ? '' : 'opacity:0.45;cursor:not-allowed'}" ${favCount > 0 ? 'data-action="quiz.selectQuizBatch" data-arg0="-3"' : ''}>
<i class="fas fa-star" style="color:${isFav ? 'var(--primary)' : 'var(--warning)'}"></i>
<span>收藏测试</span>
<span class="badge ${isFav ? 'badge-primary' : 'badge-warning'}" style="font-size:11px;padding:2px 8px">${favCount}</span>
</button>
</div>
</div>
<div class="form-group" style="margin-top:16px">
<label>分组测试</label>
<select data-action="quiz.onQuizBatchSizeChange" data-action-event="change" data-value-arg="0" data-value-number="true" style="margin-bottom:10px">
<option value="50" ${state.quizBatchSize === 50 ? 'selected' : ''}>50 个 / 组</option>
<option value="100" ${state.quizBatchSize === 100 ? 'selected' : ''}>100 个 / 组</option>
</select>
<div class="batch-group-grid">
<button type="button" class="batch-group-btn ${bi === -1 ? 'active' : ''}" data-action="quiz.selectQuizBatch" data-arg0="-1">
全部<br><small>${state.words.length}词</small>
</button>
${batches.map(b => `
<button type="button" class="batch-group-btn ${bi === b.index ? 'active' : ''}" data-action="quiz.selectQuizBatch" data-arg0="${b.index}">
${b.label}<br><small>${b.range}</small>
</button>
`).join('')}
</div>
</div>`;
}
export function getQuizPoolCount() {
if (state.quizBatchIndex === -2) return getQuizErrorWords().length;
if (state.quizBatchIndex === -3) return getFavoriteWords().length;
return getBatchWords(state.quizBatchSize, state.quizBatchIndex).length;
}
export function getQuizPoolLabel() {
if (state.quizBatchIndex === -2) return '<i class="fas fa-redo-alt" style="margin-right:4px"></i>错题测试';
if (state.quizBatchIndex === -3) return '<i class="fas fa-star" style="margin-right:4px;color:var(--warning)"></i>收藏测试';
if (state.quizBatchIndex === -1) return '全部单词';
return '第' + (state.quizBatchIndex + 1) + '组';
}
export async function startQuiz() {
const mode = document.getElementById('quiz-mode-select').value;
const countVal = document.getElementById('quiz-count-select').value;
const quizOrder = document.querySelector('input[name="quiz-order"]:checked')?.value || 'random';
const useAI = document.getElementById('use-ai').checked;
const isErrorMode = state.quizBatchIndex === -2;
const removeCorrectEl = document.getElementById('quiz-remove-correct-errors');
state.quizRemoveCorrectErrors = isErrorMode && removeCorrectEl ? removeCorrectEl.checked : false;
let batchWords;
if (isErrorMode) {
batchWords = getQuizErrorWords();
} else if (state.quizBatchIndex === -3) {
batchWords = getFavoriteWords();
} else {
batchWords = getBatchWords(state.quizBatchSize, state.quizBatchIndex);
}
if (quizOrder === 'order') {
batchWords = [...batchWords].sort((a, b) => a.id - b.id);
}
const count = countVal === 'all' ? batchWords.length : parseInt(countVal);
if (batchWords.length < 4) {
const msg = isErrorMode ? '错题不足4个,无法生成测试'
: state.quizBatchIndex === -3 ? '收藏不足4个,无法生成测试'
: '所选范围内单词不足4个,无法生成测试';
showToast(msg, 'warning');
return;
}
const generation = ++quizGeneration;
const libraryId = state.activeWordLibrary;
let questions;
let lastQuizAI = false;
const aiCooldownRemaining = useAI ? getAiCooldownRemaining() : 0;
const aiCooling = aiCooldownRemaining > 0;
if (aiCooling) {
const secs = Math.ceil(aiCooldownRemaining / 1000);
showToast(`API 冷却中(${secs}秒),本次使用本地题库`, 'info');
}
if (useAI && !aiCooling) {
const el = document.getElementById('page-content');
el.innerHTML = `
<div class="page-header"><h1>AI 测试</h1></div>
<div class="loading"><div class="spinner"></div><span>AI 正在出题,请稍候...</span></div>`;
try {
const pool = (quizOrder === 'order' ? batchWords : shuffle(batchWords)).slice(0, Math.min(count * 3, batchWords.length));
const aiQuestions = await generateAIQuiz(pool, mode, count);
if (!isCurrentQuizGeneration(generation, libraryId)) return;
lastQuizAI = true;
showToast('AI 出题成功', 'success');
questions = aiQuestions.map((q, i) => {
const tagged = q.options.map((opt, idx) => ({
text: opt,
correct: idx === q.answer
}));
const shuffled = shuffle(tagged);
const matchedWord = mode === 'en2zh'
? (pool.find(w => w.english === q.question)
|| pool.find(w => q.question.toLowerCase().includes(`'${w.english.toLowerCase()}'`))
|| pool.find(w => q.question.toLowerCase().includes(w.english.toLowerCase())))
: (pool.find(w => w.chinese === q.question)
|| pool.find(w => q.question.includes(w.chinese))
|| pool.find(w => q.options.includes(w.english) && q.question.includes(w.chinese)));
const cleanQuestion = mode === 'zh2en' && matchedWord ? matchedWord.chinese : q.question;
return {
...q,
question: cleanQuestion,
options: shuffled.map(t => t.text),
answer: shuffled.findIndex(t => t.correct),
wordId: matchedWord?.id || null,
word: matchedWord || null,
phonetic: mode === 'en2zh' ? (matchedWord?.phonetic || '') : null,
displayWord: mode === 'en2zh' ? (matchedWord?.english || null) : null
};
});
} catch (e) {
if (!isCurrentQuizGeneration(generation, libraryId)) return;
lastQuizAI = false;
showToast('AI 出题失败,已切换本地题库:' + e.message, 'error');
questions = generateLocalQuiz(mode, count, batchWords, quizOrder);
}
} else {
questions = generateLocalQuiz(mode, count, batchWords, quizOrder);
}
if (!isCurrentQuizGeneration(generation, libraryId)) return;
if (!questions || questions.length === 0) {
showToast('生成题目失败', 'error');
return;
}
const audioPrefetch = mode === 'en2zh' ? createQuizAudioPrefetchState(questions) : null;
const sourceLabel = isErrorMode ? '错题测试' : state.quizBatchIndex === -3 ? '收藏测试' : state.quizBatchIndex === -1 ? '全部' : '第' + (state.quizBatchIndex + 1) + '组';
const modeLabel = mode === 'en2zh' ? 'ENG → 中文' : '中文 → ENG';
state.quizSession = {
active: true,
finished: false,
mode,
questions,
index: 0,
answers: [],
correct: 0,
startTime: Date.now(),
isErrorMode: isErrorMode,
removeCorrectErrors: state.quizRemoveCorrectErrors,
sourceLabel,
modeLabel,
isAI: lastQuizAI,
audioPrefetch
};
renderPage('quiz');
prefetchQuizAudioWindow(state.quizSession, 0);
if (mode === 'en2zh' && questions.length > 0) {
const q = questions[0];
speak(getQuizAudioText(q));
}
}
+371
View File
@@ -0,0 +1,371 @@
import { STOP_WORDS } from '../data/stopwords.js';
import { state } from '../core/state.js';
import { saveSchedule, saveWords } from '../core/storage.js';
import { showToast } from '../ui/toast.js';
import { speak } from '../services/tts.js';
import { callAI, translateWord } from '../services/ai.js';
import { initWordSchedule } from '../services/ebbinghaus.js';
import { getMastery } from '../services/stats.js';
import { escapeHtml, formatFormsHtml } from '../ui/dom.js';
import { findWordContext } from '../services/extractor.js';
import { getNextId } from './words.js';
import { beginMailLearnRequest, isCurrentMailLearnRequest } from './mail-learn.js';
export let readerTranslations = {};
export let readerSentences = [];
let readerSessionGeneration = 0;
// ==================== Full-Text Reading Mode ====================
export function splitSentences(text) {
const raw = text.split(/(?<=[.!?])\s+|(?:\r?\n){2,}/);
return raw.map(s => s.trim()).filter(s => s.length > 0);
}
export function isStopWord(w) { return STOP_WORDS.has(w.toLowerCase()); }
export function buildReadingSentenceHtml(sentence) {
const knownMap = {};
state.words.forEach(w => { knownMap[w.english.toLowerCase()] = w; });
const tokens = sentence.split(/(\s+|[,.;:!?'"()\[\]{}<>\/\\—–\-])/);
let html = '';
tokens.forEach(tok => {
const clean = tok.toLowerCase().replace(/[^a-z'-]/g, '');
const word = knownMap[clean];
if (word && clean.length >= 2) {
html += `<span class="reader-word known" data-word-id="${word.id}" data-action="reader.showReaderWordTip" data-element-arg="0" data-arg1="${word.id}" title="${escapeHtml(word.chinese || '')}">${escapeHtml(tok)}</span>`;
} else if (/^[a-zA-Z]{2,}$/.test(clean) && !isStopWord(clean)) {
html += `<span class="reader-word unknown" data-action="reader.showReaderUnknownTip" data-element-arg="0" data-arg1="${escapeHtml(clean)}" title="点击查看释义">${escapeHtml(tok)}</span>`;
} else {
html += escapeHtml(tok);
}
});
return html;
}
export async function startFullReading() {
const sessionGeneration = ++readerSessionGeneration;
const text = document.getElementById('mail-learn-input').value.trim();
if (!text) { showToast('请粘贴邮件内容', 'error'); return; }
const sentences = splitSentences(text);
if (!sentences.length) { showToast('无法解析邮件内容', 'error'); return; }
const resultEl = document.getElementById('mail-learn-result');
const request = beginMailLearnRequest(resultEl);
resultEl.innerHTML = '<div class="loading"><div class="spinner"></div> 正在准备全文阅读...</div>';
let translations = {};
const hasAI = state.settings.apiKey && state.settings.apiUrl && state.settings.model;
if (hasAI) {
try {
const numbered = sentences.map((s, i) => `${i + 1}. ${s}`).join('\n');
const content = await callAI([
{ role: 'system', content: '你是翻译助手。用户给出编号的英文句子,请逐句翻译为中文,严格保持编号格式返回。只返回翻译结果,每行格式:编号. 中文翻译' },
{ role: 'user', content: numbered }
]);
if (sessionGeneration !== readerSessionGeneration || !isCurrentMailLearnRequest(request)) return;
content.split('\n').forEach(line => {
const m = line.match(/^(\d+)\.\s*(.+)/);
if (m) translations[Number(m[1]) - 1] = m[2].trim();
});
} catch (err) {
if (sessionGeneration !== readerSessionGeneration || !isCurrentMailLearnRequest(request)) return;
showToast('AI 翻译失败,阅读模式仍可使用:' + err.message, 'warning');
}
}
if (sessionGeneration !== readerSessionGeneration || !isCurrentMailLearnRequest(request) || !resultEl.isConnected) return;
const knownCount = countKnownWordsInText(text);
const totalUniqueWords = countUniqueWords(text);
const coveragePct = totalUniqueWords > 0 ? Math.round(knownCount / totalUniqueWords * 100) : 0;
resultEl.innerHTML = `
<div class="card reader-card">
<div class="reader-header">
<h3><i class="fas fa-book-reader"></i> 全文阅读</h3>
<div class="reader-stats">
<span class="badge badge-primary">${sentences.length} 句</span>
<span class="badge badge-success">词库覆盖 ${coveragePct}%</span>
<button class="btn btn-sm btn-ghost" data-action="reader.toggleAllTranslations" title="显示/隐藏全部翻译"><i class="fas fa-eye"></i> 全部翻译</button>
</div>
</div>
<div class="reader-legend">
<span><span class="reader-dot known-dot"></span> 词库中的单词(点击查看详情)</span>
<span><span class="reader-dot unknown-dot"></span> 生词(点击查询)</span>
<span><i class="fas fa-hand-pointer" style="font-size:12px"></i> 点击句子查看翻译</span>
</div>
<div class="reader-body" id="reader-body">
${sentences.map((s, i) => `
<div class="reader-sentence-block" data-idx="${i}">
<div class="reader-sentence-num">${i + 1}</div>
<div class="reader-sentence-content">
<div class="reader-sentence-en" data-action="reader.toggleSentenceTranslation" data-arg0="${i}">${buildReadingSentenceHtml(s)}</div>
<div class="reader-sentence-cn" id="reader-cn-${i}" style="display:none">
${translations[i] ? escapeHtml(translations[i]) : '<span class="text-muted">点击上方英文句子查看翻译</span>'}
</div>
<div class="reader-sentence-tools">
<button class="btn btn-sm btn-ghost" data-action="reader.speakSentence" data-arg0="${i}" title="朗读"><i class="fas fa-volume-up"></i></button>
<button class="btn btn-sm btn-ghost" data-action="reader.toggleSentenceTranslation" data-arg0="${i}" title="翻译"><i class="fas fa-language"></i></button>
${!translations[i] && hasAI ? `<button class="btn btn-sm btn-ghost" id="ai-translate-btn-${i}" data-action="reader.aiTranslateSentence" data-arg0="${i}" data-element-arg="1" title="AI翻译"><i class="fas fa-magic"></i></button>` : ''}
</div>
</div>
</div>`).join('')}
</div>
</div>
<div id="reader-word-tooltip" class="reader-tooltip" style="display:none"></div>
`;
readerTranslations = translations;
readerSentences = sentences;
}
export function countKnownWordsInText(text) {
const knownSet = new Set(state.words.map(w => w.english.toLowerCase()));
const tokens = text.match(/\b[a-zA-Z]{2,}\b/g) || [];
const unique = new Set(tokens.map(t => t.toLowerCase()));
let count = 0;
unique.forEach(w => { if (!STOP_WORDS.has(w) && knownSet.has(w)) count++; });
return count;
}
export function countUniqueWords(text) {
const tokens = text.match(/\b[a-zA-Z]{2,}\b/g) || [];
const unique = new Set(tokens.map(t => t.toLowerCase()));
let count = 0;
unique.forEach(w => { if (!STOP_WORDS.has(w)) count++; });
return count;
}
export function speakSentence(idx) {
const sentences = readerSentences;
if (sentences && sentences[idx]) speak(sentences[idx]);
}
export function toggleSentenceTranslation(idx) {
const el = document.getElementById(`reader-cn-${idx}`);
if (!el) return;
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
export function toggleAllTranslations() {
const els = document.querySelectorAll('[id^="reader-cn-"]');
const anyHidden = [...els].some(e => e.style.display === 'none');
els.forEach(e => { e.style.display = anyHidden ? 'block' : 'none'; });
}
export async function aiTranslateSentence(idx, btnEl) {
const sessionGeneration = readerSessionGeneration;
const sentence = readerSentences?.[idx];
if (!sentence) return;
const cnEl = document.getElementById(`reader-cn-${idx}`);
if (!cnEl) return;
if (btnEl) btnEl.disabled = true;
const isCurrentSentence = () => sessionGeneration === readerSessionGeneration
&& readerSentences?.[idx] === sentence
&& cnEl.isConnected
&& (!btnEl || btnEl.isConnected);
try {
const content = await callAI([
{ role: 'system', content: '你是翻译助手,只返回中文翻译结果,不要任何解释。' },
{ role: 'user', content: `翻译为中文:${sentence}` }
]);
if (!isCurrentSentence()) return;
const translation = content.trim();
cnEl.textContent = translation;
cnEl.style.display = 'block';
readerTranslations[idx] = translation;
if (btnEl) btnEl.remove();
} catch (err) {
if (!isCurrentSentence()) return;
showToast('翻译失败: ' + err.message, 'error');
if (btnEl) btnEl.disabled = false;
}
}
export function positionReaderTooltip(tip, el) {
const margin = 8;
const gap = 6;
const rect = el.getBoundingClientRect();
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const tipWidth = tip.offsetWidth;
const tipHeight = tip.offsetHeight;
const left = Math.min(
Math.max(rect.left, margin),
Math.max(margin, window.innerWidth - tipWidth - margin)
);
const top = rect.bottom + gap + tipHeight <= window.innerHeight
? rect.bottom + scrollTop + gap
: Math.max(scrollTop + margin, rect.top + scrollTop - tipHeight - gap);
tip.style.left = `${left}px`;
tip.style.top = `${top}px`;
}
let readerTipCloseHandler = null;
let readerTipCloseTimer = null;
let readerTipSeq = 0;
function bindReaderTipClose(tip, anchorEl) {
if (readerTipCloseTimer !== null) {
clearTimeout(readerTipCloseTimer);
readerTipCloseTimer = null;
}
if (readerTipCloseHandler) {
document.removeEventListener('click', readerTipCloseHandler);
readerTipCloseHandler = null;
}
const handler = (e) => {
if (!tip.contains(e.target) && e.target !== anchorEl) {
++readerTipSeq;
tip.style.display = 'none';
document.removeEventListener('click', handler);
if (readerTipCloseHandler === handler) readerTipCloseHandler = null;
}
};
readerTipCloseHandler = handler;
readerTipCloseTimer = setTimeout(() => {
readerTipCloseTimer = null;
if (readerTipCloseHandler === handler) {
document.addEventListener('click', handler);
}
}, 10);
}
export function showReaderWordTip(el, wordId) {
++readerTipSeq;
const word = state.words.find(w => w.id === wordId);
if (!word) return;
const tip = document.getElementById('reader-word-tooltip');
if (!tip) return;
const mastery = getMastery(word.id);
const masteryText = mastery === 'mastered' ? '已掌握' : mastery === 'learning' ? '学习中' : '新词';
const badgeClass = mastery === 'mastered' ? 'badge-success' : mastery === 'learning' ? 'badge-warning' : 'badge-primary';
tip.innerHTML = `
<div class="reader-tip-header">
<strong>${escapeHtml(word.english)}</strong>
<span style="color:var(--text-muted);font-size:12px">${escapeHtml(word.phonetic || '')}</span>
<span class="badge ${badgeClass}" style="font-size:11px;padding:2px 6px">${masteryText}</span>
<button class="btn btn-sm btn-ghost" data-action="reader.speak" data-arg0="${escapeHtml(word.english)}"><i class="fas fa-volume-up"></i></button>
</div>
<div class="reader-tip-cn">${escapeHtml(word.chinese || '')}</div>
${word.forms ? `<div class="reader-tip-forms">${formatFormsHtml(word.forms)}</div>` : ''}
${word.example ? `<div class="reader-tip-example"><em>${escapeHtml(word.example.en)}</em><br><span>${escapeHtml(word.example.cn)}</span></div>` : ''}
`;
tip.style.display = 'block';
positionReaderTooltip(tip, el);
bindReaderTipClose(tip, el);
}
export async function showReaderUnknownTip(el, word) {
const tip = document.getElementById('reader-word-tooltip');
if (!tip) return;
const seq = ++readerTipSeq;
tip.innerHTML = `
<div class="reader-tip-header">
<strong>${escapeHtml(word)}</strong>
<button class="btn btn-sm btn-ghost" data-action="reader.speak" data-arg0="${escapeHtml(word)}"><i class="fas fa-volume-up"></i></button>
</div>
<div class="reader-tip-cn" style="color:var(--text-muted)">查询中...</div>
`;
tip.style.display = 'block';
positionReaderTooltip(tip, el);
bindReaderTipClose(tip, el);
const hasAI = state.settings.apiKey && state.settings.apiUrl && state.settings.model;
if (hasAI) {
try {
const result = await translateWord(word);
if (seq !== readerTipSeq) return;
tip.innerHTML = `
<div class="reader-tip-header">
<strong>${escapeHtml(result.english || word)}</strong>
<span style="color:var(--text-muted);font-size:12px">${escapeHtml(result.phonetic || '')}</span>
<button class="btn btn-sm btn-ghost" data-action="reader.speak" data-arg0="${escapeHtml(word)}"><i class="fas fa-volume-up"></i></button>
</div>
<div class="reader-tip-cn">${escapeHtml(result.chinese || '')}</div>
${result.forms ? `<div class="reader-tip-forms">${formatFormsHtml(result.forms)}</div>` : ''}
${result.example ? `<div class="reader-tip-example"><em>${escapeHtml(result.example.en)}</em><br><span>${escapeHtml(result.example.cn)}</span></div>` : ''}
<button class="btn btn-sm btn-primary" style="margin-top:8px;width:100%" data-action="reader.addWordFromReader" data-arg0="${escapeHtml(word)}" data-element-arg="1">
<i class="fas fa-plus"></i> 加入词库
</button>
`;
positionReaderTooltip(tip, el);
} catch (err) {
if (seq !== readerTipSeq) return;
const cnEl = tip.querySelector('.reader-tip-cn');
if (cnEl) cnEl.textContent = '查询失败: ' + err.message;
}
} else {
const cnEl = tip.querySelector('.reader-tip-cn');
if (cnEl) cnEl.textContent = '请先配置 AI 设置以启用翻译';
}
}
export async function addWordFromReader(word, btnEl) {
const key = String(word || '').trim().toLowerCase();
const libraryId = state.activeWordLibrary;
const sessionGeneration = readerSessionGeneration;
const wordExists = () => state.words.some(item => String(item.english || '').trim().toLowerCase() === key);
const isCurrentOperation = () => state.activeWordLibrary === libraryId
&& readerSessionGeneration === sessionGeneration
&& (!btnEl || btnEl.isConnected);
if (!key) return false;
if (wordExists()) {
showToast(`${word}」已在词库中`, 'info');
if (btnEl) { btnEl.disabled = true; btnEl.textContent = '已添加 ✓'; }
return true;
}
if (btnEl) { btnEl.disabled = true; btnEl.textContent = '添加中...'; }
try {
const result = await translateWord(word);
if (!isCurrentOperation()) return false;
if (wordExists()) {
showToast(`${word}」已在词库中`, 'info');
if (btnEl) { btnEl.textContent = '已添加 ✓'; btnEl.classList.remove('btn-primary'); btnEl.classList.add('btn-success'); }
return true;
}
const emailText = document.getElementById('mail-learn-input')?.value || '';
const contextSentence = findWordContext(word, emailText);
const emailTitle = emailText.slice(0, 40).replace(/\n/g, ' ') + '...';
const previousWords = state.words.slice();
const previousSchedule = { ...state.schedule };
const id = getNextId();
state.words.push({
id,
...result,
sourceContext: contextSentence ? { emailTitle, sentence: contextSentence } : null
});
initWordSchedule(id, false);
if (!(saveWords() && saveSchedule())) {
state.words = previousWords;
state.schedule = previousSchedule;
saveWords();
saveSchedule();
throw new Error('本地存储写入失败');
}
showToast(`已添加「${word}」到词库`, 'success');
if (btnEl) { btnEl.textContent = '已添加 \u2713'; btnEl.classList.remove('btn-primary'); btnEl.classList.add('btn-success'); }
return true;
} catch (err) {
if (!isCurrentOperation()) return false;
showToast('添加失败: ' + err.message, 'error');
if (btnEl) { btnEl.disabled = false; btnEl.textContent = '重试'; }
return false;
}
}
+143
View File
@@ -0,0 +1,143 @@
import { state } from '../core/state.js';
import { navigate } from '../core/router.js';
import { showToast } from '../ui/toast.js';
import { getQuizErrorWords } from '../services/stats.js';
import { shuffle } from '../services/quiz-generator.js';
import { escapeHtml } from '../ui/dom.js';
import { renderLearnSession, startLearnSession } from './learn.js';
// ==================== Page: Review ====================
export function renderReview(el) {
if (state.words.length === 0) {
el.innerHTML = `
<div class="page-header">
<div>
<h1>错题复习</h1>
<p class="page-desc">针对测试中答错的单词进行专项复习</p>
</div>
</div>
<div class="empty-state">
<div class="empty-icon"><i class="fas fa-redo-alt"></i></div>
<h3>暂无单词</h3>
<p>请先导入单词</p>
<button class="btn btn-primary" data-action="review.navigate" data-arg0="words">去导入</button>
</div>`;
return;
}
if (state.learnSession && state.learnSession.active && state.learnSession.isReview) {
renderLearnSession(el);
return;
}
const errorWords = getQuizErrorWords();
const critical = errorWords.filter(w => w.correctRate < 60);
const moderate = errorWords.filter(w => w.correctRate >= 60 && w.correctRate < 80);
const easy = errorWords.filter(w => w.correctRate >= 80);
const hasQuizRecords = state.records.some(r => r.type === 'quiz');
el.innerHTML = `
<div class="page-header">
<div>
<h1>错题复习</h1>
<p class="page-desc">针对测试中答错的单词进行专项复习</p>
</div>
${errorWords.length > 0 ? `<button class="btn btn-primary" data-action="review.startReviewSession"><i class="fas fa-play"></i> 开始复习</button>` : ''}
</div>
${errorWords.length > 0 ? `
<div class="stat-grid" style="margin-bottom:24px">
<div class="stat-card" style="border-left:3px solid var(--primary)">
<div class="stat-icon" style="background:var(--primary-bg);color:var(--primary)"><i class="fas fa-list-ul"></i></div>
<div class="stat-info">
<div class="stat-value">${errorWords.length}</div>
<div class="stat-label">错题总数</div>
</div>
</div>
<div class="stat-card" style="border-left:3px solid var(--error)">
<div class="stat-icon" style="background:var(--error-light);color:var(--error)"><i class="fas fa-times-circle"></i></div>
<div class="stat-info">
<div class="stat-value">${critical.length}</div>
<div class="stat-label">需重点复习</div>
</div>
</div>
<div class="stat-card" style="border-left:3px solid var(--warning)">
<div class="stat-icon" style="background:var(--warning-light);color:var(--warning)"><i class="fas fa-exclamation-triangle"></i></div>
<div class="stat-info">
<div class="stat-value">${moderate.length}</div>
<div class="stat-label">需巩固</div>
</div>
</div>
<div class="stat-card" style="border-left:3px solid var(--success)">
<div class="stat-icon" style="background:var(--success-light);color:var(--success)"><i class="fas fa-check-circle"></i></div>
<div class="stat-info">
<div class="stat-value">${easy.length}</div>
<div class="stat-label">接近掌握</div>
</div>
</div>
</div>` : `
<div class="card" style="margin-bottom:24px">
${hasQuizRecords
? `<div class="empty-state" style="padding:32px 20px"><div class="empty-icon" style="color:var(--success)"><i class="fas fa-laugh-beam"></i></div><h3 style="color:var(--success)">太棒了,没有错题!</h3><p>继续保持,坚持练习</p></div>`
: `<div class="empty-state" style="padding:32px 20px"><div class="empty-icon"><i class="fas fa-robot"></i></div><h3>还没有测试记录</h3><p>请先进行 AI 测试,错题会自动收集到这里</p><button class="btn btn-primary" data-action="review.navigate" data-arg0="quiz" style="margin-top:4px"><i class="fas fa-robot"></i> 去测试</button></div>`
}
</div>`}
${errorWords.length > 0 ? `
<div class="card" style="margin-bottom:20px">
<h3><i class="fas fa-info-circle"></i> 复习说明</h3>
<p style="font-size:13px;color:var(--text-muted)">复习内容来自 AI 测试中答错的单词,按正确率从低到高排列。通过反复练习巩固薄弱环节。</p>
</div>
${critical.length > 0 ? `
<div class="card" style="margin-bottom:16px">
<h3><i class="fas fa-exclamation-circle" style="color:var(--error)"></i> 需重点复习(正确率 &lt; 60%</h3>
<div class="review-list">
${critical.map(w => renderReviewWordItem(w)).join('')}
</div>
</div>` : ''}
${moderate.length > 0 ? `
<div class="card" style="margin-bottom:16px">
<h3><i class="fas fa-exclamation-triangle" style="color:var(--warning)"></i> 需巩固(正确率 60-80%</h3>
<div class="review-list">
${moderate.map(w => renderReviewWordItem(w)).join('')}
</div>
</div>` : ''}
${easy.length > 0 ? `
<div class="card" style="margin-bottom:16px">
<h3><i class="fas fa-check-circle" style="color:var(--success)"></i> 接近掌握(正确率 ≥ 80%</h3>
<div class="review-list">
${easy.map(w => renderReviewWordItem(w)).join('')}
</div>
</div>` : ''}
` : ''}
`;
}
export function renderReviewWordItem(w) {
const stats = w.quizStats || {};
const total = (stats.correct || 0) + (stats.incorrect || 0);
const rate = w.correctRate != null ? w.correctRate : (total > 0 ? Math.round(stats.correct / total * 100) : '-');
const borderColor = rate < 60 ? 'var(--error)' : rate < 80 ? 'var(--warning)' : 'var(--success)';
return `
<div class="review-word-item" style="border-left-color:${borderColor}">
<div class="rw-en">${escapeHtml(w.english)}</div>
<div class="rw-zh">${escapeHtml(w.chinese)}</div>
<div class="rw-stage">正确 ${stats.correct || 0} 次 · 错误 ${stats.incorrect || 0} 次 · 正确率 ${rate}%</div>
</div>`;
}
export function startReviewSession() {
const errorWords = getQuizErrorWords();
if (errorWords.length === 0) {
showToast('没有需要复习的错题', 'info');
return;
}
startLearnSession(shuffle(errorWords));
}
+419
View File
@@ -0,0 +1,419 @@
import { EBBINGHAUS_INTERVALS, WORD_LIBRARIES } from '../constants.js';
import { state } from '../core/state.js';
import {
getLibraryStorageKey,
loadJsonSetting,
saveFavorites,
saveJsonSetting,
snapshotStorageItems,
restoreStorageItems,
STORAGE_KEYS
} from '../core/storage.js';
import { renderPage } from '../core/router.js';
import { closeModal, showModal } from '../ui/modal.js';
import { showToast } from '../ui/toast.js';
import { getToday } from '../services/ebbinghaus.js';
import { invalidateErrorWordsCache } from '../services/stats.js';
let backupData = null;
let backupReadGeneration = 0;
let backupReadPending = false;
export function exportAllData() {
const libraries = {};
WORD_LIBRARIES.forEach(library => {
libraries[library.id] = library.id === state.activeWordLibrary ? {
words: state.words,
records: state.records,
schedule: state.schedule,
favorites: state.favorites
} : {
words: loadJsonSetting(getLibraryStorageKey(STORAGE_KEYS.words, library.id), []),
records: loadJsonSetting(getLibraryStorageKey(STORAGE_KEYS.records, library.id), []),
schedule: loadJsonSetting(getLibraryStorageKey(STORAGE_KEYS.schedule, library.id), {}),
favorites: loadJsonSetting(getLibraryStorageKey(STORAGE_KEYS.favorites, library.id), [])
};
});
const data = {
words: state.words,
records: state.records,
schedule: state.schedule,
favorites: state.favorites,
emails: state.mailEmails,
activeWordLibrary: state.activeWordLibrary,
libraries,
settings: Object.fromEntries(Object.entries(state.settings).filter(([key]) => !['apiKey', 'ttsApiKey'].includes(key))),
exportDate: new Date().toISOString()
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ai-english-backup-${getToday()}.json`;
a.click();
URL.revokeObjectURL(url);
showToast('数据已导出', 'success');
}
export function showImportAllModal() {
backupData = null;
backupReadPending = false;
backupReadGeneration++;
showModal('<i class="fas fa-upload" style="color:var(--primary)"></i> 导入数据备份', `
<div class="form-group">
<label>选择备份文件</label>
<input type="file" accept=".json" id="import-backup-file" data-action="settings.handleImportBackup" data-action-event="change" data-element-arg="0">
<p class="hint">选择之前导出的备份文件</p>
</div>
`, `
<button class="btn btn-secondary" data-action="settings.closeModal">取消</button>
<button class="btn btn-primary" id="import-backup-btn" data-action="settings.doImportBackup" disabled>导入</button>
`);
}
export function handleImportBackup(input) {
const generation = ++backupReadGeneration;
backupData = null;
backupReadPending = false;
const importButton = document.getElementById('import-backup-btn');
if (importButton) importButton.disabled = true;
const file = input.files[0];
if (!file) return;
backupReadPending = true;
const reader = new FileReader();
reader.onload = e => {
if (generation !== backupReadGeneration) return;
backupReadPending = false;
backupData = typeof e.target.result === 'string' ? e.target.result : null;
if (importButton) importButton.disabled = !backupData;
};
reader.onerror = () => {
if (generation !== backupReadGeneration) return;
backupReadPending = false;
backupData = null;
if (importButton) importButton.disabled = true;
showToast('备份文件读取失败', 'error');
};
reader.readAsText(file);
}
function normalizeBackupCategory(value) {
return typeof value === 'string' ? value.trim().slice(0, 100) : '';
}
function normalizeBackupWords(value) {
if (!Array.isArray(value)) throw new Error('单词数据格式错误');
const usedIds = new Set();
return value.map((raw, index) => {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error(`${index + 1} 个单词格式错误`);
const english = typeof raw.english === 'string' ? raw.english.trim() : '';
const chinese = typeof raw.chinese === 'string' ? raw.chinese.trim() : '';
if (!english || !chinese) throw new Error(`${index + 1} 个单词缺少英文或释义`);
const numericId = Number(raw.id);
const id = Number.isSafeInteger(numericId) && numericId > 0 ? numericId : index + 1;
if (usedIds.has(id)) throw new Error(`单词 ID 重复:${id}`);
usedIds.add(id);
const word = { ...raw, id, english, chinese };
if (Object.prototype.hasOwnProperty.call(raw, 'category')) word.category = normalizeBackupCategory(raw.category);
if (typeof raw.phonetic === 'string') word.phonetic = raw.phonetic.slice(0, 200);
if (Number.isFinite(Number(raw.frequency))) word.frequency = Number(raw.frequency);
if (raw.forms && typeof raw.forms === 'object' && !Array.isArray(raw.forms)) {
word.forms = Object.fromEntries(Object.entries(raw.forms).slice(0, 30).map(([key, val]) => [
String(key).slice(0, 100),
Array.isArray(val) ? val.slice(0, 30).map(item => String(item).slice(0, 200)) : String(val).slice(0, 500)
]));
}
if (raw.example && typeof raw.example === 'object' && !Array.isArray(raw.example)) {
word.example = { en: String(raw.example.en || '').slice(0, 5000), cn: String(raw.example.cn || '').slice(0, 5000) };
}
if (raw.sourceContext && typeof raw.sourceContext === 'object' && !Array.isArray(raw.sourceContext)) {
word.sourceContext = {
emailTitle: String(raw.sourceContext.emailTitle || '').slice(0, 500),
sentence: String(raw.sourceContext.sentence || '').slice(0, 5000)
};
}
return word;
});
}
function normalizeBackupSchedule(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('复习计划格式错误');
const schedule = {};
Object.entries(value).forEach(([wordId, raw]) => {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return;
const numericWordId = Number(wordId);
if (!Number.isSafeInteger(numericWordId) || numericWordId <= 0) return;
const numericStage = Number(raw.stage);
const stage = Number.isFinite(numericStage)
? Math.min(EBBINGHAUS_INTERVALS.length - 1, Math.max(0, Math.trunc(numericStage)))
: 0;
const normalizeCount = count => {
const numericCount = Number(count);
return Number.isFinite(numericCount) ? Math.max(0, Math.trunc(numericCount)) : 0;
};
const nextReview = typeof raw.nextReview === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(raw.nextReview)
? raw.nextReview
: getToday();
schedule[numericWordId] = {
stage,
nextReview,
correctCount: normalizeCount(raw.correctCount),
incorrectCount: normalizeCount(raw.incorrectCount)
};
});
return schedule;
}
function normalizeBackupEmails(value) {
if (!Array.isArray(value)) throw new Error('邮件数据格式错误');
return value.map((raw, index) => {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error(`${index + 1} 封邮件格式错误`);
return {
...raw,
id: Number.isSafeInteger(Number(raw.id)) ? Number(raw.id) : index + 1,
title: String(raw.title || '').trim().slice(0, 500),
content: String(raw.content || '').slice(0, 200000),
date: String(raw.date || '').trim().slice(0, 100)
};
});
}
function normalizeBackupRecords(value) {
if (!Array.isArray(value)) throw new Error('学习记录格式错误');
return value.map((raw, index) => {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error(`${index + 1} 条学习记录格式错误`);
const wordId = Number(raw.wordId);
if (!Number.isSafeInteger(wordId) || wordId <= 0) throw new Error(`${index + 1} 条学习记录的单词 ID 无效`);
if (typeof raw.isCorrect !== 'boolean') throw new Error(`${index + 1} 条学习记录的答题结果无效`);
if (raw.type !== 'quiz' && raw.type !== 'review') throw new Error(`${index + 1} 条学习记录的类型无效`);
const time = Number(raw.time);
return {
...raw,
wordId,
date: typeof raw.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(raw.date) ? raw.date : getToday(),
time: Number.isFinite(time) && time >= 0 ? time : Date.now(),
isCorrect: raw.isCorrect,
type: raw.type,
quizMode: typeof raw.quizMode === 'string' ? raw.quizMode.slice(0, 100) : ''
};
});
}
function normalizeBackupFavorites(value) {
if (!Array.isArray(value)) throw new Error('收藏数据格式错误');
return [...new Set(value.map(Number).filter(id => Number.isSafeInteger(id) && id > 0))];
}
function normalizeBackupLibrary(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('词库数据格式错误');
return {
words: normalizeBackupWords(raw.words ?? []),
records: normalizeBackupRecords(raw.records ?? []),
schedule: normalizeBackupSchedule(raw.schedule ?? {}),
favorites: normalizeBackupFavorites(raw.favorites ?? [])
};
}
export function doImportBackup() {
if (backupReadPending) {
showToast('文件仍在读取中,请稍候', 'warning');
return;
}
if (!backupData) {
showToast('请选择文件', 'warning');
return;
}
const memorySnapshot = {
activeWordLibrary: state.activeWordLibrary,
words: state.words,
records: state.records,
schedule: state.schedule,
favorites: state.favorites,
mailEmails: state.mailEmails,
settings: state.settings
};
const storageSnapshot = new Map();
const rememberStorage = key => {
if (!storageSnapshot.has(key)) storageSnapshot.set(key, localStorage.getItem(key));
};
const persist = (key, value) => {
rememberStorage(key);
if (!saveJsonSetting(key, value)) throw new Error('本地存储写入失败');
};
try {
const data = JSON.parse(backupData);
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('备份数据格式错误');
const activeWordLibrary = WORD_LIBRARIES.some(library => library.id === data.activeWordLibrary)
? data.activeWordLibrary
: state.activeWordLibrary;
const libraries = {};
if (data.libraries != null) {
if (typeof data.libraries !== 'object' || Array.isArray(data.libraries)) throw new Error('词库备份格式错误');
WORD_LIBRARIES.forEach(library => {
if (Object.prototype.hasOwnProperty.call(data.libraries, library.id)) {
libraries[library.id] = normalizeBackupLibrary(data.libraries[library.id]);
}
});
}
let activeLibrary = libraries[activeWordLibrary] || {
words: state.words,
records: state.records,
schedule: state.schedule,
favorites: state.favorites
};
activeLibrary = {
words: data.words == null ? activeLibrary.words : normalizeBackupWords(data.words),
records: data.records == null ? normalizeBackupRecords(activeLibrary.records) : normalizeBackupRecords(data.records),
schedule: data.schedule == null ? normalizeBackupSchedule(activeLibrary.schedule) : normalizeBackupSchedule(data.schedule),
favorites: data.favorites == null ? normalizeBackupFavorites(activeLibrary.favorites) : normalizeBackupFavorites(data.favorites)
};
libraries[activeWordLibrary] = activeLibrary;
const emails = data.emails == null ? state.mailEmails : normalizeBackupEmails(data.emails);
const settings = { ...state.settings };
if (data.settings != null) {
if (!data.settings || typeof data.settings !== 'object' || Array.isArray(data.settings)) throw new Error('设置数据格式错误');
const stringSettings = ['apiUrl', 'model', 'ttsProvider', 'ttsEndpoint', 'ttsRegion', 'ttsVoice', 'ttsOutputFormat'];
stringSettings.forEach(key => {
if (typeof data.settings[key] === 'string') settings[key] = data.settings[key].trim().slice(0, 1000);
});
['ttsEnabled', 'ttsPrefetchEnabled'].forEach(key => {
if (typeof data.settings[key] === 'boolean') settings[key] = data.settings[key];
});
}
Object.entries(libraries).forEach(([libraryId, lib]) => {
persist(getLibraryStorageKey(STORAGE_KEYS.words, libraryId), lib.words);
persist(getLibraryStorageKey(STORAGE_KEYS.records, libraryId), lib.records);
persist(getLibraryStorageKey(STORAGE_KEYS.schedule, libraryId), lib.schedule);
persist(getLibraryStorageKey(STORAGE_KEYS.favorites, libraryId), lib.favorites);
});
persist(STORAGE_KEYS.mailEmails, emails);
persist(STORAGE_KEYS.settings, settings);
rememberStorage(STORAGE_KEYS.activeWordLibrary);
localStorage.setItem(STORAGE_KEYS.activeWordLibrary, activeWordLibrary);
state.activeWordLibrary = activeWordLibrary;
state.words = activeLibrary.words;
state.records = activeLibrary.records;
state.schedule = activeLibrary.schedule;
state.favorites = activeLibrary.favorites;
state.mailEmails = emails;
state.settings = settings;
invalidateErrorWordsCache();
closeModal();
state.learnSession = null;
state.quizSession = null;
showToast('数据恢复成功', 'success');
renderPage(state.currentPage);
} catch (e) {
state.activeWordLibrary = memorySnapshot.activeWordLibrary;
state.words = memorySnapshot.words;
state.records = memorySnapshot.records;
state.schedule = memorySnapshot.schedule;
state.favorites = memorySnapshot.favorites;
state.mailEmails = memorySnapshot.mailEmails;
state.settings = memorySnapshot.settings;
storageSnapshot.forEach((value, key) => {
try {
if (value == null) localStorage.removeItem(key);
else localStorage.setItem(key, value);
} catch (restoreError) {
console.error(`恢复本地存储失败:${key}`, restoreError);
}
});
showToast('导入失败:' + e.message, 'error');
}
}
export function clearAllData() {
if (!confirm('确定要清空所有数据吗?(所有词库的单词、学习记录、复习计划和保存邮件都将被删除,收藏单词将保留,此操作不可恢复!)')) return false;
if (!confirm('再次确认:所有词库的数据将永久删除且无法恢复(收藏单词保留),是否继续?')) return false;
const libraryKeys = WORD_LIBRARIES.flatMap(library => [
getLibraryStorageKey(STORAGE_KEYS.words, library.id),
getLibraryStorageKey(STORAGE_KEYS.records, library.id),
getLibraryStorageKey(STORAGE_KEYS.schedule, library.id),
getLibraryStorageKey(STORAGE_KEYS.favorites, library.id)
]);
const storageSnapshot = snapshotStorageItems([...libraryKeys, STORAGE_KEYS.mailEmails]);
if (!storageSnapshot) {
showToast('无法读取本地存储,清空操作已取消', 'error');
return false;
}
const memorySnapshot = {
words: state.words,
records: state.records,
schedule: state.schedule,
favorites: state.favorites,
mailEmails: state.mailEmails
};
const clearedLibraries = {};
try {
WORD_LIBRARIES.forEach(library => {
const words = library.id === state.activeWordLibrary
? state.words
: loadJsonSetting(getLibraryStorageKey(STORAGE_KEYS.words, library.id), []);
const favorites = library.id === state.activeWordLibrary
? state.favorites
: loadJsonSetting(getLibraryStorageKey(STORAGE_KEYS.favorites, library.id), []);
const favoriteIds = new Set(Array.isArray(favorites) ? favorites : []);
const keptWords = (Array.isArray(words) ? words : []).filter(word => favoriteIds.has(word.id));
const schedule = Object.fromEntries(keptWords.map(word => [word.id, {
stage: 0,
nextReview: getToday(),
correctCount: 0,
incorrectCount: 0
}]));
const cleared = { words: keptWords, records: [], schedule, favorites: keptWords.map(word => word.id) };
clearedLibraries[library.id] = cleared;
if (!saveJsonSetting(getLibraryStorageKey(STORAGE_KEYS.words, library.id), cleared.words)
|| !saveJsonSetting(getLibraryStorageKey(STORAGE_KEYS.records, library.id), cleared.records)
|| !saveJsonSetting(getLibraryStorageKey(STORAGE_KEYS.schedule, library.id), cleared.schedule)
|| !saveJsonSetting(getLibraryStorageKey(STORAGE_KEYS.favorites, library.id), cleared.favorites)) {
throw new Error('本地存储写入失败');
}
});
if (!saveJsonSetting(STORAGE_KEYS.mailEmails, [])) throw new Error('邮件数据清除失败');
} catch (error) {
restoreStorageItems(storageSnapshot);
Object.assign(state, memorySnapshot);
showToast('清空失败:' + error.message, 'error');
renderPage('settings');
return false;
}
const activeLibrary = clearedLibraries[state.activeWordLibrary];
state.words = activeLibrary.words;
state.records = activeLibrary.records;
state.schedule = activeLibrary.schedule;
state.favorites = activeLibrary.favorites;
state.mailEmails = [];
state.learnSession = null;
state.quizSession = null;
invalidateErrorWordsCache();
const keptCount = Object.values(clearedLibraries).reduce((count, library) => count + library.words.length, 0);
const keptMsg = keptCount > 0 ? `,保留了 ${keptCount} 个收藏单词` : '';
showToast('所有词库数据已清空' + keptMsg, 'info');
renderPage('settings');
return true;
}
export function clearFavorites() {
if (!confirm('确定要清除所有收藏单词吗?此操作不可恢复!')) return false;
const previousFavorites = state.favorites;
state.favorites = [];
if (!saveFavorites()) {
state.favorites = previousFavorites;
renderPage('settings');
return false;
}
showToast('收藏单词已清除', 'info');
renderPage('settings');
return true;
}
+405
View File
@@ -0,0 +1,405 @@
import { WORD_LIBRARIES } from '../constants.js';
import { COMMON_NAMES, STOP_WORDS } from '../data/stopwords.js';
import { state } from '../core/state.js';
import { saveJsonSetting, saveSettings, STORAGE_KEYS } from '../core/storage.js';
import { renderPage } from '../core/router.js';
import { showToast } from '../ui/toast.js';
import { clearCloudTtsCache, getTtsSettings, resetCloudTtsAuthBlock, sanitizeAzureSpeechKey, testCloudTtsConnection as testAzureTTSConnection } from '../services/tts.js';
import { callAI } from '../services/ai.js';
import { escapeHtml } from '../ui/dom.js';
export { toggleBatchExtract } from './extract.js';
export { showFilterWordsModal } from './filter-words.js';
export { manualLoadWords, switchWordLibrary, toggleAutoLoad } from '../services/library.js';
export { clearAllData, clearFavorites, doImportBackup, exportAllData, handleImportBackup, showImportAllModal } from './settings-data.js';
// ==================== Page: Settings ====================
export function renderSettings(el) {
const s = state.settings;
el.innerHTML = `
<div class="page-header">
<div>
<h1>设置</h1>
<p class="page-desc">配置 AI 模型接口与管理学习数据</p>
</div>
</div>
<div class="settings-layout">
<div class="card settings-card">
<h3><span class="icon-badge" style="color:#a855f7;background:rgba(168,85,247,0.1)"><i class="fas fa-robot"></i></span> AI 模型配置</h3>
<div class="form-group">
<label>API Key</label>
<input type="password" id="set-api-key" value="${escapeHtml(s.apiKey || '')}" placeholder="输入你的 API Key">
</div>
<div class="form-group">
<label>API URL</label>
<input type="text" id="set-api-url" value="${escapeHtml(s.apiUrl || 'https://tohub.com/v1')}" placeholder="https://tohub.com/v1">
<p class="hint">格式:https://your-api.com/v1</p>
</div>
<div class="form-group">
<label>Model</label>
<input type="text" id="set-model" value="${escapeHtml(s.model || 'gemini-3-flash')}" placeholder="gemini-3-flash">
</div>
<div class="btn-group settings-card-actions">
<button class="btn btn-primary" data-action="settings.doSaveSettings"><i class="fas fa-save"></i> 保存设置</button>
<button class="btn btn-secondary" data-action="settings.testAIConnection"><i class="fas fa-plug"></i> 测试连接</button>
</div>
</div>
<div class="card settings-card">
<h3><span class="icon-badge" style="color:#06b6d4;background:rgba(6,182,212,0.1)"><i class="fas fa-headphones"></i></span> TTS 语音配置</h3>
<p class="settings-card-desc">优先使用云端 TTS 朗读,失败后自动回退到有道发音。</p>
<div class="settings-row" style="margin-bottom:20px">
<div class="settings-row-main">
<div>
<div class="settings-row-title">启用云端 TTS</div>
<div class="settings-row-desc">开启后朗读会优先调用下面配置的 TTS 模型</div>
</div>
</div>
<div class="toggle-switch ${s.ttsEnabled !== false ? 'active' : ''}" data-action="settings.toggleTtsEnabled"><div class="toggle-knob"></div></div>
</div>
<div class="form-group">
<label>API Key</label>
<input type="password" id="set-tts-api-key" value="${escapeHtml(s.ttsApiKey || '')}" placeholder="输入 Azure Speech API 密钥">
</div>
<div class="form-group">
<label>Region</label>
<input type="text" id="set-tts-region" value="${escapeHtml(s.ttsRegion || 'eastus')}" placeholder="eastus">
<p class="hint">例如 eastus,需与 API Key 所属区域一致。</p>
</div>
<div class="form-group">
<label>Voice</label>
<select id="set-tts-voice">
${['en-US-JennyNeural', 'en-US-AvaNeural', 'en-US-GuyNeural', 'en-US-AriaNeural', 'en-GB-SoniaNeural', 'en-GB-RyanNeural'].map(v => `<option value="${v}" ${(s.ttsVoice || 'en-US-JennyNeural') === v ? 'selected' : ''}>${v}</option>`).join('')}
</select>
</div>
<div class="settings-row" style="margin-bottom:20px">
<div class="settings-row-main">
<div>
<div class="settings-row-title">预取 Azure TTS 音频</div>
<div class="settings-row-desc">提前生成 Azure 音频,减少播放等待,但会消耗更多请求额度</div>
</div>
</div>
<div class="toggle-switch ${s.ttsPrefetchEnabled === true ? 'active' : ''}" data-action="settings.toggleTtsPrefetchEnabled"><div class="toggle-knob"></div></div>
</div>
<div class="btn-group settings-card-actions">
<button class="btn btn-primary" data-action="settings.doSaveSettings"><i class="fas fa-save"></i> 保存 TTS 设置</button>
<button class="btn btn-secondary" data-action="settings.testTTSConnection"><i class="fas fa-volume-up"></i> 测试 TTS</button>
</div>
</div>
<div class="card settings-card full-width">
<h3><span class="icon-badge" style="color:#3b82f6;background:rgba(59,130,246,0.1)"><i class="fas fa-database"></i></span> 数据管理</h3>
<p class="settings-card-desc" style="margin-bottom:0">所有数据存储在浏览器本地,清除浏览器数据会丢失。建议定期导出备份。</p>
<div class="settings-stat-grid">
<div class="settings-stat-item">
<div class="settings-stat-value" style="color:var(--primary)">${state.words.length}</div>
<div class="settings-stat-label">单词</div>
</div>
<div class="settings-stat-item">
<div class="settings-stat-value" style="color:var(--success)">${state.records.length}</div>
<div class="settings-stat-label">学习记录</div>
</div>
<div class="settings-stat-item">
<div class="settings-stat-value" style="color:var(--warning)">${Object.keys(state.schedule).length}</div>
<div class="settings-stat-label">复习计划</div>
</div>
<div class="settings-stat-item">
<div class="settings-stat-value" style="color:var(--warning)">${state.favorites.length}</div>
<div class="settings-stat-label">收藏单词</div>
</div>
<div class="settings-stat-item">
<div class="settings-stat-value" style="color:var(--primary)">${state.mailEmails.length}</div>
<div class="settings-stat-label">保存邮件</div>
</div>
</div>
<div class="btn-group settings-card-actions">
<button class="btn btn-secondary" data-action="settings.exportAllData"><i class="fas fa-download"></i> 导出全部数据</button>
<button class="btn btn-secondary" data-action="settings.showImportAllModal"><i class="fas fa-upload"></i> 导入数据备份</button>
</div>
<div class="settings-divider">
<div class="btn-group">
<button class="btn btn-error" data-action="settings.clearAllData"><i class="fas fa-trash-alt"></i> 清空所有数据</button>
<button class="btn btn-secondary" data-action="settings.clearFavorites" style="color:var(--warning);border-color:var(--warning)"><i class="fas fa-star"></i> 清除收藏单词</button>
</div>
<p class="hint" style="margin-top:8px"><i class="fas fa-exclamation-triangle" style="color:var(--warning)"></i> 清空数据不可恢复,AI 配置和收藏单词将保留</p>
</div>
</div>
</div>
<div class="settings-layout" style="margin-top:24px">
<div class="card settings-card">
<h3><span class="icon-badge" style="color:#22c55e;background:rgba(34,197,94,0.1)"><i class="fas fa-sliders-h"></i></span> 词库与提词</h3>
<div class="settings-row" style="display:block">
<div class="settings-row-title" style="margin-bottom:6px">当前单词库</div>
<div class="settings-row-desc" style="margin-bottom:10px">切换后会使用所选词库,并为每个词库分别保留学习进度和收藏。</div>
<div class="word-library-picker">
<select id="word-library-select">
${WORD_LIBRARIES.map(library => `<option value="${library.id}" ${state.activeWordLibrary === library.id ? 'selected' : ''}>${escapeHtml(library.name)}</option>`).join('')}
</select>
<button class="btn btn-primary" data-action="settings.switchWordLibrary"><i class="fas fa-exchange-alt"></i> 切换词库</button>
</div>
</div>
<div class="settings-row">
<div class="settings-row-main">
<div class="toggle-switch ${state.autoLoadEnabled ? 'active' : ''}" data-action="settings.toggleAutoLoad" data-arg0="${!state.autoLoadEnabled}">
<div class="toggle-knob"></div>
</div>
<div>
<div class="settings-row-title">启动时自动加载内置词库</div>
<div class="settings-row-desc">若词库为空,自动加载 data/ 目录下的单词文件</div>
</div>
</div>
<button class="btn btn-sm btn-secondary" data-action="settings.manualLoadWords" title="手动加载"><i class="fas fa-sync-alt"></i></button>
</div>
<div class="settings-row">
<div class="settings-row-main">
<div class="toggle-switch ${state.batchExtractEnabled ? 'active' : ''}" data-action="settings.toggleBatchExtract" data-arg0="${!state.batchExtractEnabled}">
<div class="toggle-knob"></div>
</div>
<div>
<div class="settings-row-title">邮件批量提词</div>
<div class="settings-row-desc">在提词页面显示批量提取功能,支持上传文件并统计词频</div>
</div>
</div>
</div>
<div class="settings-divider">
<div class="settings-row-title" style="margin-bottom:8px"><i class="fas fa-filter" style="color:var(--error);margin-right:4px"></i> 过滤词管理</div>
<p class="settings-row-desc" style="margin-bottom:10px">管理提词时自动排除的停用词和常见人名</p>
<div class="btn-group">
<button class="btn btn-sm btn-secondary" data-action="settings.showFilterWordsModal" data-arg0="stop"><i class="fas fa-ban"></i> 停用词 (${STOP_WORDS.size})</button>
<button class="btn btn-sm btn-secondary" data-action="settings.showFilterWordsModal" data-arg0="names"><i class="fas fa-user-slash"></i> 常见人名 (${COMMON_NAMES.size})</button>
</div>
</div>
</div>
<div class="card settings-card">
<h3><span class="icon-badge" style="color:#f59e0b;background:rgba(245,158,11,0.1)"><i class="fas fa-volume-up"></i></span> 自动播放设置</h3>
<p class="settings-card-desc">配置单词库自动播放时的朗读次数和间隔时间</p>
<div class="settings-row" style="display:block">
<div class="settings-row-title" style="margin-bottom:10px">每个单词朗读次数</div>
<div class="btn-group" style="margin-top:0">
<button class="btn btn-sm ${state.autoPlayRepeat === 1 ? 'btn-primary' : 'btn-secondary'}" data-action="settings.setAutoPlayRepeat" data-arg0="1">1 次</button>
<button class="btn btn-sm ${state.autoPlayRepeat === 2 ? 'btn-primary' : 'btn-secondary'}" data-action="settings.setAutoPlayRepeat" data-arg0="2">2 次</button>
<button class="btn btn-sm ${state.autoPlayRepeat === 3 ? 'btn-primary' : 'btn-secondary'}" data-action="settings.setAutoPlayRepeat" data-arg0="3">3 次</button>
</div>
</div>
<div id="repeat-delay-setting" class="settings-row" style="display:${state.autoPlayRepeat <= 1 ? 'none' : 'block'};margin-top:12px">
<div class="settings-row-title" style="margin-bottom:6px">重复朗读间隔</div>
<div class="settings-row-desc" style="margin-bottom:8px">同一单词多次朗读之间的间隔时间</div>
<div class="settings-input-row">
<input type="number" id="set-repeat-delay" value="${state.autoPlayRepeatDelay || ''}" placeholder="默认 1000" min="300" max="5000" step="100">
<span class="unit">毫秒(留空使用默认 1 秒)</span>
</div>
</div>
<div class="settings-row" style="display:block;margin-top:12px">
<div class="settings-row-title" style="margin-bottom:6px">单词切换间隔</div>
<div class="settings-row-desc" style="margin-bottom:8px">自动播放时切换到下一个单词的间隔时间</div>
<div class="settings-input-row">
<input type="number" id="set-word-interval" value="${state.autoPlayInterval || ''}" placeholder="默认 2500" min="500" max="10000" step="100">
<span class="unit">毫秒(留空使用默认 2.5 秒)</span>
</div>
</div>
<div class="settings-row" style="display:block;margin-top:12px">
<div class="settings-row-title" style="margin-bottom:6px">播放时滚动方式</div>
<div class="settings-row-desc" style="margin-bottom:8px">自动播放切换单词时,页面滚动行为</div>
<div class="btn-group" style="margin-top:0">
<button class="btn btn-sm ${state.autoPlayScrollMode === 'center' ? 'btn-primary' : 'btn-secondary'}" data-action="settings.setAutoPlayScrollMode" data-arg0="center">始终居中</button>
<button class="btn btn-sm ${state.autoPlayScrollMode === 'visible' ? 'btn-primary' : 'btn-secondary'}" data-action="settings.setAutoPlayScrollMode" data-arg0="visible">不在视口内时才滚动</button>
</div>
</div>
<div class="btn-group settings-card-actions">
<button class="btn btn-sm btn-primary" data-action="settings.saveAutoPlaySettings"><i class="fas fa-save"></i> 保存播放设置</button>
</div>
</div>
<div class="card settings-card full-width">
<h3><span class="icon-badge" style="color:#ef4444;background:rgba(239,68,68,0.1)"><i class="fas fa-cloud-download-alt"></i></span> 应用更新</h3>
<p class="settings-card-desc">如果 iPhone Safari 仍显示旧版本,可重新获取最新页面和程序文件。单词、学习记录、收藏及设置不会被清除。</p>
<div class="btn-group settings-card-actions">
<button class="btn btn-secondary" id="force-refresh-btn" data-action="settings.forceRefreshBrowserCache"><i class="fas fa-sync-alt"></i> 获取最新版本</button>
</div>
</div>
</div>
`;
}
export async function forceRefreshBrowserCache() {
if (!confirm('确定获取最新版本并重新加载吗?本地学习数据和设置不会被删除。')) return;
const button = document.getElementById('force-refresh-btn');
if (button) {
button.disabled = true;
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 正在获取最新版本...';
}
try {
// 当前无 Service WorkerCache Storage 通常为空;此处为将来引入 SW 时的前瞻兼容。
// 真正的缓存绕过依赖下方的时间戳 URL + 静态资源 ?v= 版本号。
if ('caches' in window) {
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map(cacheName => caches.delete(cacheName)));
}
} catch (error) {
console.warn('清理应用缓存失败,将继续重新加载:', error);
}
const url = new URL(window.location.href);
url.searchParams.set('_app_update', Date.now().toString());
window.location.replace(url.toString());
}
export function setAutoPlayRepeat(count) {
const previousValue = state.autoPlayRepeat;
state.autoPlayRepeat = count;
if (!saveJsonSetting(STORAGE_KEYS.autoPlayRepeat, count)) {
state.autoPlayRepeat = previousValue;
return false;
}
const delayEl = document.getElementById('repeat-delay-setting');
if (delayEl) delayEl.style.display = count <= 1 ? 'none' : '';
if (state.currentPage === 'settings') renderPage('settings');
return true;
}
export function setAutoPlayScrollMode(mode) {
const previousValue = state.autoPlayScrollMode;
state.autoPlayScrollMode = mode === 'visible' ? 'visible' : 'center';
if (!saveJsonSetting(STORAGE_KEYS.autoPlayScrollMode, state.autoPlayScrollMode)) {
state.autoPlayScrollMode = previousValue;
return false;
}
if (state.currentPage === 'settings') renderPage('settings');
return true;
}
export function saveAutoPlaySettings() {
const intervalVal = parseInt(document.getElementById('set-word-interval').value) || 0;
const repeatDelayVal = parseInt(document.getElementById('set-repeat-delay').value) || 0;
const previousInterval = state.autoPlayInterval;
const previousRepeatDelay = state.autoPlayRepeatDelay;
state.autoPlayInterval = intervalVal;
state.autoPlayRepeatDelay = repeatDelayVal;
const saved = saveJsonSetting(STORAGE_KEYS.autoPlayInterval, intervalVal)
&& saveJsonSetting(STORAGE_KEYS.autoPlayRepeatDelay, repeatDelayVal);
if (!saved) {
state.autoPlayInterval = previousInterval;
state.autoPlayRepeatDelay = previousRepeatDelay;
saveJsonSetting(STORAGE_KEYS.autoPlayInterval, previousInterval);
saveJsonSetting(STORAGE_KEYS.autoPlayRepeatDelay, previousRepeatDelay);
return false;
}
showToast('播放设置已保存', 'success');
return true;
}
function valueById(id, fallback = '') {
const el = document.getElementById(id);
return el ? el.value.trim() : fallback;
}
function syncSettingsFromForm() {
state.settings = {
...state.settings,
apiKey: valueById('set-api-key', state.settings.apiKey || ''),
apiUrl: valueById('set-api-url', state.settings.apiUrl || '').replace(/\/$/, ''),
model: valueById('set-model', state.settings.model || ''),
ttsProvider: 'azure',
ttsApiKey: sanitizeAzureSpeechKey(valueById('set-tts-api-key', state.settings.ttsApiKey || '')),
ttsEndpoint: '',
ttsRegion: valueById('set-tts-region', state.settings.ttsRegion || 'eastus').toLowerCase(),
ttsVoice: valueById('set-tts-voice', state.settings.ttsVoice || 'en-US-JennyNeural'),
ttsOutputFormat: 'audio-24khz-48kbitrate-mono-mp3',
ttsPrefetchEnabled: state.settings.ttsPrefetchEnabled === true
};
}
export function toggleTtsEnabled() {
const previousSettings = state.settings;
syncSettingsFromForm();
state.settings.ttsEnabled = state.settings.ttsEnabled === false;
resetCloudTtsAuthBlock();
clearCloudTtsCache();
if (!saveSettings()) {
state.settings = previousSettings;
return false;
}
renderPage('settings');
return true;
}
export function toggleTtsPrefetchEnabled() {
const previousSettings = state.settings;
syncSettingsFromForm();
state.settings.ttsPrefetchEnabled = state.settings.ttsPrefetchEnabled !== true;
if (!saveSettings()) {
state.settings = previousSettings;
return false;
}
renderPage('settings');
return true;
}
function persistSettings() {
const previousSettings = state.settings;
syncSettingsFromForm();
resetCloudTtsAuthBlock();
clearCloudTtsCache();
if (saveSettings()) return true;
state.settings = previousSettings;
return false;
}
export function doSaveSettings() {
if (!persistSettings()) return false;
showToast('设置已保存', 'success');
return true;
}
export async function testTTSConnection() {
if (!persistSettings()) return;
const cfg = getTtsSettings();
if (!cfg.enabled) {
showToast('请先启用云端 TTS', 'warning');
return;
}
if (!cfg.apiKey || !cfg.region || !cfg.voice) {
showToast('请填写 TTS API Key、Region 和 Voice', 'warning');
return;
}
showToast('正在测试 TTS...', 'info');
try {
const played = await testAzureTTSConnection();
showToast(played ? 'TTS 测试成功' : 'TTS 未播放:请检查配置或稍后重试', played ? 'success' : 'warning');
} catch (e) {
showToast('TTS 测试失败:' + e.message, 'error');
}
}
export async function testAIConnection() {
if (!doSaveSettings()) return;
if (!state.settings.apiKey || !state.settings.apiUrl || !state.settings.model) {
showToast('请填写所有配置项', 'warning');
return;
}
showToast('正在测试连接...', 'info');
try {
const reply = await callAI([
{ role: 'user', content: '请回复"连接成功"四个字' }
]);
showToast('连接成功!AI 回复:' + reply.slice(0, 50), 'success');
} catch (e) {
showToast('连接失败:' + e.message, 'error');
}
}
+216
View File
@@ -0,0 +1,216 @@
import { state } from '../core/state.js';
import { getToday } from '../services/ebbinghaus.js';
import { getErrorTopWords, getLast30DaysData, getStats, getStreak } from '../services/stats.js';
import { escapeHtml } from '../ui/dom.js';
let studyChart = null;
// ==================== Page: Stats ====================
export function renderStats(el) {
destroyStudyChart();
if (state.records.length === 0 && state.words.length === 0) {
el.innerHTML = `
<div class="page-header">
<div>
<h1>学习统计</h1>
<p class="page-desc">查看你的学习进度与数据分析</p>
</div>
</div>
<div class="empty-state">
<div class="empty-icon"><i class="fas fa-chart-line"></i></div>
<h3>暂无数据</h3>
<p>开始学习后这里会显示你的统计数据</p>
</div>`;
return;
}
const s = getStats();
const streak = getStreak();
const todayRecords = state.records.filter(r => r.date === getToday());
const todayCorrect = todayRecords.filter(r => r.isCorrect).length;
const todayTotal = todayRecords.length;
const todayRate = todayTotal > 0 ? Math.round(todayCorrect / todayTotal * 100) : 0;
const allCorrect = state.records.filter(r => r.isCorrect).length;
const allTotal = state.records.length;
const allRate = allTotal > 0 ? Math.round(allCorrect / allTotal * 100) : 0;
const dailyData = getLast30DaysData();
const errorTop = getErrorTopWords(10);
el.innerHTML = `
<div class="page-header">
<div>
<h1>学习统计</h1>
<p class="page-desc">查看你的学习进度与数据分析</p>
</div>
</div>
<div class="stats-body">
<div class="stats-left">
<div class="stats-left-cards">
<div class="stat-card" style="border-left:3px solid var(--primary)">
<div class="stat-icon" style="background:var(--primary-bg);color:var(--primary)"><i class="fas fa-database"></i></div>
<div class="stat-info">
<div class="stat-value">${s.total}</div>
<div class="stat-label">总单词</div>
</div>
</div>
<div class="stat-card" style="border-left:3px solid var(--success)">
<div class="stat-icon" style="background:var(--success-light);color:var(--success)"><i class="fas fa-check-circle"></i></div>
<div class="stat-info">
<div class="stat-value">${s.mastered}</div>
<div class="stat-label">已掌握</div>
</div>
</div>
<div class="stat-card" style="border-left:3px solid var(--warning)">
<div class="stat-icon" style="background:var(--warning-light);color:var(--warning)"><i class="fas fa-fire"></i></div>
<div class="stat-info">
<div class="stat-value">${streak}</div>
<div class="stat-label">连续天数</div>
</div>
</div>
<div class="stat-card" style="border-left:3px solid var(--error)">
<div class="stat-icon" style="background:var(--error-light);color:var(--error)"><i class="fas fa-bullseye"></i></div>
<div class="stat-info">
<div class="stat-value">${allRate}%</div>
<div class="stat-label">总正确率</div>
</div>
</div>
<div class="stat-card" style="border-left:3px solid var(--primary)">
<div class="stat-icon" style="background:var(--primary-bg);color:var(--primary)"><i class="fas fa-pen"></i></div>
<div class="stat-info">
<div class="stat-value">${todayTotal}</div>
<div class="stat-label">今日练习次数</div>
</div>
</div>
<div class="stat-card" style="border-left:3px solid var(--success)">
<div class="stat-icon" style="background:var(--success-light);color:var(--success)"><i class="fas fa-bullseye"></i></div>
<div class="stat-info">
<div class="stat-value">${todayRate}%</div>
<div class="stat-label">今日正确率</div>
</div>
</div>
</div>
<div class="card" style="flex:1">
<h3><i class="fas fa-chart-bar"></i> 近30天学习量</h3>
<div class="chart-container">
<canvas id="study-chart"></canvas>
</div>
</div>
</div>
<div class="card stats-error-card">
<h3><i class="fas fa-exclamation-triangle" style="color:var(--error)"></i> 最容易出错的单词 Top 10</h3>
${errorTop.length > 0 ? `
<div style="overflow-y:auto;flex:1">
<table class="error-table">
<thead><tr><th>单词</th><th>释义</th><th>正确率</th><th>次数</th></tr></thead>
<tbody>
${errorTop.map(w => {
const color = w.rate < 40 ? 'var(--error)' : w.rate < 70 ? 'var(--warning)' : 'var(--success)';
return `
<tr>
<td><strong>${escapeHtml(w.english)}</strong></td>
<td style="color:var(--text-secondary)">${escapeHtml(w.chinese)}</td>
<td>
<span style="color:${color};font-weight:600">${w.rate}%</span>
<div class="accuracy-bar"><div class="accuracy-fill" style="width:${w.rate}%;background:${color}"></div></div>
</td>
<td style="color:var(--text-muted);text-align:center">${w.total}</td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>` : '<div style="text-align:center;padding:40px 20px;color:var(--text-muted);flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center"><i class="fas fa-chart-bar" style="font-size:32px;margin-bottom:12px;opacity:0.4"></i><p>暂无测试数据</p><p style="font-size:12px;margin-top:4px">完成 AI 测试后,错误统计将在此展示</p></div>'}
</div>
</div>
<div class="card">
<h3><i class="fas fa-chart-pie"></i> 掌握程度分布</h3>
<div style="padding:16px 0">
<div style="display:flex;justify-content:space-between;align-items:center;font-size:13px;margin-bottom:6px">
<span style="color:var(--success);font-weight:600"><i class="fas fa-check-circle" style="margin-right:4px"></i> 已掌握</span><span style="font-weight:700">${s.mastered}</span>
</div>
<div class="progress-bar" style="height:10px;margin-bottom:16px">
<div class="progress-fill" style="width:${s.total ? s.mastered/s.total*100 : 0}%;background:var(--success)"></div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;font-size:13px;margin-bottom:6px">
<span style="color:var(--warning);font-weight:600"><i class="fas fa-spinner" style="margin-right:4px"></i> 学习中</span><span style="font-weight:700">${s.learning}</span>
</div>
<div class="progress-bar" style="height:10px;margin-bottom:16px">
<div class="progress-fill" style="width:${s.total ? s.learning/s.total*100 : 0}%;background:var(--warning)"></div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;font-size:13px;margin-bottom:6px">
<span style="color:var(--text-muted);font-weight:600"><i class="fas fa-plus-circle" style="margin-right:4px"></i> 新词</span><span style="font-weight:700">${s.newCount}</span>
</div>
<div class="progress-bar" style="height:10px">
<div class="progress-fill" style="width:${s.total ? s.newCount/s.total*100 : 0}%;background:var(--text-muted)"></div>
</div>
</div>
</div>
`;
renderStudyChart(dailyData);
}
export function destroyStudyChart() {
if (!studyChart) return;
studyChart.destroy();
studyChart = null;
}
export function refreshStudyChart() {
if (!document.getElementById('study-chart')) return;
renderStudyChart(getLast30DaysData());
}
export function renderStudyChart(dailyData) {
const ctx = document.getElementById('study-chart');
if (!ctx || typeof globalThis.Chart === 'undefined') return;
destroyStudyChart();
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
const gridColor = isDark ? 'rgba(148, 163, 184, 0.1)' : 'rgba(148, 163, 184, 0.15)';
const textColor = isDark ? '#94a3b8' : '#64748b';
studyChart = new globalThis.Chart(ctx, {
type: 'bar',
data: {
labels: dailyData.map(d => d.label),
datasets: [{
label: '每日学习量',
data: dailyData.map(d => d.count),
backgroundColor: 'rgba(99, 102, 241, 0.7)',
hoverBackgroundColor: 'rgba(99, 102, 241, 0.9)',
borderRadius: 4,
borderSkipped: false
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: isDark ? '#334155' : '#1e293b',
titleFont: { size: 12 },
bodyFont: { size: 13 },
padding: 10,
cornerRadius: 8
}
},
scales: {
y: {
beginAtZero: true,
grid: { color: gridColor },
ticks: { color: textColor, font: { size: 11 } }
},
x: {
grid: { display: false },
ticks: { color: textColor, font: { size: 10 }, maxRotation: 45 }
}
}
}
});
}
+106
View File
@@ -0,0 +1,106 @@
import { state } from '../core/state.js';
import { logTts, prefetchAudio, speak, stopAllAudio } from '../services/tts.js';
let autoPlayActive = false;
let autoPlayTimer = null;
export function toggleAutoPlay() {
if (autoPlayActive) stopAutoPlay();
else startAutoPlay();
}
export function startAutoPlay() {
const rows = document.querySelectorAll('.word-table tbody tr[data-word-id]');
const wordIds = Array.from(rows).map(row => Number.parseInt(row.dataset.wordId, 10));
if (wordIds.length === 0) return;
autoPlayActive = true;
updateAutoPlayBtn();
prefetchWordListAudio(wordIds);
playWordSequence(wordIds, 0);
}
export function prefetchWordListAudio(wordIds) {
const texts = wordIds
.map(id => state.words.find(word => word.id === id)?.english)
.filter(Boolean);
if (!texts.length) return;
void prefetchAudio(texts, 0.92, { allowCloud: true })
.catch(error => logTts('word list audio prefetch failed', error));
}
export function playWordSequence(wordIds, index) {
if (!autoPlayActive || index >= wordIds.length) {
stopAutoPlay();
return;
}
const word = state.words.find(item => item.id === wordIds[index]);
if (!word) {
playWordSequence(wordIds, index + 1);
return;
}
document.querySelectorAll('.word-table tbody tr').forEach(row => row.classList.remove('is-playing'));
const row = document.querySelector(`tr[data-word-id="${word.id}"]`);
if (row) {
row.classList.add('is-playing');
let shouldScroll = true;
if (state.autoPlayScrollMode === 'visible') {
const rect = row.getBoundingClientRect();
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const header = document.getElementById('mobile-header');
const topBound = header ? header.getBoundingClientRect().height : 0;
shouldScroll = rect.top < topBound || rect.bottom > viewportHeight;
}
if (shouldScroll) {
row.scrollIntoView({
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
block: 'center',
inline: 'nearest'
});
}
}
const repeatCount = Math.max(1, state.autoPlayRepeat || 1);
const repeatDelay = state.autoPlayRepeatDelay || 1000;
const wordInterval = state.autoPlayInterval || 2500;
let spoken = 0;
async function playCurrentWord() {
if (!autoPlayActive) return;
await speak(word.english);
if (!autoPlayActive) return;
spoken++;
if (spoken < repeatCount) {
autoPlayTimer = setTimeout(playCurrentWord, repeatDelay);
} else {
autoPlayTimer = setTimeout(() => playWordSequence(wordIds, index + 1), wordInterval);
}
}
void playCurrentWord();
}
export function stopAutoPlay() {
autoPlayActive = false;
if (autoPlayTimer) {
clearTimeout(autoPlayTimer);
autoPlayTimer = null;
}
stopAllAudio();
document.querySelectorAll('.word-table tbody tr').forEach(row => row.classList.remove('is-playing'));
updateAutoPlayBtn();
}
export function updateAutoPlayBtn() {
const button = document.getElementById('auto-play-btn');
if (!button) return;
if (autoPlayActive) {
button.innerHTML = '<i class="fas fa-stop"></i> 停止播放';
button.className = 'btn btn-error';
} else {
button.innerHTML = '<i class="fas fa-play"></i> 自动播放';
button.className = 'btn btn-secondary';
}
}
+446
View File
@@ -0,0 +1,446 @@
import { STAGE_LABELS, WORDS_PER_PAGE } from '../constants.js';
import { state } from '../core/state.js';
import { saveFavorites, saveRecords, saveSchedule, saveWords } from '../core/storage.js';
import { isFavorite, toggleFavorite } from '../services/favorites.js';
import { navigate, renderPage } from '../core/router.js';
import { showToast } from '../ui/toast.js';
import { closeModal, showModal } from '../ui/modal.js';
import { speak } from '../services/tts.js';
import { getToday, initWordSchedule } from '../services/ebbinghaus.js';
import { getMastery, getQuizErrorWords } from '../services/stats.js';
import { debounce, escapeHtml, formatFormsHtml } from '../ui/dom.js';
export { playWordSequence, prefetchWordListAudio, startAutoPlay, stopAutoPlay, toggleAutoPlay, updateAutoPlayBtn } from './words-autoplay.js';
export function getNextId() {
const usedIds = new Set([
...state.words.map(w => Number(w.id)),
...state.records.map(r => Number(r.wordId)),
...Object.keys(state.schedule).map(Number),
...state.favorites.map(Number)
].filter(Number.isFinite));
let next = 1;
for (const id of usedIds) {
if (id >= next) next = id + 1;
}
return next;
}
export function normalizeImportedWord(raw, id) {
const word = { ...raw, id };
word.english = String(word.english || '').trim();
word.chinese = String(word.chinese || '').trim();
if (word.phonetic != null) word.phonetic = String(word.phonetic);
if (word.category != null) word.category = String(word.category);
if (word.frequency != null) word.frequency = Number(word.frequency) || 0;
return word;
}
// ==================== Page: Words ====================
export function renderWords(el) {
const categories = [...new Set(state.words.map(w => w.category))].filter(Boolean);
el.innerHTML = `
<div class="page-header">
<div>
<h1>单词库</h1>
<p class="page-desc">管理和浏览你的单词库,共 ${state.words.length} 个单词</p>
</div>
<div class="page-actions">
<button class="btn btn-primary" data-action="words.showImportModal"><i class="fas fa-file-import"></i> 导入单词</button>
${state.words.length > 0 ? `<button class="btn btn-secondary" data-action="words.exportWords"><i class="fas fa-file-export"></i> 导出</button>` : ''}
</div>
</div>
${state.words.length > 0 ? `
<div class="search-bar">
<input type="text" placeholder="搜索单词或释义..." id="word-search" value="${escapeHtml(state.wordSearch)}" data-action="words.onWordSearch" data-action-event="input" data-value-arg="0">
<select id="category-filter" data-action="words.onCategoryFilter" data-action-event="change" data-value-arg="0">
<option value="">全部分类</option>
<option value="__favorites__" ${state.wordCategory === '__favorites__' ? 'selected' : ''}>⭐ 收藏 (${state.favorites.length})</option>
<option value="__errors__" ${state.wordCategory === '__errors__' ? 'selected' : ''}>❌ 错题 (${getQuizErrorWords().length})</option>
<option value="__freq_high__" ${state.wordCategory === '__freq_high__' ? 'selected' : ''}>📊 高频次 (≥50)</option>
<option value="__freq_mid__" ${state.wordCategory === '__freq_mid__' ? 'selected' : ''}>📊 中频次 (10-49)</option>
<option value="__freq_low__" ${state.wordCategory === '__freq_low__' ? 'selected' : ''}>📊 低频次 (1-9)</option>
${categories.map(c => `<option value="${escapeHtml(c)}" ${state.wordCategory === c ? 'selected' : ''}>${escapeHtml(categoryLabel(c))}</option>`).join('')}
</select>
<button id="auto-play-btn" class="btn btn-secondary" data-action="words.toggleAutoPlay" style="white-space:nowrap"><i class="fas fa-play"></i> 自动播放</button>
</div>
<div id="word-list-container"></div>` : `
<div class="empty-state">
<div class="empty-icon"><i class="fas fa-book-open"></i></div>
<h3>单词库是空的</h3>
<p>通过邮件提词或导入 JSON 文件添加单词</p>
<div style="display:flex;gap:10px;justify-content:center;flex-wrap:wrap">
<button class="btn btn-primary" data-action="words.navigate" data-arg0="extract"><i class="fas fa-envelope"></i> 去邮件提词</button>
<button class="btn btn-secondary" data-action="words.showImportModal"><i class="fas fa-file-import"></i> 导入单词</button>
</div>
</div>`}
`;
if (state.words.length > 0) renderWordList();
}
export function renderWordList() {
const container = document.getElementById('word-list-container');
if (!container) return;
let filtered = state.words;
if (state.wordSearch) {
const q = state.wordSearch.toLowerCase();
filtered = filtered.filter(w =>
w.english.toLowerCase().includes(q) || w.chinese.includes(q)
);
}
if (state.wordCategory === '__favorites__') {
filtered = filtered.filter(w => isFavorite(w.id));
} else if (state.wordCategory === '__errors__') {
const errorIds = new Set(getQuizErrorWords().map(w => w.id));
filtered = filtered.filter(w => errorIds.has(w.id));
} else if (state.wordCategory === '__freq_high__') {
filtered = filtered.filter(w => (w.frequency || 0) >= 50);
} else if (state.wordCategory === '__freq_mid__') {
filtered = filtered.filter(w => { const f = w.frequency || 0; return f >= 10 && f < 50; });
} else if (state.wordCategory === '__freq_low__') {
filtered = filtered.filter(w => (w.frequency || 0) < 10 && (w.frequency || 0) > 0);
} else if (state.wordCategory) {
filtered = filtered.filter(w => w.category === state.wordCategory);
}
const totalPages = Math.ceil(filtered.length / WORDS_PER_PAGE);
state.wordPage = totalPages === 0 ? 0 : Math.max(1, Math.min(state.wordPage, totalPages));
const start = state.wordPage > 0 ? (state.wordPage - 1) * WORDS_PER_PAGE : 0;
const pageWords = filtered.slice(start, start + WORDS_PER_PAGE);
container.innerHTML = `
<div class="word-table-wrapper">
<table class="word-table">
<thead>
<tr>
<th>单词</th>
<th class="phonetic-cell">音标</th>
<th>释义</th>
<th>状态</th>
<th class="action-cell">操作</th>
</tr>
</thead>
<tbody>
${pageWords.map(w => {
const mastery = getMastery(w.id);
const badgeClass = mastery === 'mastered' ? 'badge-success' : mastery === 'learning' ? 'badge-warning' : 'badge-primary';
const badgeText = mastery === 'mastered' ? '已掌握' : mastery === 'learning' ? '学习中' : '新词';
const favored = isFavorite(w.id);
const safeEn = escapeHtml(w.english);
return `
<tr data-word-id="${w.id}">
<td class="word-cell" data-action="words.showWordDetail" data-arg0="${w.id}">${escapeHtml(w.english)}</td>
<td class="phonetic-cell">${escapeHtml(w.phonetic || '-')}</td>
<td class="meaning-cell" title="${escapeHtml(w.chinese)}">${escapeHtml(w.chinese)}</td>
<td class="status-cell"><span class="badge ${badgeClass}">${badgeText}</span></td>
<td class="action-cell">
<button class="fav-toggle-btn" data-action="words.toggleWordFavorite" data-arg0="${w.id}" title="${favored ? '取消收藏' : '收藏'}" aria-label="${favored ? '取消收藏' : '收藏'} ${escapeHtml(w.english)}" style="color:${favored ? 'var(--warning)' : 'var(--text-muted)'}"><i class="${favored ? 'fas' : 'far'} fa-star"></i></button>
<button data-action="words.speak" data-arg0="${safeEn}" title="朗读" aria-label="朗读 ${escapeHtml(w.english)}"><i class="fas fa-volume-up"></i></button>
<button data-action="words.showWordDetail" data-arg0="${w.id}" title="详情" aria-label="查看 ${escapeHtml(w.english)} 详情"><i class="fas fa-eye"></i></button>
<button class="btn-delete" data-action="words.deleteWord" data-arg0="${w.id}" title="删除" aria-label="删除 ${escapeHtml(w.english)}"><i class="fas fa-trash-alt"></i></button>
</td>
</tr>`;
}).join('')}
</tbody>
</table>
<div class="word-table-footer">
<span>共 ${filtered.length} 个单词,第 ${state.wordPage} / ${totalPages} 页</span>
<div>${totalPages > 1 ? renderPagination(totalPages) : ''}</div>
</div>
</div>
`;
}
export function renderPagination(totalPages) {
let html = '<div class="pagination">';
html += `<button data-action="words.goWordPage" data-arg0="${state.wordPage - 1}" ${state.wordPage <= 1 ? 'disabled' : ''}></button>`;
const maxShow = 7;
let startP = Math.max(1, state.wordPage - 3);
let endP = Math.min(totalPages, startP + maxShow - 1);
if (endP - startP < maxShow - 1) startP = Math.max(1, endP - maxShow + 1);
for (let i = startP; i <= endP; i++) {
html += `<button class="${i === state.wordPage ? 'active' : ''}" data-action="words.goWordPage" data-arg0="${i}">${i}</button>`;
}
html += `<button data-action="words.goWordPage" data-arg0="${state.wordPage + 1}" ${state.wordPage >= totalPages ? 'disabled' : ''}></button>`;
html += '</div>';
return html;
}
export function goWordPage(p) {
state.wordPage = p;
renderWordList();
requestAnimationFrame(() => {
document.querySelector('.search-bar')?.scrollIntoView({
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches
? 'auto'
: 'smooth',
block: 'start'
});
});
}
const debouncedRenderWordList = debounce(() => renderWordList(), 180);
export function onWordSearch(val) {
state.wordSearch = val;
state.wordPage = 1;
debouncedRenderWordList(); // 逐键防抖,避免每次击键全量重建列表
}
export function onCategoryFilter(val) {
state.wordCategory = val;
state.wordPage = 1;
renderWordList();
}
export function categoryLabel(cat) {
const map = {
high_frequency: '🔴 高频词汇',
medium_frequency: '🟡 中频词汇',
low_frequency: '🔵 低频词汇',
action_verbs: '⚡ 动作动词',
business_core: '💼 商务核心',
technical_core: '🔧 技术核心',
technical_operations: '🛠️ 技术操作',
time_planning: '📅 时间计划',
communication: '💬 沟通交流',
descriptive_words: '📝 描述词汇',
academic: '🎓 学术词汇',
daily_life: '🏠 日常生活',
emotions: '😊 情感词汇',
nature: '🌿 自然环境',
health: '🏥 健康医疗',
food: '🍽️ 饮食相关',
travel: '✈️ 旅行出行',
science: '🔬 科学技术',
law: '⚖️ 法律词汇',
finance: '💰 金融财务'
};
return map[cat] || cat || '未分类';
}
export function showImportModal() {
showModal('<i class="fas fa-file-import" style="color:var(--primary)"></i> 导入单词', `
<div class="form-group">
<label>选择 JSON 文件</label>
<input type="file" accept=".json" id="import-file" data-action="words.handleImportFile" data-action-event="change" data-element-arg="0">
<p class="hint">支持项目自带的 words-*.json 格式</p>
</div>
<div style="text-align:center;color:var(--text-muted);margin:16px 0">— 或者 —</div>
<div class="form-group">
<label>粘贴 JSON 内容</label>
<textarea id="import-text" placeholder='[{"id":1,"english":"word","chinese":"单词",...}]' rows="6"></textarea>
</div>
`, `
<button class="btn btn-secondary" data-action="words.closeModal">取消</button>
<button class="btn btn-primary" data-action="words.doImport">导入</button>
`);
}
let importFileReadGeneration = 0;
export function handleImportFile(input) {
const generation = ++importFileReadGeneration;
const file = input.files[0];
const target = document.getElementById('import-text');
if (!file || !target) return;
const reader = new FileReader();
reader.onload = e => {
if (generation === importFileReadGeneration && target.isConnected && input.isConnected && document.getElementById('import-text') === target) {
target.value = e.target.result;
}
};
reader.onerror = () => {
if (generation === importFileReadGeneration && target.isConnected) showToast('单词文件读取失败', 'error');
};
reader.readAsText(file);
}
export function doImport() {
const text = document.getElementById('import-text').value.trim();
if (!text) { showToast('请选择文件或粘贴 JSON 内容', 'warning'); return; }
try {
const data = JSON.parse(text);
if (!Array.isArray(data)) throw new Error('数据格式错误');
const valid = data.filter(w => w.english && w.chinese);
if (valid.length === 0) throw new Error('未找到有效单词');
const previousWords = state.words.slice();
const previousSchedule = { ...state.schedule };
const existingKeys = new Set(state.words.map(w => String(w.english || '').trim().toLowerCase()).filter(Boolean));
let added = 0;
let nextId = getNextId();
valid.forEach(raw => {
const key = String(raw.english || '').trim().toLowerCase();
if (!key || existingKeys.has(key)) return;
const word = normalizeImportedWord(raw, nextId++);
state.words.push(word);
existingKeys.add(key);
initWordSchedule(word.id, false);
added++;
});
if (added === 0) {
showToast('没有新单词可导入(全部已存在)', 'info');
return;
}
if (!(saveWords() && saveSchedule())) {
state.words = previousWords;
state.schedule = previousSchedule;
saveWords();
saveSchedule();
throw new Error('本地存储写入失败');
}
closeModal();
showToast(`成功导入 ${added} 个单词(共 ${valid.length} 个,${valid.length - added} 个重复跳过)`, 'success');
renderPage('words');
} catch (e) {
showToast('导入失败:' + e.message, 'error');
}
}
export function exportWords() {
const blob = new Blob([JSON.stringify(state.words, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `words-export-${getToday()}.json`;
a.click();
URL.revokeObjectURL(url);
showToast('导出成功', 'success');
}
export function toggleWordFavorite(wordId) {
if (!toggleFavorite(wordId)) return false;
const faved = isFavorite(wordId);
if (state.wordCategory === '__favorites__' && !faved) {
const row = document.querySelector(`tr[data-word-id="${wordId}"]`);
if (row) {
row.style.opacity = '0.45';
const btn = row.querySelector('.fav-toggle-btn');
if (btn) {
btn.style.color = 'var(--text-muted)';
btn.title = '收藏';
btn.querySelector('i').className = 'far fa-star';
}
return true;
}
}
renderWordList();
return true;
}
export function deleteWord(id) {
if (!confirm('确定要删除这个单词吗?')) return false;
const previousWords = state.words;
const previousSchedule = state.schedule;
const previousRecords = state.records;
const previousFavorites = state.favorites;
state.words = state.words.filter(w => w.id !== id);
state.schedule = { ...state.schedule };
delete state.schedule[id];
state.records = state.records.filter(r => r.wordId !== id);
state.favorites = state.favorites.filter(wordId => wordId !== id);
const saved = saveWords() && saveSchedule() && saveRecords() && saveFavorites();
if (!saved) {
state.words = previousWords;
state.schedule = previousSchedule;
state.records = previousRecords;
state.favorites = previousFavorites;
saveWords();
saveSchedule();
saveRecords();
saveFavorites();
renderWordList();
return false;
}
renderWordList();
showToast('已删除', 'info');
return true;
}
export function showWordDetail(id) {
const w = state.words.find(x => x.id === id);
if (!w) return;
const sch = state.schedule[w.id];
const mastery = getMastery(w.id);
const masteryText = mastery === 'mastered' ? '已掌握' : mastery === 'learning' ? '学习中' : '新词';
const safeEn = escapeHtml(w.english);
showModal(safeEn, `
<div style="text-align:center;margin-bottom:20px">
<div style="font-size:32px;font-weight:700;margin-bottom:6px">${escapeHtml(w.english)}</div>
<div style="color:var(--text-muted);font-size:16px;margin-bottom:8px">${escapeHtml(w.phonetic || '')}</div>
<button class="btn btn-sm btn-ghost" data-action="words.speak" data-arg0="${safeEn}"><i class="fas fa-volume-up"></i> 朗读</button>
<span class="badge ${mastery === 'mastered' ? 'badge-success' : mastery === 'learning' ? 'badge-warning' : 'badge-primary'}">${masteryText}</span>
</div>
<div style="margin-bottom:16px" id="word-detail-chinese-${w.id}">
<strong>中文释义:</strong> <span id="chinese-text-${w.id}">${escapeHtml(w.chinese)}</span>
<button class="btn btn-sm btn-ghost" data-action="words.editWordChinese" data-arg0="${w.id}" title="修改释义" style="margin-left:8px"><i class="fas fa-edit"></i></button>
</div>
${w.forms ? `<div style="margin-bottom:16px"><strong>词形变化:</strong><br>${formatFormsHtml(w.forms)}</div>` : ''}
${w.example ? `
<div style="margin-bottom:16px">
<strong>例句:</strong><br>
<em>${escapeHtml(w.example.en)}</em><br>
<span style="color:var(--text-secondary)">${escapeHtml(w.example.cn)}</span>
</div>` : ''}
${w.sourceContext ? `
<div style="margin-bottom:16px;padding:12px;background:var(--primary-bg);border-radius:var(--radius-sm);border-left:3px solid var(--primary)">
<div style="font-size:12px;color:var(--text-muted);margin-bottom:4px"><i class="fas fa-envelope"></i> 来源邮件</div>
<div style="font-size:13px;color:var(--text-secondary);font-style:italic">"${escapeHtml(w.sourceContext.sentence)}"</div>
</div>` : ''}
${sch ? `
<div style="font-size:13px;color:var(--text-muted);border-top:1px solid var(--border);padding-top:12px;margin-top:12px">
复习阶段:${STAGE_LABELS[sch.stage] || '—'} | 下次复习:${escapeHtml(sch.nextReview || '—')}<br>
正确 ${sch.correctCount} 次 / 错误 ${sch.incorrectCount}
</div>` : ''}
`);
}
export function editWordChinese(id) {
const w = state.words.find(x => x.id === id);
if (!w) return;
const container = document.getElementById(`word-detail-chinese-${id}`);
if (!container) return;
container.innerHTML = `
<strong>中文释义:</strong>
<div style="display:flex;gap:8px;align-items:center;margin-top:6px">
<input type="text" id="edit-chinese-input-${id}" value="${escapeHtml(w.chinese)}" style="flex:1;padding:6px 10px;border:1px solid var(--border);border-radius:var(--radius-sm);font-size:14px;background:var(--card);color:var(--text)">
<button class="btn btn-sm btn-primary" data-action="words.saveWordChinese" data-arg0="${id}"><i class="fas fa-check"></i></button>
<button class="btn btn-sm btn-secondary" data-action="words.showWordDetail" data-arg0="${id}"><i class="fas fa-times"></i></button>
</div>`;
const input = document.getElementById(`edit-chinese-input-${id}`);
if (input) { input.focus(); input.select(); }
}
export function saveWordChinese(id) {
const input = document.getElementById(`edit-chinese-input-${id}`);
if (!input) return;
const newVal = input.value.trim();
if (!newVal) { showToast('释义不能为空', 'warning'); return; }
const w = state.words.find(x => x.id === id);
if (!w) return;
const previousChinese = w.chinese;
w.chinese = newVal;
if (!saveWords()) {
w.chinese = previousChinese;
return;
}
if (state.currentPage === 'words') renderWordList();
showToast('释义已更新', 'success');
showWordDetail(id);
}
+249
View File
@@ -0,0 +1,249 @@
import { state } from '../core/state.js';
import { extractJsonValue, isPlainObject, sanitizeAiQuestion } from './quiz-generator.js';
let aiCooldownUntil = 0;
/**
* AI API client and language helpers.
* Inputs and outputs are plain text or word/question objects. Network calls use
* state.settings and do not write localStorage directly; HTTP 429 responses start
* an in-memory cooldown used by quiz generation.
*/
// ==================== AI API Client ====================
export function getAiCooldownRemaining() {
return Math.max(0, aiCooldownUntil - Date.now());
}
export async function callAI(messages, stream = false) {
const { apiKey, apiUrl, model } = state.settings;
if (!apiKey || !apiUrl || !model) {
throw new Error('请先在设置页面配置 AI 模型信息');
}
const url = apiUrl.replace(/\/$/, '') + '/chat/completions';
const controller = new AbortController();
let timedOut = false;
const timeoutId = setTimeout(() => {
timedOut = true;
controller.abort();
}, 30000);
try {
const res = await fetch(url, {
method: 'POST',
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({
model,
messages,
stream,
temperature: 0.7
})
});
if (stream) clearTimeout(timeoutId);
if (!res.ok) {
const err = await res.text();
let errMsg = '';
try {
const parsed = JSON.parse(err);
const msg = parsed?.error?.message || parsed?.message || '';
errMsg = msg.split('\n')[0];
} catch (_) {
errMsg = err;
}
if (errMsg.length > 80) errMsg = errMsg.slice(0, 80) + '...';
if (res.status === 429) {
aiCooldownUntil = Date.now() + 60000;
}
throw new Error(`API 请求失败 (${res.status}): ${errMsg}`);
}
if (stream) return res;
let data;
try {
data = await res.json();
} catch (error) {
if (timedOut) throw error;
throw new Error('AI 返回的成功响应不是有效 JSON');
}
if (!isPlainObject(data) || !Array.isArray(data.choices) || data.choices.length === 0) {
throw new Error('AI 返回的 choices 结构无效');
}
const choice = data.choices[0];
if (!isPlainObject(choice) || !isPlainObject(choice.message)
|| typeof choice.message.content !== 'string' || !choice.message.content.trim()) {
throw new Error('AI 返回的消息内容结构无效');
}
return choice.message.content;
} catch (error) {
if (timedOut) throw new Error('AI 请求超时(30 秒)');
throw error;
} finally {
clearTimeout(timeoutId);
}
}
export async function generateAIQuiz(words, mode, count) {
const wordList = words.map(w => `${w.english} - ${w.chinese}`).join('\n');
const modeDesc = mode === 'en2zh'
? '英译中(给出英文单词,选出正确的中文意思)'
: '中翻英(给出中文意思,选出正确的英文单词)';
const prompt = `你是英语学习助手。请根据以下单词列表生成 ${count} 道选择题。
模式:${modeDesc}
单词列表:
${wordList}
请严格以JSON格式返回,不要包含任何markdown标记或其他文本,直接返回JSON:
{
"questions": [
{
"question": "题目文本(英文单词或中文意思)",
"options": ["选项A", "选项B", "选项C", "选项D"],
"answer": 0,
"explanation": "简短解析"
}
]
}
要求:
1. 每道题有4个选项,只有1个正确(answer是正确选项的索引0-3)
2. 干扰项要合理但有区分度
3. 从列表中选不同的单词出题
4. 【非常重要】所有4个选项的长度和格式必须高度一致,不能让正确答案明显更长或更短
5. 中文选项只写一个最核心的词义,不要加括号补充、不要加多个义项,例如"放弃"而不是"放弃(某事或某物)"
6. 英文选项只写单词本身,不要附加解释`;
const content = await callAI([
{ role: 'system', content: '你是一个专业的英语教学助手,只返回JSON格式数据。' },
{ role: 'user', content: prompt }
]);
const parsed = extractJsonValue(content, {});
const questions = Array.isArray(parsed?.questions) ? parsed.questions.map(sanitizeAiQuestion).filter(Boolean) : [];
if (questions.length === 0) throw new Error('AI 返回的题目格式无效');
return questions;
}
export async function translateWord(word) {
const results = await translateWords([word]);
if (!results.length) throw new Error('AI 返回的单词数据格式无效');
return results[0];
}
export async function translateWords(words) {
const uniqueWords = [...new Set(words.map(w => String(w || '').trim().toLowerCase()).filter(Boolean))];
if (!uniqueWords.length) return [];
const prompt = `你是英语词典助手。请为以下英文单词返回纯 JSON 数组(不要 markdown)。每个数组元素结构如下:
{
"english": "<英文单词>",
"frequency": <整数词频,常见100/较常见50/一般25/少见10>,
"category": "<high_frequency / medium_frequency / low_frequency>",
"chinese": "<简洁中文释义>",
"phonetic": "<国际音标>",
"forms": { <词性>: "<说明>", "verb": ["第三人称","现在分词","过去式/过去分词"], "plural": "<复数>" },
"example": { "en": "<英文例句>", "cn": "<中文翻译>" }
}
要求:
1. 必须为每个输入单词返回一个对象。
2. english 字段必须使用输入单词本身。
3. 只返回 JSON 数组,不要解释。
单词列表:${uniqueWords.map(w => `"${w}"`).join(', ')}`;
const content = await callAI([
{ role: 'system', content: '你是英语词典助手,只返回纯JSON。' },
{ role: 'user', content: prompt }
]);
const parsed = extractJsonValue(content, null);
const list = Array.isArray(parsed) ? parsed : (isPlainObject(parsed) && Array.isArray(parsed.words) ? parsed.words : []);
const byWord = new Map();
list.forEach(item => {
if (!isPlainObject(item)) return;
const english = String(item.english || '').trim().toLowerCase();
if (english) byWord.set(english, { ...item, english });
});
const results = uniqueWords.map(word => byWord.get(word)).filter(Boolean);
if (!results.length && uniqueWords.length === 1 && list.length === 1 && isPlainObject(list[0])) {
results.push({ ...list[0], english: String(list[0].english || uniqueWords[0]).trim() });
}
if (!results.length) throw new Error('AI 返回的单词数据格式无效');
return results;
}
export async function filterNamesWithAI(words) {
if (!state.settings.apiKey || !state.settings.apiUrl || !state.settings.model) return [];
try {
const content = await callAI([
{ role: 'system', content: '你是一个英语语言分析助手。请从给定的单词列表中识别出所有人名(包括英文名、姓氏、昵称等),只返回纯 JSON 数组。' },
{ role: 'user', content: `以下是从英文邮件中提取的单词列表,请识别其中哪些是人名(名字、姓氏、昵称),返回纯 JSON 数组(不要 markdown),如果没有人名就返回 []。\n\n单词列表:${words.join(', ')}` }
]);
const parsed = extractJsonValue(content, []);
if (Array.isArray(parsed)) {
return parsed.map(n => String(n).toLowerCase());
}
} catch (e) {
console.warn('AI name filtering failed:', e);
}
return [];
}
export function deduplicateBasic(words) {
const wordSet = new Set(words);
const mapping = {};
for (const w of words) {
if (mapping[w]) continue;
const checks = [];
if (w.endsWith('s') && !w.endsWith('ss') && w.length > 3) checks.push(w.slice(0, -1));
if (w.endsWith('es') && w.length > 4) checks.push(w.slice(0, -2));
if (w.endsWith('ies') && w.length > 4) checks.push(w.slice(0, -3) + 'y');
if (w.endsWith('ed') && w.length > 4) { checks.push(w.slice(0, -2)); checks.push(w.slice(0, -1)); }
if (w.endsWith('ing') && w.length > 5) { checks.push(w.slice(0, -3)); checks.push(w.slice(0, -3) + 'e'); }
if (w.endsWith('er') && w.length > 4) { checks.push(w.slice(0, -2)); checks.push(w.slice(0, -1)); }
if (w.endsWith('est') && w.length > 5) { checks.push(w.slice(0, -3)); checks.push(w.slice(0, -2)); }
if (w.endsWith('ly') && w.length > 4) checks.push(w.slice(0, -2));
for (const base of checks) {
if (base !== w && wordSet.has(base) && !mapping[base]) {
mapping[w] = base;
break;
}
}
}
return mapping;
}
export async function deduplicateWithAI(words) {
if (!state.settings.apiKey || !state.settings.apiUrl || !state.settings.model) {
return deduplicateBasic(words);
}
if (words.length === 0) return {};
try {
const content = await callAI([
{ role: 'system', content: '你是英语语言分析助手。从给定单词列表中找出同一个词的不同词形变化(如单复数、时态、比较级等),返回纯 JSON 对象,key 是应移除的变体词,value 是应保留的基本词形。没有重复返回 {}。不要用 markdown。' },
{ role: 'user', content: `分析以下单词,找出词形变化重复(如 chuck/chucks, process/processes, change/changed/changing 等同源词),返回 JSON 对象,key 是变体词,value 是基本形式。\n\n${words.join(', ')}` }
]);
const result = extractJsonValue(content, {});
if (isPlainObject(result)) {
const inputWords = new Set(words.map(word => String(word || '').trim().toLowerCase()).filter(Boolean));
const mapping = {};
for (const [variant, base] of Object.entries(result)) {
const vl = String(variant).trim().toLowerCase();
const bl = String(base).trim().toLowerCase();
if (vl !== bl && inputWords.has(vl) && inputWords.has(bl)) mapping[vl] = bl;
}
return mapping;
}
} catch (e) {
console.warn('AI deduplication failed:', e);
}
return deduplicateBasic(words);
}
+148
View File
@@ -0,0 +1,148 @@
import { EBBINGHAUS_INTERVALS } from '../constants.js';
import { state } from '../core/state.js';
import { MAX_RECORDS, saveRecords, saveSchedule } from '../core/storage.js';
/**
* Ebbinghaus scheduling and study-record helpers. Inputs are word IDs, dates,
* and quiz outcomes; mutations update shared state and persist records:<libraryId>
* and schedule:<libraryId> through the storage service.
*/
// ==================== Ebbinghaus Algorithm ====================
export function toLocalDateStr(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
export function getToday() {
return toLocalDateStr(new Date());
}
export function addDays(dateStr, days) {
const [y, m, day] = dateStr.split('-').map(Number);
const d = new Date(y, m - 1, day);
d.setDate(d.getDate() + days);
return toLocalDateStr(d);
}
export function getDueWords() {
const today = getToday();
const due = [];
for (const [wordId, sch] of Object.entries(state.schedule)) {
if (sch.nextReview <= today) {
const word = state.words.find(w => w.id === Number(wordId));
if (word) due.push({ ...word, schedule: sch });
}
}
return due;
}
export function initWordSchedule(wordId, save = true) {
if (state.schedule[wordId]) return true;
state.schedule[wordId] = {
stage: 0,
nextReview: getToday(),
correctCount: 0,
incorrectCount: 0
};
if (!save || saveSchedule()) return true;
delete state.schedule[wordId];
return false;
}
export function trimRecords(records, maxRecords, keepPerWord) {
if (records.length <= maxRecords) return records;
const selected = new Set();
const counts = new Map();
// 第一轮尽可能为每个单词和记录类型保留最新一条,避免整个组合被裁掉
for (let i = records.length - 1; i >= 0; i--) {
const record = records[i];
const key = `${record.wordId}:${record.type}`;
if (counts.has(key)) continue;
selected.add(i);
counts.set(key, 1);
if (selected.size === maxRecords) break;
}
// 第二轮按时间从新到旧填充剩余容量,每个组合优先保留 keepPerWord 条
for (let i = records.length - 1; i >= 0 && selected.size < maxRecords; i--) {
if (selected.has(i)) continue;
const record = records[i];
const key = `${record.wordId}:${record.type}`;
const count = counts.get(key) || 0;
if (count < keepPerWord) {
selected.add(i);
counts.set(key, count + 1);
}
}
// 组合较少时前两轮可能未填满容量,继续保留其余最新记录,避免一次裁剪丢失大量历史
for (let i = records.length - 1; i >= 0 && selected.size < maxRecords; i--) {
selected.add(i);
}
return [...selected]
.sort((a, b) => a - b)
.map(index => records[index]);
}
export function recordAndUpdateSchedule(wordId, isCorrect, type, quizMode, options = {}) {
const previousRecords = state.records;
const previousSchedule = state.schedule;
const nextRecords = previousRecords.slice();
const previousWordSchedule = previousSchedule[wordId];
const nextWordSchedule = previousWordSchedule ? { ...previousWordSchedule } : {
stage: 0,
nextReview: getToday(),
correctCount: 0,
incorrectCount: 0
};
nextRecords.push({
wordId,
date: getToday(),
time: Date.now(),
isCorrect,
type,
quizMode
});
let recordsToSave = nextRecords;
if (options.removeIncorrectQuizRecords && isCorrect) {
recordsToSave = nextRecords.filter(record => !(
record.wordId === wordId
&& record.type === 'quiz'
&& !record.isCorrect
));
}
if (recordsToSave.length > MAX_RECORDS) {
recordsToSave = trimRecords(recordsToSave, MAX_RECORDS, 10);
}
if (isCorrect) {
nextWordSchedule.correctCount++;
nextWordSchedule.stage = Math.min(nextWordSchedule.stage + 1, EBBINGHAUS_INTERVALS.length - 1);
} else {
nextWordSchedule.incorrectCount++;
nextWordSchedule.stage = Math.max(0, nextWordSchedule.stage - 1);
}
nextWordSchedule.nextReview = addDays(getToday(), EBBINGHAUS_INTERVALS[nextWordSchedule.stage]);
state.records = recordsToSave;
state.schedule = { ...previousSchedule, [wordId]: nextWordSchedule };
const recordsSaved = saveRecords();
const scheduleSaved = saveSchedule();
if (recordsSaved && scheduleSaved) return true;
state.records = previousRecords;
state.schedule = previousSchedule;
saveRecords();
saveSchedule();
return false;
}
+100
View File
@@ -0,0 +1,100 @@
import { COMMON_NAMES, STOP_WORDS } from '../data/stopwords.js';
/**
* Pure English-word extraction helpers. Functions accept text or message arrays,
* return normalized words/frequencies/context, and have no DOM or storage side effects.
*/
export function detectProperNouns(text) {
const lines = text.split(/\n/).map(l => l.trim()).filter(Boolean);
const wordStats = new Map();
for (const line of lines) {
const sentences = line.split(/(?<=[.!?])\s+/);
for (const sentence of sentences) {
const words = sentence.match(/\b[a-zA-Z][a-zA-Z'-]*[a-zA-Z]\b/g) || [];
words.forEach((w, idx) => {
const lower = w.toLowerCase();
if (lower.length < 2 || STOP_WORDS.has(lower)) return;
if (!wordStats.has(lower)) wordStats.set(lower, { cap: 0, low: 0 });
const entry = wordStats.get(lower);
const isFirstWord = idx === 0;
if (w[0] === w[0].toUpperCase() && w[0] !== w[0].toLowerCase()) {
if (!isFirstWord) entry.cap++;
} else {
entry.low++;
}
});
}
}
const proper = new Set();
for (const [word, s] of wordStats) {
if (s.cap > 0 && s.low === 0) proper.add(word);
}
return proper;
}
export function extractEnglishWords(text) {
const cleaned = text
.replace(/https?:\/\/\S+/g, '')
.replace(/[\w.+-]+@[\w.-]+/g, '')
.replace(/<[^>]+>/g, '')
.replace(/[^a-zA-Z\s'-]/g, ' ');
const rawWords = cleaned.match(/\b[a-zA-Z][a-zA-Z'-]*[a-zA-Z]\b|[a-zA-Z]\b/g) || [];
const seen = new Set();
const result = [];
for (const w of rawWords) {
const lower = w.toLowerCase();
if (lower.length < 2) continue;
if (STOP_WORDS.has(lower)) continue;
if (seen.has(lower)) continue;
seen.add(lower);
result.push(lower);
}
return result.sort();
}
export function extractWithFrequency(emails) {
const wordEmailCount = {};
const wordTotalCount = {};
for (const emailText of emails) {
const cleaned = emailText
.replace(/https?:\/\/\S+/g, '')
.replace(/[\w.+-]+@[\w.-]+/g, '')
.replace(/<[^>]+>/g, '')
.replace(/[^a-zA-Z\s'-]/g, ' ');
const rawWords = cleaned.match(/\b[a-zA-Z][a-zA-Z'-]*[a-zA-Z]\b|[a-zA-Z]\b/g) || [];
const seenInEmail = new Set();
for (const w of rawWords) {
const lower = w.toLowerCase();
if (lower.length < 2 || STOP_WORDS.has(lower)) continue;
wordTotalCount[lower] = (wordTotalCount[lower] || 0) + 1;
if (!seenInEmail.has(lower)) {
seenInEmail.add(lower);
wordEmailCount[lower] = (wordEmailCount[lower] || 0) + 1;
}
}
}
const properNouns = new Set();
for (const emailText of emails) {
detectProperNouns(emailText).forEach(n => properNouns.add(n));
}
return Object.keys(wordEmailCount)
.filter(w => !COMMON_NAMES.has(w) && !properNouns.has(w))
.map(w => ({ word: w, emailCount: wordEmailCount[w], totalCount: wordTotalCount[w] }))
.sort((a, b) => b.emailCount - a.emailCount || b.totalCount - a.totalCount);
}
export function findWordContext(word, emailText) {
const sentences = emailText.split(/[.!?;\n]+/).map(s => s.trim()).filter(Boolean);
const regex = new RegExp('\\b' + word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'i');
const match = sentences.find(s => regex.test(s));
return match ? match.slice(0, 120) : null;
}
+27
View File
@@ -0,0 +1,27 @@
import { state } from '../core/state.js';
import { saveFavorites } from '../core/storage.js';
/**
* Favorite-word queries and mutations. Functions read and update shared state;
* mutations persist the active library's favorites:<libraryId> key.
*/
export function isFavorite(wordId) { return state.favorites.includes(wordId); }
export function toggleFavorite(wordId) {
const previousFavorites = state.favorites.slice();
const idx = state.favorites.indexOf(wordId);
if (idx >= 0) {
state.favorites.splice(idx, 1);
} else {
state.favorites.push(wordId);
}
if (!saveFavorites()) {
state.favorites = previousFavorites;
return false;
}
return true;
}
export function getFavoriteWords() {
return state.words.filter(w => state.favorites.includes(w.id));
}
+225
View File
@@ -0,0 +1,225 @@
import { WORD_LIBRARIES } from '../constants.js';
import { state } from '../core/state.js';
import {
getLibraryStorageKey,
loadLibraryState,
restoreStorageItems,
saveFavorites,
saveJsonSetting,
saveRecords,
saveSchedule,
saveStringSetting,
saveWords,
snapshotStorageItems,
STORAGE_KEYS
} from '../core/storage.js';
import { renderPage } from '../core/router.js';
import { showToast } from '../ui/toast.js';
import { initWordSchedule } from './ebbinghaus.js';
import { getNextId, normalizeImportedWord } from '../pages/words.js';
/**
* Word-library loading and switching. Functions fetch data/*.json, update shared
* vocabulary state, and persist activeWordLibrary plus words/schedule library keys;
* failed mutations restore both memory and localStorage snapshots.
*/
// ==================== Auto Load Words ====================
export function getWordLibrary(libraryId = state.activeWordLibrary) {
return WORD_LIBRARIES.find(library => library.id === libraryId) || WORD_LIBRARIES[0];
}
export async function fetchWordLibrary(libraryId) {
const library = getWordLibrary(libraryId);
const url = new URL(`../../../data/${library.file}`, import.meta.url);
const controller = new AbortController();
let timedOut = false;
const timeoutId = setTimeout(() => {
timedOut = true;
controller.abort();
}, 15000);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (!Array.isArray(data) || data.length === 0) throw new Error('词库内容为空');
const valid = data.filter(word => word && word.english && word.chinese);
if (valid.length === 0) throw new Error('词库中没有有效单词');
return valid;
} catch (error) {
const message = timedOut ? '请求超时(15 秒)' : error.message;
throw new Error(`无法加载${library.name}${message}`);
} finally {
clearTimeout(timeoutId);
}
}
export async function switchWordLibrary() {
const select = document.getElementById('word-library-select');
const libraryId = select ? select.value : state.activeWordLibrary;
if (libraryId === state.activeWordLibrary) {
showToast('当前已是所选词库', 'info');
return;
}
const library = getWordLibrary(libraryId);
const previousLibraryId = state.activeWordLibrary;
const requestedLibraryId = library.id;
const memorySnapshot = {
words: state.words,
records: state.records,
schedule: state.schedule,
favorites: state.favorites
};
const storageSnapshot = snapshotStorageItems([
getLibraryStorageKey(STORAGE_KEYS.words, requestedLibraryId),
getLibraryStorageKey(STORAGE_KEYS.records, requestedLibraryId),
getLibraryStorageKey(STORAGE_KEYS.schedule, requestedLibraryId),
getLibraryStorageKey(STORAGE_KEYS.favorites, requestedLibraryId),
STORAGE_KEYS.activeWordLibrary
]);
if (!storageSnapshot) {
showToast('无法读取本地存储,词库切换已取消', 'error');
return;
}
const rollback = () => {
if (!restoreStorageItems(storageSnapshot)) console.error('Failed to restore word library storage');
state.activeWordLibrary = previousLibraryId;
Object.assign(state, memorySnapshot);
};
state.activeWordLibrary = requestedLibraryId;
loadLibraryState(requestedLibraryId);
try {
if (state.words.length === 0) {
const words = await fetchWordLibrary(requestedLibraryId);
if (state.activeWordLibrary !== requestedLibraryId) return;
const seenEn = new Set();
const uniqueWords = words.filter(word => {
const lower = String(word.english).trim().toLowerCase();
if (seenEn.has(lower)) return false;
seenEn.add(lower);
return true;
});
state.words = uniqueWords.map((word, index) => normalizeImportedWord(word, index + 1));
state.records = [];
state.schedule = {};
state.favorites = [];
state.words.forEach(word => initWordSchedule(word.id, false));
if (!saveWords() || !saveRecords() || !saveSchedule() || !saveFavorites()) throw new Error('词库数据保存失败,已恢复原词库');
}
if (state.activeWordLibrary !== requestedLibraryId) return;
if (!saveStringSetting(STORAGE_KEYS.activeWordLibrary, requestedLibraryId)) {
throw new Error('词库切换未保存,已恢复原词库');
}
} catch (error) {
if (state.activeWordLibrary !== requestedLibraryId) return;
try {
rollback();
} catch (rollbackError) {
console.error('Failed to roll back word library switch', rollbackError);
state.activeWordLibrary = previousLibraryId;
Object.assign(state, memorySnapshot);
}
showToast(error.message, 'error');
renderPage('settings');
return;
}
state.learnSession = null;
state.quizSession = null;
state.wordPage = 1;
state.wordSearch = '';
state.wordCategory = '';
showToast(`已切换到${library.name},共 ${state.words.length} 个单词`, 'success');
renderPage('settings');
}
export function toggleAutoLoad(enabled) {
const nextEnabled = !!enabled;
if (!saveJsonSetting(STORAGE_KEYS.autoLoadEnabled, nextEnabled)) {
if (state.currentPage === 'settings') renderPage('settings');
return false;
}
state.autoLoadEnabled = nextEnabled;
showToast(nextEnabled ? '已开启自动加载' : '已关闭自动加载', 'info');
if (state.currentPage === 'settings') renderPage('settings');
return true;
}
export async function manualLoadWords() {
await doLoadWords(true);
}
export async function autoLoadWords() {
if (!state.autoLoadEnabled) return;
await doLoadWords(false);
}
export async function doLoadWords(force) {
if (!force && state.words.length > 0) return;
const libraryId = state.activeWordLibrary;
let memorySnapshot;
let storageSnapshot;
try {
const valid = await fetchWordLibrary(libraryId);
if (state.activeWordLibrary !== libraryId) return;
memorySnapshot = {
words: state.words.slice(),
records: state.records.slice(),
schedule: { ...state.schedule },
favorites: state.favorites.slice()
};
storageSnapshot = snapshotStorageItems([
getLibraryStorageKey(STORAGE_KEYS.words, libraryId),
getLibraryStorageKey(STORAGE_KEYS.records, libraryId),
getLibraryStorageKey(STORAGE_KEYS.schedule, libraryId),
getLibraryStorageKey(STORAGE_KEYS.favorites, libraryId)
]);
if (!storageSnapshot) throw new Error('无法读取本地存储');
if (state.words.length === 0) {
state.records = [];
state.schedule = {};
state.favorites = [];
}
const existingEn = new Set(state.words.map(word => word.english.toLowerCase()));
let added = 0;
let nextId = getNextId();
valid.forEach(rawWord => {
const lower = rawWord.english.toLowerCase();
if (existingEn.has(lower)) return;
const word = normalizeImportedWord(rawWord, nextId++);
state.words.push(word);
initWordSchedule(word.id, false);
existingEn.add(lower);
added++;
});
if (added > 0) {
if (!saveWords() || !saveRecords() || !saveSchedule() || !saveFavorites()) throw new Error('词库数据保存失败,已恢复加载前状态');
showToast(`${force ? '手动' : '自动'}加载了 ${added} 个单词${valid.length - added > 0 ? `${valid.length - added} 个重复跳过)` : ''}`, 'success');
renderPage(state.currentPage);
} else if (force) {
showToast('没有新单词可加载(全部已存在)', 'info');
}
} catch (error) {
if (state.activeWordLibrary !== libraryId) return;
if (memorySnapshot) {
state.words = memorySnapshot.words;
state.records = memorySnapshot.records;
state.schedule = memorySnapshot.schedule;
state.favorites = memorySnapshot.favorites;
}
if (storageSnapshot) {
try {
restoreStorageItems(storageSnapshot);
} catch (rollbackError) {
console.error('Failed to roll back loaded words', rollbackError);
}
}
if (force) showToast(error.message, 'warning');
}
}
+168
View File
@@ -0,0 +1,168 @@
/**
* Pure MIME and EML decoding helpers. Inputs are raw strings or byte buffers;
* outputs are decoded text. The module has no DOM, network, or storage side effects.
*/
export function createTextDecoder(charset = 'utf-8') {
try {
return new TextDecoder(charset);
} catch (_) {
return new TextDecoder('utf-8');
}
}
function toBytePreservingString(input) {
if (typeof input === 'string') return input;
const bytes = input instanceof Uint8Array
? input
: input instanceof ArrayBuffer
? new Uint8Array(input)
: ArrayBuffer.isView(input)
? new Uint8Array(input.buffer, input.byteOffset, input.byteLength)
: new Uint8Array();
let result = '';
const chunkSize = 0x8000;
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
result += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
}
return result;
}
function byteStringToBytes(text) {
return Uint8Array.from(text, char => char.charCodeAt(0) & 0xff);
}
function decodeUnencodedBody(text, charset) {
// 未编码正文(7bit/8bit):先把「每字符 = 一个字节」的字节串按 charset 解码;
// 若声明 UTF-8 却解出替换符 U+FFFD,说明 text 实际上已是解析好的 JS 字符串(而非原始
// 字节串),下面两种情况据此把它重新编码为 UTF-8 字节后再解码:
// 情况一:text 含真正的 Unicode 字符(码点 > 0xFF),必然不是字节串,直接重编码;
// 情况二:text 仅含高位 Latin-1 字节(0x80-0xFF),重编码后不再出现替换符时才采用。
const decoder = createTextDecoder(charset);
const decoded = decoder.decode(byteStringToBytes(text));
if (/^utf-?8$/i.test(charset) && decoded.includes('\ufffd') && /[^\u0000-\u00ff]/.test(text)) {
return decoder.decode(new TextEncoder().encode(text));
}
if (/^utf-?8$/i.test(charset) && decoded.includes('\ufffd') && /[\u0080-\u00ff]/.test(text)) {
const encoded = new TextEncoder().encode(text);
const retry = decoder.decode(encoded);
if (!retry.includes('\ufffd')) return retry;
}
return decoded;
}
export function decodeQuotedPrintable(text, charset) {
const unfolded = text.replace(/=\r?\n/g, '');
const bytes = [];
for (let index = 0; index < unfolded.length; index++) {
if (unfolded[index] === '=' && /^[0-9A-Fa-f]{2}$/.test(unfolded.slice(index + 1, index + 3))) {
bytes.push(Number.parseInt(unfolded.slice(index + 1, index + 3), 16));
index += 2;
} else {
bytes.push(unfolded.charCodeAt(index) & 0xff);
}
}
return createTextDecoder(charset).decode(Uint8Array.from(bytes));
}
export function decodeBase64(text, charset) {
try {
const binary = atob(text.replace(/\s+/g, ''));
return createTextDecoder(charset).decode(byteStringToBytes(binary));
} catch (_) {
return text;
}
}
export function splitMimeSection(section) {
const separatorIndex = section.search(/\r?\n\r?\n/);
if (separatorIndex < 0) return { headers: section, body: '' };
const separator = section.slice(separatorIndex).match(/^\r?\n\r?\n/)[0];
return {
headers: section.slice(0, separatorIndex),
body: section.slice(separatorIndex + separator.length)
};
}
export function decodeBasicEntities(text) {
return text.replace(/&(nbsp|amp|lt|gt|quot|apos|#(?:\d+|x[0-9a-f]+));/gi, (match, entity) => {
const normalized = entity.toLowerCase();
const entities = { nbsp: ' ', amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
if (normalized in entities) return entities[normalized];
const isHex = normalized.startsWith('#x');
const codePoint = Number.parseInt(normalized.slice(isHex ? 2 : 1), isHex ? 16 : 10);
try {
return String.fromCodePoint(codePoint);
} catch (_) {
return match;
}
});
}
export function decodeMimePart(part) {
const unfoldedHeaders = part.headers.replace(/\r?\n[ \t]+/g, ' ');
const charsetMatch = unfoldedHeaders.match(/charset\s*=\s*(?:"([^"]+)"|([^;\s]+))/i);
const charset = charsetMatch ? (charsetMatch[1] || charsetMatch[2]) : 'utf-8';
let body;
if (/content-transfer-encoding:\s*quoted-printable/i.test(unfoldedHeaders)) {
body = decodeQuotedPrintable(part.body, charset);
} else if (/content-transfer-encoding:\s*base64/i.test(unfoldedHeaders)) {
body = decodeBase64(part.body, charset);
} else {
body = decodeUnencodedBody(part.body, charset);
}
if (/content-type:\s*text\/html\b/i.test(unfoldedHeaders)) {
body = body.replace(/<(script|style|template|noscript)\b[^>]*>[\s\S]*?(?:<\/\1\s*>|$)/gi, ' ');
body = decodeBasicEntities(body.replace(/<[^>]+>/g, ' '));
}
return body;
}
export function extractMimeText(section, depth = 0) {
const unfoldedHeaders = section.headers.replace(/\r?\n[ \t]+/g, ' ');
const isMultipart = /content-type:\s*multipart\//i.test(unfoldedHeaders);
if (!isMultipart) return decodeMimePart(section);
if (depth >= 5) return '';
const boundaryMatch = unfoldedHeaders.match(/boundary\s*=\s*(?:"([^"]+)"|([^;\s]+))/i);
const boundary = boundaryMatch && (boundaryMatch[1] || boundaryMatch[2]);
if (!boundary) return '';
const escapedBoundary = boundary.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const parts = section.body
.split(new RegExp(`(?:^|\\r?\\n)--${escapedBoundary}(?:--)?[ \\t]*(?=\\r?\\n|$)`))
.map(part => part.replace(/^\r?\n/, '').replace(/\r?\n$/, ''))
.filter(part => part.trim() && part.trim() !== '--')
.map(splitMimeSection);
const partHeaders = item => item.headers.replace(/\r?\n[ \t]+/g, ' ');
const isType = (item, typeRe) => typeRe.test(partHeaders(item));
const hasContentType = item => /(?:^|\s)content-type\s*:/i.test(partHeaders(item));
const isPlainText = item => !hasContentType(item) || isType(item, /content-type:\s*text\/plain\b/i);
const candidates = parts.filter(item => !/content-disposition:\s*attachment\b/i.test(partHeaders(item)));
if (/content-type:\s*multipart\/alternative\b/i.test(unfoldedHeaders)) {
const preferredCandidates = [
...candidates.filter(isPlainText),
...candidates.filter(item => isType(item, /content-type:\s*text\/html\b/i)),
...candidates.filter(item => isType(item, /content-type:\s*multipart\//i))
];
for (const candidate of preferredCandidates) {
const body = extractMimeText(candidate, depth + 1);
if (body.trim()) return body;
}
return '';
}
for (const candidate of candidates) {
if (!isPlainText(candidate) && !isType(candidate, /content-type:\s*(?:text\/html|multipart\/)/i)) continue;
const body = extractMimeText(candidate, depth + 1);
if (body.trim()) return body;
}
return '';
}
export function extractEmlBody(input) {
return extractMimeText(splitMimeSection(toBytePreservingString(input)));
}
+121
View File
@@ -0,0 +1,121 @@
import { state } from '../core/state.js';
/**
* Pure quiz generation and AI-response validation helpers. Inputs and outputs
* are arrays/plain objects; the module reads vocabulary from state when needed
* and has no DOM, network, or localStorage side effects.
*/
// ==================== Local Quiz Generator ====================
export function shuffle(arr) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
export function generateLocalQuiz(mode, count, wordPool, order) {
const words = wordPool || state.words;
if (words.length < 4) return [];
const allWords = state.words.length >= 4 ? state.words : words;
const ordered = order === 'order'
? [...words].sort((a, b) => a.id - b.id)
: shuffle(words);
const targetCount = Math.min(count, words.length);
// 干扰项按当前模式做文本去重,避免出现与正确答案释义相同的选项(同义词库常见)
// textOf 容错缺失字段(脏数据),避免对 undefined 调用 .trim() 导致本地出题整体崩溃
const textOf = w => String((mode === 'en2zh' ? w.chinese : w.english) || '').trim();
const questions = [];
for (const word of ordered) {
if (questions.length >= targetCount) break;
const correctText = textOf(word);
if (!correctText) continue;
const usedTexts = new Set([correctText]);
const others = [];
for (const o of shuffle(allWords)) {
if (o.id === word.id) continue;
const t = textOf(o);
if (!t || usedTexts.has(t)) continue;
usedTexts.add(t);
others.push(o);
if (others.length === 3) break;
}
// 同义词过多或脏数据导致凑不齐 3 个有效干扰项时跳过该词,避免生成少于四项的无效题目
if (others.length < 3) continue;
let question, tagged;
if (mode === 'en2zh') {
question = word.english;
tagged = [
{ text: word.chinese, correct: true },
...others.map(o => ({ text: o.chinese, correct: false }))
];
} else {
question = word.chinese;
tagged = [
{ text: word.english, correct: true },
...others.map(o => ({ text: o.english, correct: false }))
];
}
const shuffled = shuffle(tagged);
questions.push({
question,
phonetic: mode === 'en2zh' ? word.phonetic : null,
options: shuffled.map(t => t.text),
answer: shuffled.findIndex(t => t.correct),
wordId: word.id,
word
});
}
return questions;
}
export function extractJsonValue(content, fallback = null) {
const cleaned = String(content || '').replace(/```json?\n?/gi, '').replace(/```/g, '').trim();
try {
return JSON.parse(cleaned);
} catch (_) {
const firstObject = cleaned.indexOf('{');
const lastObject = cleaned.lastIndexOf('}');
const firstArray = cleaned.indexOf('[');
const lastArray = cleaned.lastIndexOf(']');
const candidates = [];
if (firstObject >= 0 && lastObject > firstObject) candidates.push(cleaned.slice(firstObject, lastObject + 1));
if (firstArray >= 0 && lastArray > firstArray) candidates.push(cleaned.slice(firstArray, lastArray + 1));
for (const candidate of candidates) {
try { return JSON.parse(candidate); } catch (_) { /* try next */ }
}
return fallback;
}
}
export function isPlainObject(value) {
if (value === null || typeof value !== 'object') return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
export function sanitizeAiQuestion(q) {
if (!isPlainObject(q) || !Array.isArray(q.options) || q.options.length !== 4) return null;
const question = typeof q.question === 'string' ? q.question.trim() : '';
const options = q.options.map(option => typeof option === 'string' ? option.trim() : '');
if (!question || options.some(option => !option) || new Set(options).size !== 4) return null;
const answer = Number(q.answer);
if (!Number.isInteger(answer) || answer < 0 || answer > 3) return null;
return {
...q,
question,
options,
answer,
explanation: String(q.explanation || '').trim()
};
}
+116
View File
@@ -0,0 +1,116 @@
import { EBBINGHAUS_INTERVALS } from '../constants.js';
import { state } from '../core/state.js';
import { toLocalDateStr } from './ebbinghaus.js';
/**
* Derived learning statistics. Functions read words, records, schedule, and
* favorites from shared state, return computed values, and perform no storage writes.
*/
let errorWordsCache = null;
export function invalidateErrorWordsCache() { errorWordsCache = null; }
export function getMastery(wordId) {
const sch = state.schedule[wordId];
if (!sch) return 'new';
if (sch.stage >= EBBINGHAUS_INTERVALS.length - 1) return 'mastered';
if (sch.stage >= 3) return 'learning';
if (sch.correctCount > 0) return 'learning';
return 'new';
}
export function getStats() {
const total = state.words.length;
let mastered = 0, learning = 0, newCount = 0;
state.words.forEach(w => {
const m = getMastery(w.id);
if (m === 'mastered') mastered++;
else if (m === 'learning') learning++;
else newCount++;
});
const due = getQuizErrorWords().length;
return { total, mastered, learning, newCount, due };
}
export function getQuizErrorWords() {
if (errorWordsCache) return [...errorWordsCache];
const wordById = new Map(state.words.map(w => [w.id, w]));
const wordStats = {};
state.records.filter(r => r.type === 'quiz').forEach(r => {
if (!wordStats[r.wordId]) wordStats[r.wordId] = { correct: 0, incorrect: 0 };
if (r.isCorrect) wordStats[r.wordId].correct++;
else wordStats[r.wordId].incorrect++;
});
errorWordsCache = Object.entries(wordStats)
.filter(([, s]) => s.incorrect > 0)
.map(([id, stats]) => {
const word = wordById.get(Number(id));
if (!word) return null;
const total = stats.correct + stats.incorrect;
const rate = total > 0 ? Math.round(stats.correct / total * 100) : 0;
return { ...word, quizStats: stats, correctRate: rate };
})
.filter(Boolean)
.sort((a, b) => a.correctRate - b.correctRate);
return [...errorWordsCache];
}
export function getStreak() {
const dates = [...new Set(state.records.map(r => r.date))].sort().reverse();
if (dates.length === 0) return 0;
let streak = 0;
let check = new Date();
for (let i = 0; i < 365; i++) {
const dateStr = toLocalDateStr(check);
if (dates.includes(dateStr)) {
streak++;
} else if (i > 0) {
break;
}
check.setDate(check.getDate() - 1);
}
return streak;
}
export function getLast30DaysData() {
const data = [];
const today = new Date();
for (let i = 29; i >= 0; i--) {
const d = new Date(today);
d.setDate(d.getDate() - i);
const dateStr = toLocalDateStr(d);
const count = state.records.filter(r => r.date === dateStr).length;
data.push({
date: dateStr,
label: `${d.getMonth() + 1}/${d.getDate()}`,
count
});
}
return data;
}
export function getErrorTopWords(n) {
const wordStats = {};
state.records.forEach(r => {
if (!wordStats[r.wordId]) wordStats[r.wordId] = { correct: 0, total: 0 };
wordStats[r.wordId].total++;
if (r.isCorrect) wordStats[r.wordId].correct++;
});
return Object.entries(wordStats)
.filter(([, s]) => s.total >= 1)
.map(([id, s]) => {
const w = state.words.find(w => w.id === Number(id));
if (!w) return null;
return {
...w,
rate: Math.round(s.correct / s.total * 100),
total: s.total
};
})
.filter(Boolean)
.sort((a, b) => a.rate - b.rate)
.slice(0, n);
}
+474
View File
@@ -0,0 +1,474 @@
import { state } from '../core/state.js';
import { showToast } from '../ui/toast.js';
/**
* Text-to-speech service with Azure, Youdao, and Web Speech fallbacks.
* Public calls accept text and return playback success. The service reads TTS
* configuration from state.settings, performs network/audio side effects, keeps
* only in-memory caches, and never writes localStorage.
*/
// ==================== TTS (Text-to-Speech) ====================
let currentAudio = null;
let ttsObjectUrl = null;
let ttsFetchController = null;
let cloudTtsAuthBlockedUntil = 0;
let ttsRequestSeq = 0;
export function resetCloudTtsAuthBlock() {
cloudTtsAuthBlockedUntil = 0;
}
export const CLOUD_TTS_CACHE_LIMIT = 50;
export const CLOUD_TTS_PREFETCH_CONCURRENCY = 4;
export const YOUDAO_TTS_PREFETCH_LIMIT = 50;
export const QUIZ_AUDIO_PREFETCH_WINDOW_SIZE = 50;
export const QUIZ_AUDIO_PREFETCH_NEXT_THRESHOLD = 1;
const cloudTtsCache = new Map();
const cloudTtsInflight = new Map();
const youdaoTtsPrefetchCache = new Map();
function abortCurrentTtsFetch() {
if (!ttsFetchController) return;
const controller = ttsFetchController;
ttsFetchController = null;
for (const [cacheKey, entry] of cloudTtsInflight.entries()) {
if (entry.controller === controller) {
clearTimeout(entry.timeoutId);
cloudTtsInflight.delete(cacheKey);
}
}
controller.abort();
}
function stopCurrentAudioPlayback() {
if (currentAudio) {
currentAudio.pause();
currentAudio.currentTime = 0;
currentAudio = null;
}
if (ttsObjectUrl) {
URL.revokeObjectURL(ttsObjectUrl);
ttsObjectUrl = null;
}
}
function stopCurrentPlayback() {
abortCurrentTtsFetch();
stopCurrentAudioPlayback();
if ('speechSynthesis' in window) window.speechSynthesis.cancel();
}
export function stopAllAudio() {
ttsRequestSeq++;
stopCurrentPlayback();
}
export function clearCloudTtsCache() {
cloudTtsCache.clear();
}
export function sanitizeAzureSpeechKey(value) {
return sanitizeAzureSpeechKeyValue(value);
}
export function getTtsSettings() {
return readTtsSettings();
}
function sanitizeAzureSpeechKeyValue(value) {
return String(value || '')
.replace(/```(?:\w+)?/g, '')
.replace(/[`'"\s]/g, '')
.trim();
}
function readTtsSettings() {
const s = state.settings || {};
return {
enabled: s.ttsEnabled !== false,
provider: s.ttsProvider || 'azure',
apiKey: sanitizeAzureSpeechKeyValue(s.ttsApiKey),
endpoint: (s.ttsEndpoint || '').trim().replace(/\/$/, ''),
region: (s.ttsRegion || '').trim().toLowerCase(),
voice: (s.ttsVoice || 'en-US-JennyNeural').trim(),
outputFormat: (s.ttsOutputFormat || 'audio-24khz-48kbitrate-mono-mp3').trim()
};
}
function normalizeAzureTtsEndpoint(endpoint, region) {
let cleanEndpoint = (endpoint || '').trim().replace(/\/$/, '');
cleanEndpoint = cleanEndpoint.replace(/\.stt\.speech\.microsoft\.com/i, '.tts.speech.microsoft.com');
if (cleanEndpoint) {
return /\/cognitiveservices\/v1$/i.test(cleanEndpoint) ? cleanEndpoint : `${cleanEndpoint}/cognitiveservices/v1`;
}
return region ? `https://${region}.tts.speech.microsoft.com/cognitiveservices/v1` : '';
}
function escapeXml(str) {
return String(str == null ? '' : str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function voiceLangFromName(voice) {
const m = String(voice || '').match(/^([a-z]{2}-[A-Z]{2})-/);
return m ? m[1] : 'en-US';
}
function buildAzureSsml(text, voice, rate) {
const lang = voiceLangFromName(voice);
const pct = Math.round((rate - 1) * 100);
const rateAttr = pct === 0 ? '' : ` rate="${pct > 0 ? '+' : ''}${pct}%"`;
return `<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="${lang}"><voice xml:lang="${lang}" name="${escapeXml(voice)}"><prosody${rateAttr}>${escapeXml(text)}</prosody></voice></speak>`;
}
function isAzureTtsAuthError(status) {
return status === 401 || status === 403;
}
function azureTtsErrorMessage(status, body, cfg) {
const endpoint = normalizeAzureTtsEndpoint(cfg.endpoint, cfg.region);
const detail = String(body || '').trim().slice(0, 300);
if (status === 401) {
return `Azure TTS 认证失败 (401):请确认 Speech API Key 属于 ${cfg.region || '当前'} 区域,并且没有复制到多余空格、引号或 Markdown 标记。当前请求地址:${endpoint}`;
}
if (status === 403) {
return `Azure TTS 无访问权限 (403):请检查 Speech 资源网络访问权限、订阅状态,以及当前区域 ${cfg.region || ''} 是否可用。当前请求地址:${endpoint}`;
}
return detail || `Azure TTS 请求失败 (${status})`;
}
function getCloudTtsCache(cacheKey) {
if (!cloudTtsCache.has(cacheKey)) return null;
const blob = cloudTtsCache.get(cacheKey);
cloudTtsCache.delete(cacheKey);
cloudTtsCache.set(cacheKey, blob);
return blob;
}
function setCloudTtsCache(cacheKey, blob) {
cloudTtsCache.set(cacheKey, blob);
while (cloudTtsCache.size > CLOUD_TTS_CACHE_LIMIT) {
const oldestKey = cloudTtsCache.keys().next().value;
cloudTtsCache.delete(oldestKey);
}
}
function setYoudaoTtsPrefetchCache(cacheKey, audio) {
youdaoTtsPrefetchCache.set(cacheKey, audio);
while (youdaoTtsPrefetchCache.size > YOUDAO_TTS_PREFETCH_LIMIT) {
const oldestKey = youdaoTtsPrefetchCache.keys().next().value;
youdaoTtsPrefetchCache.delete(oldestKey);
}
}
function getCloudTtsCacheKey(text, rate, cfg = readTtsSettings()) {
const endpoint = normalizeAzureTtsEndpoint(cfg.endpoint, cfg.region);
if (!cfg.enabled || cfg.provider !== 'azure' || !cfg.apiKey || !endpoint) return null;
return [cfg.provider, endpoint, cfg.voice, cfg.outputFormat, rate, text].join('\n');
}
async function fetchCloudTtsBlob(text, rate = 1, { abortCurrent = false } = {}) {
const cfg = readTtsSettings();
if (cloudTtsAuthBlockedUntil > Date.now()) return null;
const endpoint = normalizeAzureTtsEndpoint(cfg.endpoint, cfg.region);
const cacheKey = getCloudTtsCacheKey(text, rate, cfg);
if (!cacheKey || !endpoint) return null;
const cached = getCloudTtsCache(cacheKey);
if (cached) return cached;
if (abortCurrent) {
abortCurrentTtsFetch();
}
const existing = cloudTtsInflight.get(cacheKey);
if (existing) {
if (abortCurrent) {
ttsFetchController = existing.controller;
}
return existing.promise;
}
const controller = new AbortController();
let timedOut = false;
const timeoutId = setTimeout(() => {
timedOut = true;
controller.abort();
}, 15000);
if (abortCurrent) {
ttsFetchController = controller;
}
const request = (async () => {
try {
const res = await fetch(endpoint, {
method: 'POST',
signal: controller.signal,
headers: {
'Ocp-Apim-Subscription-Key': cfg.apiKey,
'Content-Type': 'application/ssml+xml',
'X-Microsoft-OutputFormat': cfg.outputFormat
},
body: buildAzureSsml(text, cfg.voice, rate)
});
if (!res.ok) {
const err = await res.text().catch(() => '');
if (isAzureTtsAuthError(res.status)) {
cloudTtsAuthBlockedUntil = Date.now() + 5 * 60 * 1000;
}
throw new Error(azureTtsErrorMessage(res.status, err, cfg));
}
resetCloudTtsAuthBlock();
const blob = await res.blob();
setCloudTtsCache(cacheKey, blob);
return blob;
} catch (error) {
if (timedOut) {
const timeoutError = new Error('Azure TTS 请求超时(15 秒)');
timeoutError.name = 'TimeoutError';
throw timeoutError;
}
throw error;
} finally {
clearTimeout(timeoutId);
if (ttsFetchController === controller) {
ttsFetchController = null;
}
const existing = cloudTtsInflight.get(cacheKey);
if (existing?.promise === request) {
cloudTtsInflight.delete(cacheKey);
}
}
})();
cloudTtsInflight.set(cacheKey, { promise: request, controller, timeoutId });
return request;
}
async function speakCloudTts(text, rate = 1, requestSeq = ttsRequestSeq) {
stopCurrentAudioPlayback();
let blob = await fetchCloudTtsBlob(text, rate, { abortCurrent: true });
if (!blob) return false;
if (requestSeq !== ttsRequestSeq) return false;
const objectUrl = URL.createObjectURL(blob);
ttsObjectUrl = objectUrl;
const audio = new Audio(objectUrl);
currentAudio = audio;
const cleanupAudio = () => {
if (ttsObjectUrl === objectUrl) {
URL.revokeObjectURL(objectUrl);
ttsObjectUrl = null;
}
if (currentAudio === audio) {
currentAudio = null;
}
};
try {
await new Promise((resolve, reject) => {
let settled = false;
const settle = (fn, value) => {
if (settled) return;
settled = true;
fn(value);
};
audio.addEventListener('ended', () => settle(resolve, true), { once: true });
audio.addEventListener('pause', () => settle(resolve, false), { once: true });
audio.addEventListener('error', () => settle(reject, new Error('Azure TTS 音频播放失败')), { once: true });
const playPromise = audio.play();
if (playPromise) playPromise.catch(err => settle(reject, err));
});
return true;
} finally {
cleanupAudio();
}
}
function speakYoudao(text, rate = 1) {
ttsLog('youdao TTS for:', text);
stopCurrentPlayback();
const cacheKey = `youdao\n${text}`;
let audio = youdaoTtsPrefetchCache.get(cacheKey);
if (audio) {
youdaoTtsPrefetchCache.delete(cacheKey);
audio.currentTime = 0;
} else {
audio = new Audio();
audio.preload = 'auto';
audio.src = `https://dict.youdao.com/dictvoice?audio=${encodeURIComponent(text)}&type=0`;
}
if (rate !== 1) audio.playbackRate = rate;
currentAudio = audio;
const cleanupAudio = () => {
if (currentAudio === audio) {
currentAudio = null;
}
};
return new Promise((resolve) => {
let settled = false;
const settle = (value) => {
if (settled) return;
settled = true;
cleanupAudio();
resolve(value);
};
audio.addEventListener('ended', () => {
ttsLog('youdao ended');
settle(true);
}, { once: true });
audio.addEventListener('pause', () => {
ttsLog('youdao paused');
settle(false);
}, { once: true });
audio.addEventListener('error', (e) => {
ttsLog('youdao audio error', e);
settle(false);
}, { once: true });
const p = audio.play();
if (p) {
p.then(() => {
ttsLog('youdao playing OK');
}).catch((err) => {
ttsLog('youdao play() rejected', err && err.name);
settle(false);
});
}
});
}
export async function prefetchAudio(input, rate = 0.92, { allowCloud = false } = {}) {
const texts = (Array.isArray(input) ? input : [input])
.map(text => String(text || '').trim())
.filter(Boolean);
const uniqueTexts = [...new Set(texts)];
if (!uniqueTexts.length) return 0;
const cfg = readTtsSettings();
const cloudPrefetchEnabled = state.settings?.ttsPrefetchEnabled === true;
if (allowCloud && cloudPrefetchEnabled && cfg.enabled && cfg.provider === 'azure' && cfg.apiKey) {
let cursor = 0;
let successCount = 0;
const workers = Array.from(
{ length: Math.min(CLOUD_TTS_PREFETCH_CONCURRENCY, uniqueTexts.length) },
async () => {
while (cursor < uniqueTexts.length) {
const text = uniqueTexts[cursor++];
try {
const blob = await fetchCloudTtsBlob(text, rate);
if (blob) successCount++;
} catch (err) {
if (err && err.name !== 'AbortError') ttsLog('cloud TTS prefetch failed', err);
}
}
}
);
await Promise.all(workers);
return successCount;
}
let successCount = 0;
uniqueTexts.forEach(text => {
const cacheKey = `youdao\n${text}`;
if (youdaoTtsPrefetchCache.has(cacheKey)) {
successCount++;
return;
}
try {
const audio = new Audio();
audio.preload = 'auto';
audio.src = `https://dict.youdao.com/dictvoice?audio=${encodeURIComponent(text)}&type=0`;
setYoudaoTtsPrefetchCache(cacheKey, audio);
audio.load();
successCount++;
} catch (err) {
ttsLog('youdao TTS prefetch failed', err);
}
});
return successCount;
}
const TTS_DEBUG = false;
export function logTts() { if (TTS_DEBUG) console.log('[TTS]', ...arguments); }
function ttsLog() { logTts(...arguments); }
function speakWebSpeech(text, rate = 1, requestSeq = ttsRequestSeq) {
if (!('speechSynthesis' in window) || typeof SpeechSynthesisUtterance === 'undefined') return Promise.resolve(false);
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(String(text || ''));
utterance.lang = 'en-US';
utterance.rate = rate;
return new Promise(resolve => {
let settled = false;
const settle = value => {
if (settled) return;
settled = true;
resolve(value && requestSeq === ttsRequestSeq);
};
utterance.onend = () => settle(true);
utterance.onerror = () => settle(false);
window.speechSynthesis.speak(utterance);
});
}
async function speakWithFallback(text, cloudRate, youdaoRate) {
const requestSeq = ++ttsRequestSeq;
ttsLog('speak() called with:', JSON.stringify(text));
try {
const cloudPlayed = await speakCloudTts(text, cloudRate, requestSeq);
if (requestSeq !== ttsRequestSeq) return false;
if (cloudPlayed) return true;
} catch (err) {
if (requestSeq !== ttsRequestSeq || (err && err.name === 'AbortError')) return false;
ttsLog('cloud TTS failed -> youdao fallback', err);
if (/Azure TTS (认证失败|无访问权限)/.test(err && err.message)) showToast(err.message, 'error');
}
const youdaoPlayed = await speakYoudao(text, youdaoRate);
if (requestSeq !== ttsRequestSeq) return false;
if (youdaoPlayed) return true;
ttsLog('youdao TTS failed -> Web Speech fallback');
const webSpeechPlayed = await speakWebSpeech(text, youdaoRate, requestSeq);
if (!webSpeechPlayed && requestSeq === ttsRequestSeq) showToast('当前无法播放语音,请稍后重试', 'warning');
return webSpeechPlayed;
}
export function speak(text) {
return speakWithFallback(text, 0.92, 1);
}
export async function speakLocal(text) {
const requestSeq = ++ttsRequestSeq;
const youdaoPlayed = await speakYoudao(text, 1);
if (requestSeq !== ttsRequestSeq || youdaoPlayed) return youdaoPlayed;
const webSpeechPlayed = await speakWebSpeech(text, 1, requestSeq);
if (!webSpeechPlayed && requestSeq === ttsRequestSeq) showToast('当前无法播放语音,请稍后重试', 'warning');
return webSpeechPlayed;
}
export function speakSlow(text) {
return speakWithFallback(text, 0.55, 0.7);
}
export function testCloudTtsConnection(text = 'Hello, this is Azure text to speech.') {
const requestSeq = ++ttsRequestSeq;
return speakCloudTts(text, 0.92, requestSeq);
}
+45
View File
@@ -0,0 +1,45 @@
// ==================== Utility ====================
export function escapeHtml(str) {
const d = document.createElement('div');
d.textContent = str == null ? '' : String(str);
return d.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
export function formatFormsHtml(forms) {
if (!forms) return '';
const parts = [];
for (const [key, val] of Object.entries(forms)) {
if (Array.isArray(val)) {
parts.push(`<span class="badge badge-primary">${escapeHtml(key)}</span> ${escapeHtml(val.join(', '))}`);
} else {
parts.push(`<span class="badge badge-primary">${escapeHtml(key)}</span> ${escapeHtml(String(val))}`);
}
}
return parts.join('<br>');
}
export function autoResizeTextarea(el) {
el.style.height = '0';
el.style.height = Math.max(200, el.scrollHeight + 2) + 'px';
}
export function debounce(fn, delay) {
let timer = null;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
export function formatMarkdown(text) {
return escapeHtml(text)
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/^### (.*$)/gm, '<h4>$1</h4>')
.replace(/^## (.*$)/gm, '<h4>$1</h4>')
.replace(/^# (.*$)/gm, '<h4>$1</h4>')
.replace(/^- (.*$)/gm, '<li>$1</li>')
.replace(/^(\d+)\. (.*$)/gm, '<li>$2</li>')
.replace(/(?:^<li>.*<\/li>\n?)+/gm, list => `<ul>${list.replace(/\n/g, '')}</ul>\n`)
.replace(/`(.*?)`/g, '<code style="background:var(--bg);padding:2px 6px;border-radius:4px;font-size:13px">$1</code>')
.replace(/\n\n/g, '</p><p>')
.replace(/\n/g, '<br>');
}
+23
View File
@@ -0,0 +1,23 @@
// ==================== Modal ====================
export function showModal(title, bodyHTML, footerHTML = '') {
closeModal();
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal-overlay" data-action="modal.closeModalBackdrop">
<div class="modal">
<div class="modal-header">
<h3>${title}</h3>
<button class="modal-close" data-action="modal.closeModal">&times;</button>
</div>
<div class="modal-body">${bodyHTML}</div>
${footerHTML ? `<div class="modal-footer">${footerHTML}</div>` : ''}
</div>
</div>`;
}
export function closeModal() {
document.getElementById('modal-root').innerHTML = '';
}
export function closeModalBackdrop(el) { closeModal(); }
+10
View File
@@ -0,0 +1,10 @@
// ==================== Sidebar (Mobile) ====================
export function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('open');
document.getElementById('overlay').classList.toggle('show');
}
export function closeSidebar() {
document.getElementById('sidebar').classList.remove('open');
document.getElementById('overlay').classList.remove('show');
}
+35
View File
@@ -0,0 +1,35 @@
import { STORAGE_KEYS } from '../core/storage.js';
// ==================== Theme ====================
export function initTheme() {
let saved = 'light';
try {
const stored = localStorage.getItem(STORAGE_KEYS.theme);
if (stored === 'dark' || stored === 'light') saved = stored;
} catch (error) {
console.warn('Failed to read saved theme', error);
}
document.documentElement.setAttribute('data-theme', saved);
updateThemeIcon(saved);
}
export function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
try {
localStorage.setItem(STORAGE_KEYS.theme, next);
} catch (error) {
console.warn('Failed to save theme', error);
}
updateThemeIcon(next);
document.dispatchEvent(new CustomEvent('themechange', { detail: { theme: next } }));
}
export function updateThemeIcon(theme) {
const icon = theme === 'dark' ? '<i class="fas fa-sun"></i>' : '<i class="fas fa-moon"></i>';
const el = document.getElementById('theme-icon');
if (el) el.innerHTML = icon;
const mob = document.getElementById('theme-toggle-mobile');
if (mob) mob.innerHTML = icon;
}
+15
View File
@@ -0,0 +1,15 @@
import { escapeHtml } from './dom.js';
// ==================== Toast ====================
export function showToast(msg, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
const icons = { info: 'fa-info-circle', success: 'fa-check-circle', error: 'fa-times-circle', warning: 'fa-exclamation-triangle' };
toast.className = `toast ${type}`;
toast.innerHTML = `<i class="fas ${icons[type] || icons.info}"></i><span>${escapeHtml(msg)}</span>`;
container.appendChild(toast);
setTimeout(() => {
toast.classList.add('hiding');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
+8
View File
@@ -0,0 +1,8 @@
{
"name": "ai-english-refactor",
"private": true,
"type": "module",
"scripts": {
"serve": "python -m http.server 8000 --directory .."
}
}
+22
View File
@@ -0,0 +1,22 @@
/* ==================== Reset & Base ==================== */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 16px; scroll-behavior: smooth; -webkit-text-size-adjust: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans SC', sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
min-height: 100vh;
min-height: 100dvh;
-webkit-font-smoothing: antialiased;
padding-bottom: env(safe-area-inset-bottom, 0);
overscroll-behavior: none;
}
a { color: var(--primary); text-decoration: none; }
img { max-width: 100%; }
button { cursor: pointer; font-family: inherit; }
input, select, textarea { font-family: inherit; font-size: inherit; }
+425
View File
@@ -0,0 +1,425 @@
/* ==================== Cards ==================== */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
box-shadow: var(--shadow);
transition: box-shadow var(--transition), transform var(--transition), border-color var(--transition);
}
.card:hover {
box-shadow: var(--shadow-md);
border-color: color-mix(in srgb, var(--primary) 20%, var(--border));
}
.card h3 {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
color: var(--text);
display: flex;
align-items: center;
gap: 8px;
}
.card h3 i {
color: var(--primary);
font-size: 15px;
}
/* ==================== Buttons ==================== */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 20px;
border: none;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all var(--transition);
white-space: nowrap;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
color: #fff;
box-shadow: 0 2px 8px var(--primary-glow);
}
.btn-primary:hover {
background: linear-gradient(135deg, var(--primary-hover), var(--primary));
transform: translateY(-1px);
box-shadow: 0 6px 20px var(--primary-glow);
}
.btn-secondary {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
}
.btn-secondary:hover { background: var(--border); }
.btn-success { background: var(--success); color: #fff; }
.btn-success:hover { opacity: 0.9; }
.btn-warning { background: var(--warning); color: #fff; }
.btn-error { background: var(--error); color: #fff; }
.btn-error:hover { opacity: 0.9; }
.btn-ghost {
background: transparent;
color: var(--text-secondary);
padding: 8px 12px;
}
.btn-ghost:hover { background: var(--primary-bg); color: var(--primary); }
.btn-lg { padding: 14px 28px; font-size: 16px; }
.btn-sm { padding: 6px 14px; font-size: 13px; }
.btn-icon {
width: 36px; height: 36px;
padding: 0; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
}
/* ==================== Forms ==================== */
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-size: 14px;
font-weight: 600;
color: var(--text);
margin-bottom: 6px;
}
.form-group .hint {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
}
input[type="text"],
input[type="password"],
input[type="number"],
input[type="url"],
textarea,
select {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-input);
color: var(--text);
font-size: 14px;
transition: border-color var(--transition), box-shadow var(--transition);
outline: none;
}
input:focus, textarea:focus, select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-bg), 0 2px 8px var(--primary-glow);
}
textarea { resize: vertical; min-height: 100px; }
select {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%2394a3b8' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 36px;
}
/* ==================== Badges ==================== */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.3px;
transition: all var(--transition);
}
.badge-primary { background: var(--primary-bg); color: var(--primary); border: 1px solid color-mix(in srgb, var(--primary) 15%, transparent); }
.badge-success { background: var(--success-light); color: var(--success); border: 1px solid color-mix(in srgb, var(--success) 15%, transparent); }
.badge-warning { background: var(--warning-light); color: var(--warning); border: 1px solid color-mix(in srgb, var(--warning) 15%, transparent); }
.badge-error { background: var(--error-light); color: var(--error); border: 1px solid color-mix(in srgb, var(--error) 15%, transparent); }
/* ==================== Toast ==================== */
#toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 8px;
}
.toast {
padding: 14px 20px;
border-radius: var(--radius-sm);
background: var(--bg-card);
border: 1px solid var(--border);
box-shadow: var(--shadow-lg);
font-size: 14px;
color: var(--text);
display: flex;
align-items: center;
gap: 10px;
animation: slideInRight 0.35s cubic-bezier(0.4, 0, 0.2, 1);
max-width: 360px;
backdrop-filter: blur(12px);
}
.toast i {
font-size: 16px;
flex-shrink: 0;
}
.toast.success { border-left: 4px solid var(--success); }
.toast.success i { color: var(--success); }
.toast.error { border-left: 4px solid var(--error); }
.toast.error i { color: var(--error); }
.toast.warning { border-left: 4px solid var(--warning); }
.toast.warning i { color: var(--warning); }
.toast.info { border-left: 4px solid var(--primary); }
.toast.info i { color: var(--primary); }
.toast.hiding { animation: slideOutRight 0.3s ease forwards; }
/* ==================== Modal ==================== */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
backdrop-filter: blur(4px);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
animation: fadeIn 0.2s ease;
}
.modal {
background: var(--bg-card);
border-radius: var(--radius);
box-shadow: 0 20px 60px rgba(0,0,0,0.15), 0 4px 16px rgba(0,0,0,0.1);
width: 100%;
max-width: 520px;
max-height: 85vh;
overflow-y: auto;
animation: modalIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes modalIn {
from { opacity: 0; transform: scale(0.9) translateY(10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-header h3 {
font-size: 18px;
font-weight: 600;
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.modal-close {
background: none;
border: none;
font-size: 22px;
color: var(--text-muted);
cursor: pointer;
width: 32px; height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all var(--transition);
}
.modal-close:hover { background: var(--bg); color: var(--text); }
.modal-body { padding: 24px; }
.modal-footer {
padding: 16px 24px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* ==================== Pagination ==================== */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 6px;
margin-top: 24px;
}
.pagination button {
min-width: 36px; height: 36px;
border: 1px solid var(--border);
border-radius: var(--radius-xs);
background: var(--bg-card);
color: var(--text-secondary);
font-size: 14px;
cursor: pointer;
transition: all var(--transition);
}
.pagination button:hover:not(:disabled) {
border-color: var(--primary);
color: var(--primary);
background: var(--primary-bg);
transform: translateY(-1px);
}
.pagination button.active {
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
color: #fff;
border-color: var(--primary);
box-shadow: 0 2px 8px var(--primary-glow);
}
.pagination button:disabled { opacity: 0.4; cursor: default; }
/* ==================== Empty State ==================== */
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-muted);
}
.empty-state .empty-icon {
font-size: 56px;
margin-bottom: 20px;
color: var(--primary);
opacity: 0.7;
animation: emptyFloat 3s ease-in-out infinite;
}
@keyframes emptyFloat {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
.empty-state h3 { font-size: 18px; color: var(--text-secondary); margin-bottom: 8px; }
.empty-state p { font-size: 14px; margin-bottom: 20px; }
/* ==================== Loading ==================== */
.loading {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 40px;
color: var(--text-muted);
font-size: 14px;
}
.spinner {
width: 24px; height: 24px;
border: 3px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.7s linear infinite;
filter: drop-shadow(0 0 2px var(--primary-glow));
}
/* ==================== Checkbox ==================== */
input[type="checkbox"] {
width: 18px; height: 18px;
accent-color: var(--primary);
cursor: pointer;
}
/* ==================== Animations ==================== */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
@keyframes slideInRight {
from { opacity: 0; transform: translateX(60px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes slideOutRight {
from { opacity: 1; transform: translateX(0); }
to { opacity: 0; transform: translateX(60px); }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in-up {
animation: fadeInUp 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
/* ==================== Scrollbar ==================== */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
/* ==================== Toggle Switch ==================== */
.toggle-switch {
width: 44px; height: 24px; border-radius: 12px;
background: var(--border); cursor: pointer;
position: relative; transition: background 0.25s ease;
flex-shrink: 0;
}
.toggle-switch.active { background: var(--success); }
.toggle-knob {
width: 20px; height: 20px; border-radius: 50%;
background: #fff; position: absolute;
top: 2px; left: 2px;
transition: transform 0.25s ease;
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
}
.toggle-switch.active .toggle-knob { transform: translateX(20px); }
+222
View File
@@ -0,0 +1,222 @@
/* ==================== Layout ==================== */
#app {
display: flex;
min-height: 100vh;
}
#mobile-header {
display: none;
position: fixed;
top: 0; left: 0; right: 0;
height: var(--header-h);
background: var(--bg-card);
border-bottom: 1px solid var(--border);
z-index: 100;
align-items: center;
padding: 0 16px;
gap: 12px;
backdrop-filter: blur(12px);
background: rgba(255,255,255,0.85);
}
[data-theme="dark"] #mobile-header {
background: rgba(30,41,59,0.9);
}
#mobile-header h1 {
font-size: 18px;
flex: 1;
text-align: center;
}
#mobile-header button {
min-width: 44px;
min-height: 44px;
background: none;
border: none;
color: var(--text);
font-size: 20px;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
}
/* ==================== Sidebar ==================== */
#sidebar {
position: fixed;
top: 0; left: 0; bottom: 0;
width: var(--sidebar-w);
background: var(--bg-sidebar);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
z-index: 200;
transition: transform var(--transition);
}
.sidebar-header {
padding: 24px 20px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid var(--border);
background: linear-gradient(135deg, var(--primary-bg) 0%, transparent 80%);
}
.sidebar-header .logo {
font-size: 24px;
width: 42px;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
color: #fff;
border-radius: var(--radius-sm);
box-shadow: 0 4px 12px var(--primary-glow);
}
.sidebar-header h2 {
font-size: 18px;
font-weight: 700;
color: var(--text);
letter-spacing: 0.5px;
}
.sidebar-nav {
flex: 1;
padding: 12px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 11px 16px;
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-size: 15px;
font-weight: 500;
transition: all var(--transition);
text-decoration: none;
position: relative;
}
.nav-item:hover {
background: var(--primary-bg);
color: var(--primary);
}
.nav-item.active {
background: var(--primary-bg);
color: var(--primary);
font-weight: 600;
}
.nav-item.active::before {
content: '';
position: absolute;
left: 0;
top: 6px;
bottom: 6px;
width: 3px;
background: linear-gradient(180deg, var(--primary), var(--primary-hover));
border-radius: 0 3px 3px 0;
box-shadow: 2px 0 8px var(--primary-glow);
}
.nav-icon {
font-size: 16px;
width: 24px;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
.sidebar-footer {
padding: 16px;
border-top: 1px solid var(--border);
}
.sidebar-footer button {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 16px;
border: none;
border-radius: var(--radius-sm);
background: var(--primary-bg);
color: var(--text-secondary);
font-size: 14px;
transition: all var(--transition);
}
.sidebar-footer button:hover { background: var(--border); }
#overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.4);
z-index: 150;
backdrop-filter: blur(2px);
}
/* ==================== Main Content ==================== */
#main-content {
flex: 1;
margin-left: var(--sidebar-w);
min-height: 100vh;
transition: margin-left var(--transition);
}
#page-content {
max-width: 1100px;
margin: 0 auto;
padding: 32px 32px 60px;
}
/* ==================== Page Header ==================== */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 28px;
flex-wrap: wrap;
gap: 16px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border);
}
.page-header h1 {
font-size: 26px;
font-weight: 700;
background: linear-gradient(135deg, var(--text), var(--text-secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.quiz-session-meta {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.page-desc {
color: var(--text-secondary);
font-size: 15px;
margin-top: 4px;
}
.page-actions { display: flex; gap: 10px; flex-wrap: wrap; }
+18
View File
@@ -0,0 +1,18 @@
/* ==================== Email Extract ==================== */
.extract-layout { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
.word-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
.word-chip {
display: inline-flex; align-items: center; gap: 4px;
padding: 6px 14px; border-radius: 20px; font-size: 13px; font-weight: 500;
cursor: pointer; transition: 0.2s; border: 1.5px solid var(--border);
background: var(--bg-card); color: var(--text-secondary); user-select: none;
}
.word-chip:hover { border-color: var(--primary); color: var(--primary); }
.word-chip.selected { background: var(--primary); color: #fff; border-color: var(--primary); }
.word-chip.exists { background: var(--bg); color: var(--text-muted); border-color: var(--border); text-decoration: line-through; cursor: not-allowed; opacity: 0.6; }
.extract-actions { display: flex; gap: 8px; align-items: center; margin-top: 16px; flex-wrap: wrap; }
+164
View File
@@ -0,0 +1,164 @@
/* ==================== Stat Cards ==================== */
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 28px;
}
.stat-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 22px 20px;
display: flex;
align-items: center;
gap: 16px;
box-shadow: var(--shadow);
transition: all var(--transition);
position: relative;
overflow: hidden;
}
.stat-card::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, transparent 60%, var(--primary-bg));
opacity: 0;
transition: opacity var(--transition);
pointer-events: none;
}
.stat-card:hover {
transform: translateY(-3px);
box-shadow: var(--shadow-md);
}
.stat-card:hover::after {
opacity: 1;
}
.stat-icon {
width: 48px; height: 48px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
transition: transform var(--transition);
}
.stat-card:hover .stat-icon {
transform: scale(1.1);
}
.stat-value {
font-size: 28px;
font-weight: 700;
line-height: 1.2;
}
.stat-label {
font-size: 13px;
color: var(--text-secondary);
margin-top: 2px;
}
/* ==================== Home Actions ==================== */
.home-actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
margin-bottom: 28px;
}
.home-actions-two {
grid-template-columns: repeat(2, 1fr);
}
.action-card {
text-align: center;
padding: 36px 28px;
cursor: pointer;
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
border-top: 3px solid transparent;
}
.action-card::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, var(--primary-bg) 0%, transparent 70%);
opacity: 0;
transition: opacity 0.4s ease;
pointer-events: none;
}
.action-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
border-top-color: var(--primary);
}
.action-card:hover::before {
opacity: 1;
}
.action-card .action-icon {
width: 64px; height: 64px;
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
font-size: 26px;
margin: 0 auto 18px;
transition: transform 0.35s ease;
}
.action-card:hover .action-icon {
transform: scale(1.1) rotate(-3deg);
}
.action-card h3 { margin-bottom: 8px; font-size: 18px; justify-content: center; }
.action-count {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 22px;
}
/* ==================== Streak ==================== */
.streak-display {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 18px;
background: linear-gradient(135deg, #ff9a56, #ef4444);
border-radius: 24px;
color: #fff;
font-weight: 600;
font-size: 14px;
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.35);
transition: all var(--transition);
}
.streak-display:hover {
transform: scale(1.05);
box-shadow: 0 6px 20px rgba(239, 68, 68, 0.45);
}
.streak-display i { font-size: 16px; animation: fireFlicker 1.5s ease-in-out infinite; }
.streak-display .streak-num { font-size: 20px; font-weight: 700; }
@keyframes fireFlicker {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.8; transform: scale(1.1); }
}
+865
View File
@@ -0,0 +1,865 @@
/* ==================== Flip Card ==================== */
.learn-area {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
padding: 20px 0;
}
.learn-progress {
width: 100%;
max-width: 480px;
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
color: var(--text-secondary);
}
.progress-bar {
flex: 1;
height: 6px;
background: var(--border);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary), var(--primary-hover));
border-radius: 3px;
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.progress-fill::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
animation: progressShimmer 2s infinite;
}
@keyframes progressShimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.flip-card {
perspective: 1000px;
width: 100%;
max-width: 480px;
height: 320px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.flip-card-inner {
position: relative;
width: 100%;
height: 100%;
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
transform-style: preserve-3d;
}
.flip-card.flipped .flip-card-inner {
transform: rotateY(180deg);
}
.flip-card-front,
.flip-card-back {
position: absolute;
inset: 0;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-card);
box-shadow: var(--shadow-md);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px;
text-align: center;
}
.flip-card-back {
transform: rotateY(180deg);
overflow-y: auto;
justify-content: flex-start;
padding-top: 28px;
}
.card-word {
font-size: 36px;
font-weight: 700;
color: var(--text);
margin-bottom: 12px;
}
.card-phonetic {
font-size: 18px;
color: var(--text-muted);
margin-bottom: 16px;
}
.card-speak-btn {
background: var(--primary-bg);
color: var(--primary);
border: none;
width: 44px; height: 44px;
border-radius: 50%;
font-size: 20px;
cursor: pointer;
transition: all var(--transition);
}
.card-speak-btn:hover { background: var(--primary); color: #fff; }
.card-hint {
font-size: 14px;
color: var(--text-muted);
margin-top: 20px;
}
.card-chinese {
font-size: 28px;
font-weight: 600;
color: var(--primary);
margin-bottom: 16px;
}
.card-forms {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 12px;
line-height: 1.8;
}
.card-example {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.7;
text-align: left;
width: 100%;
max-width: 400px;
}
.card-example-en {
font-style: italic;
color: var(--text);
margin-bottom: 4px;
}
.learn-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
justify-content: center;
}
.rate-btn {
padding: 12px 28px;
border: 2px solid;
border-radius: var(--radius-sm);
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all var(--transition);
background: var(--bg-card);
}
.rate-btn.know { border-color: var(--success); color: var(--success); }
.rate-btn.know:hover { background: var(--success); color: #fff; }
.rate-btn.fuzzy { border-color: var(--warning); color: var(--warning); }
.rate-btn.fuzzy:hover { background: var(--warning); color: #fff; }
.rate-btn.unknown { border-color: var(--error); color: var(--error); }
.rate-btn.unknown:hover { background: var(--error); color: #fff; }
/* ==================== Setup Card (Learn / Quiz) ==================== */
.setup-card {
max-width: 720px;
margin: 0 auto;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 36px 40px 36px;
}
.setup-card .form-group + .form-group {
margin-top: 8px;
}
.setup-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.setup-row .form-group {
margin-bottom: 0;
}
.setup-divider {
height: 1px;
background: var(--border);
margin: 24px 0;
}
.mode-toggle {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.mode-toggle-btn {
padding: 10px 10px;
border: 2px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-card);
cursor: pointer;
text-align: center;
transition: all var(--transition);
}
.mode-toggle-btn:hover {
border-color: var(--primary);
background: var(--primary-bg);
}
.mode-toggle-btn.active {
border-color: var(--primary);
background: var(--primary-bg);
box-shadow: 0 2px 8px var(--primary-glow);
}
.mode-toggle-label {
font-size: 14px;
font-weight: 700;
color: var(--text);
letter-spacing: 0.3px;
}
.mode-toggle-btn.active .mode-toggle-label {
color: var(--primary);
}
.mode-toggle-hint {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
}
.setup-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
/* Quiz Setup two-column layout */
.quiz-setup-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
align-items: start;
}
.quiz-setup-left {
position: sticky;
top: 16px;
}
.quiz-setup-right .form-group:last-child {
margin-bottom: 0;
}
.setup-card-header {
text-align: center;
margin-bottom: 32px;
}
.setup-card-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
border-radius: 14px;
background: linear-gradient(135deg, var(--primary-bg), color-mix(in srgb, var(--primary) 15%, transparent));
color: var(--primary);
font-size: 24px;
margin-bottom: 14px;
box-shadow: 0 4px 12px var(--primary-glow);
}
.setup-card-header h2 {
font-size: 22px;
font-weight: 700;
color: var(--text);
}
.setup-card-desc {
text-align: center;
font-size: 14px;
color: var(--text-secondary);
margin: -16px 0 28px;
line-height: 1.6;
}
.setup-radio-group {
display: flex;
align-items: center;
gap: 28px;
}
.setup-radio {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 14px;
color: var(--text);
}
.setup-radio input[type="radio"] {
width: 18px;
height: 18px;
accent-color: var(--primary);
cursor: pointer;
margin: 0;
}
.setup-checkbox {
margin-bottom: 24px;
}
.setup-checkbox label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 14px;
color: var(--text-secondary);
}
/* ==================== Learn Controls ==================== */
.learn-controls {
display: flex;
gap: 12px;
margin-bottom: 24px;
align-items: center;
flex-wrap: wrap;
}
.learn-controls select { width: auto; min-width: 160px; }
.learn-complete {
text-align: center;
padding: 40px;
}
.learn-complete h2 {
font-size: 24px;
margin-bottom: 12px;
}
.learn-complete .summary {
display: flex;
justify-content: center;
gap: 32px;
margin: 20px 0;
}
.learn-complete .summary-item { text-align: center; }
.learn-complete .summary-item .num { font-size: 28px; font-weight: 700; }
.learn-complete .summary-item .lbl { font-size: 13px; color: var(--text-muted); }
/* ==================== Learn V2 - Card ==================== */
.lv2-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.lv2-stats-row {
display: flex;
gap: 24px;
flex-wrap: wrap;
}
.lv2-si { text-align: center; }
.lv2-sv {
display: block;
font-size: 20px;
font-weight: 700;
color: var(--text);
line-height: 1.2;
}
.lv2-sl {
display: block;
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
}
.lv2-topbar-btns {
display: flex;
gap: 8px;
}
.lv2-tb-btn {
padding: 8px 16px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-card);
color: var(--text-secondary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all var(--transition);
display: inline-flex;
align-items: center;
gap: 6px;
}
.lv2-tb-btn:hover {
border-color: var(--primary);
color: var(--primary);
background: var(--primary-bg);
}
.lv2-tb-active {
background: var(--primary);
color: #fff;
border-color: var(--primary);
}
.lv2-tb-active:hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
color: #fff;
}
.lv2-tb-end { color: var(--text-muted); }
.lv2-tb-end:hover {
border-color: var(--error);
color: var(--error);
background: var(--error-light);
}
.lv2-pbar {
width: 100%;
height: 5px;
background: var(--border);
border-radius: 3px;
margin-bottom: 24px;
overflow: hidden;
}
.lv2-pfill {
height: 100%;
background: var(--primary);
border-radius: 3px;
transition: width 0.4s ease;
}
.lv2-card-area {
display: flex;
justify-content: center;
margin-bottom: 24px;
}
.lv2-gcard {
width: 100%;
max-width: 640px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 40px 36px;
text-align: center;
box-shadow: var(--shadow-md);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.lv2-gcard::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--primary), var(--primary-hover), var(--primary));
opacity: 0;
transition: opacity 0.3s ease;
}
.lv2-gcard:hover {
box-shadow: var(--shadow-lg);
border-color: color-mix(in srgb, var(--primary) 25%, var(--border));
}
.lv2-gcard:hover::before {
opacity: 1;
}
.lv2-gcard-anim {
animation: lv2CardIn 0.3s ease-out;
}
@keyframes lv2CardIn {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.lv2-gcard-inner { position: relative; }
.lv2-gcard-badge {
position: absolute;
top: 16px;
right: 16px;
padding: 4px 12px;
border-radius: 12px;
background: var(--primary-bg);
color: var(--primary);
font-size: 11px;
font-weight: 600;
}
.lv2-gcard-divider {
width: 50px;
height: 2px;
background: var(--border);
margin: 4px auto 20px;
border-radius: 1px;
}
.lv2-gcard-word {
font-size: 38px;
font-weight: 800;
color: var(--primary);
margin-bottom: 10px;
letter-spacing: -0.5px;
}
.lv2-gcard-prow {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 12px;
}
.lv2-gcard-ph {
font-size: 17px;
color: var(--text-muted);
font-style: italic;
}
.lv2-gcard-freq {
padding: 3px 10px;
background: var(--primary-bg);
color: var(--primary);
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.lv2-gcard-cat {
display: inline-block;
padding: 4px 14px;
border-radius: 16px;
font-size: 11px;
font-weight: 700;
letter-spacing: 1px;
margin-bottom: 8px;
}
.lv2-gcard-cat-high {
background: var(--success-light);
color: var(--success);
}
.lv2-gcard-cat-mid {
background: var(--warning-light);
color: var(--warning);
}
.lv2-gcard-cat-low {
background: var(--error-light);
color: var(--error);
}
.lv2-gcard-zh {
font-size: 26px;
font-weight: 700;
color: var(--text);
margin-bottom: 16px;
}
.lv2-gcard-forms {
background: var(--bg);
border: 1px solid var(--border);
padding: 8px 16px;
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 16px;
text-align: left;
}
.lv2-gcard-form-item {
padding: 4px 0;
line-height: 1.5;
word-break: break-word;
}
.lv2-gcard-form-item + .lv2-gcard-form-item {
border-top: 1px dashed var(--border);
}
.lv2-gcard-form-key {
color: var(--text-muted);
font-size: 12px;
}
.lv2-gcard-speak-row {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 16px;
}
.lv2-gcard-speak {
width: 46px;
height: 46px;
border: none;
border-radius: 50%;
background: var(--primary-bg);
color: var(--primary);
font-size: 18px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.lv2-gcard-speak:hover {
background: var(--primary);
color: #fff;
transform: scale(1.08);
}
.lv2-gcard-speak-slow {
width: 40px;
height: 40px;
font-size: 14px;
}
.lv2-gcard-ex {
background: var(--bg);
border-left: 3px solid var(--primary);
padding: 12px 16px;
border-radius: 0 var(--radius-xs) var(--radius-xs) 0;
text-align: left;
}
.lv2-gcard-ex-en {
font-size: 15px;
font-weight: 600;
font-style: italic;
color: var(--text);
margin-bottom: 4px;
}
.lv2-gcard-ex-cn {
font-size: 14px;
color: var(--text-secondary);
}
.lv2-gcard-hint {
font-size: 14px;
color: var(--text-muted);
margin-top: 20px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
.lv2-rate-row {
display: flex;
justify-content: center;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.lv2-rate {
padding: 10px 28px;
border: 2px solid;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all var(--transition);
background: var(--bg-card);
display: inline-flex;
align-items: center;
gap: 6px;
}
.lv2-rate-know { border-color: var(--success); color: var(--success); }
.lv2-rate-know:hover, .lv2-rate-know.lv2-rate-selected { background: var(--success); color: #fff; }
.lv2-rate-fuzzy { border-color: var(--warning); color: var(--warning); }
.lv2-rate-fuzzy:hover, .lv2-rate-fuzzy.lv2-rate-selected { background: var(--warning); color: #fff; }
.lv2-rate-unknown { border-color: var(--error); color: var(--error); }
.lv2-rate-unknown:hover, .lv2-rate-unknown.lv2-rate-selected { background: var(--error); color: #fff; }
.lv2-nav-row {
display: flex;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
}
.lv2-nav-btn {
padding: 11px 24px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-card);
color: var(--text);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all var(--transition);
display: inline-flex;
align-items: center;
gap: 6px;
}
.lv2-nav-btn:hover:not(:disabled) {
border-color: var(--primary);
color: var(--primary);
background: var(--primary-bg);
}
.lv2-nav-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.lv2-nav-center {
background: var(--primary);
color: #fff;
border-color: var(--primary);
}
.lv2-nav-center:hover:not(:disabled) {
background: var(--primary-hover);
border-color: var(--primary-hover);
color: #fff;
}
/* Learn V2 - Complete Screen */
.lv2-complete {
text-align: center;
max-width: 480px;
margin: 0 auto;
padding: 48px 32px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
animation: lv2CardIn 0.5s ease-out;
position: relative;
overflow: hidden;
}
.lv2-complete::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, var(--success), var(--primary), var(--warning));
}
.lv2-complete-icon {
font-size: 60px;
margin-bottom: 16px;
color: var(--primary);
animation: celebrateIcon 0.6s ease-out 0.3s both;
}
@keyframes celebrateIcon {
0% { transform: scale(0); opacity: 0; }
50% { transform: scale(1.2); }
100% { transform: scale(1); opacity: 1; }
}
.lv2-complete-title {
font-size: 24px;
font-weight: 700;
color: var(--text);
margin-bottom: 8px;
}
.lv2-complete-desc {
font-size: 15px;
color: var(--text-secondary);
margin-bottom: 28px;
}
.lv2-complete-stats {
display: flex;
justify-content: center;
gap: 36px;
margin-bottom: 28px;
}
.lv2-cs-item { text-align: center; }
.lv2-cs-val {
font-size: 32px;
font-weight: 700;
line-height: 1.2;
}
.lv2-cs-lbl {
font-size: 13px;
color: var(--text-muted);
margin-top: 4px;
}
.lv2-complete-actions {
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
}
+26
View File
@@ -0,0 +1,26 @@
/* ==================== Mail Learn ==================== */
.saved-email-item:hover { border-color: var(--primary) !important; }
.mail-learn-layout { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
.mail-original {
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm);
padding: 20px; max-height: 600px; overflow-y: auto; line-height: 1.8; font-size: 14px;
}
.mail-original .highlight-word {
background: var(--primary-bg); color: var(--primary); padding: 1px 4px;
border-radius: 4px; cursor: pointer; font-weight: 600; transition: all 0.2s;
}
.mail-original .highlight-word:hover { background: var(--primary); color: #fff; }
.ai-analysis {
background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-sm);
padding: 20px; max-height: 600px; overflow-y: auto;
}
.ai-analysis-content { line-height: 1.8; font-size: 14px; color: var(--text-secondary); }
.ai-analysis-content h4 { color: var(--text); margin: 16px 0 8px; font-size: 15px; }
.ai-analysis-content p { margin-bottom: 8px; }
.ai-analysis-content ul { padding-left: 20px; margin-bottom: 12px; }
+360
View File
@@ -0,0 +1,360 @@
/* ==================== Quiz ==================== */
.quiz-area {
max-width: 1100px;
margin: 0 auto;
}
.quiz-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
align-items: start;
}
.quiz-left {
min-width: 0;
}
.quiz-right {
min-width: 0;
position: sticky;
top: 16px;
}
.quiz-word-panel {
border-radius: var(--radius);
overflow: hidden;
position: relative;
}
.quiz-word-panel.locked .quiz-word-detail {
filter: blur(6px);
user-select: none;
pointer-events: none;
}
.quiz-word-cover {
position: absolute;
inset: 0;
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
border-radius: var(--radius);
border: 2px dashed var(--border);
color: var(--text-muted);
font-size: 14px;
font-weight: 500;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.quiz-word-cover i {
font-size: 28px;
color: var(--primary);
}
.quiz-word-panel.revealed .quiz-word-detail {
animation: fadeReveal 0.35s ease;
}
@keyframes fadeReveal {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.quiz-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding: 14px 20px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.quiz-counter {
font-size: 14px;
color: var(--text-secondary);
font-weight: 600;
white-space: nowrap;
}
.quiz-timer {
font-size: 14px;
color: var(--text-muted);
}
.quiz-question-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 36px 32px;
text-align: center;
margin-bottom: 20px;
box-shadow: var(--shadow);
}
.quiz-prompt {
font-size: 30px;
font-weight: 700;
margin-bottom: 8px;
}
.quiz-prompt-hint {
font-size: 14px;
color: var(--text-muted);
}
.quiz-options {
display: flex;
flex-direction: column;
gap: 10px;
}
.quiz-option {
padding: 16px 20px;
border: 2px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-card);
color: var(--text);
font-size: 16px;
text-align: left;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 12px;
}
.quiz-option:hover:not(.disabled) {
border-color: var(--primary);
background: var(--primary-bg);
transform: translateX(4px);
box-shadow: 0 2px 8px var(--primary-glow);
}
.quiz-option:active:not(.disabled) {
transform: translateX(2px) scale(0.99);
}
.quiz-option .option-letter {
width: 30px; height: 30px;
border-radius: 50%;
background: var(--bg);
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
flex-shrink: 0;
transition: all 0.2s ease;
}
.quiz-option:hover:not(.disabled) .option-letter {
background: var(--primary);
color: #fff;
}
.quiz-option.correct {
border-color: var(--success);
background: var(--success-light);
animation: quizCorrect 0.4s ease;
}
.quiz-option.correct .option-letter {
background: var(--success);
color: #fff;
}
.quiz-option.wrong {
border-color: var(--error);
background: var(--error-light);
animation: quizWrong 0.4s ease;
}
.quiz-option.wrong .option-letter {
background: var(--error);
color: #fff;
}
@keyframes quizCorrect {
0% { transform: scale(1); }
50% { transform: scale(1.02); }
100% { transform: scale(1); }
}
@keyframes quizWrong {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-4px); }
75% { transform: translateX(4px); }
}
.quiz-option.disabled { cursor: default; pointer-events: none; }
.quiz-nav-row {
display: flex;
gap: 12px;
justify-content: center;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid var(--border);
}
.quiz-explanation {
margin-top: 16px;
padding: 14px 18px;
background: var(--primary-bg);
border-radius: var(--radius-sm);
font-size: 14px;
color: var(--text-secondary);
line-height: 1.6;
display: flex;
align-items: flex-start;
gap: 8px;
}
.quiz-explanation i { color: var(--primary); margin-top: 2px; }
.quiz-word-detail {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 28px 24px;
text-align: center;
box-shadow: var(--shadow);
}
/* Quiz Results */
.quiz-results {
max-width: 540px;
margin: 0 auto;
text-align: center;
}
.quiz-results .card { padding: 44px 36px; }
.quiz-ring-chart {
position: relative;
width: 160px;
height: 160px;
margin: 0 auto 24px;
}
.quiz-ring-chart svg {
transform: rotate(-90deg);
width: 160px;
height: 160px;
}
.quiz-ring-chart .ring-bg {
fill: transparent;
stroke: var(--border);
stroke-width: 10;
}
.quiz-ring-chart .ring-fill {
fill: transparent;
stroke-width: 10;
stroke-linecap: round;
transition: stroke-dashoffset 1.2s cubic-bezier(0.4, 0, 0.2, 1);
filter: drop-shadow(0 0 4px currentColor);
}
.quiz-ring-chart .ring-text {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.quiz-ring-chart .ring-value {
font-size: 36px;
font-weight: 800;
line-height: 1.1;
}
.quiz-ring-chart .ring-label {
font-size: 13px;
color: var(--text-muted);
}
.quiz-score {
font-size: 64px;
font-weight: 800;
margin: 16px 0 8px;
}
.quiz-score.good { color: var(--success); }
.quiz-score.medium { color: var(--warning); }
.quiz-score.bad { color: var(--error); }
.quiz-score-label {
font-size: 15px;
color: var(--text-secondary);
margin-bottom: 24px;
}
.quiz-result-stats {
display: flex;
justify-content: center;
gap: 32px;
margin-bottom: 28px;
}
.quiz-result-stat { text-align: center; }
.quiz-result-stat .value { font-size: 24px; font-weight: 700; }
.quiz-result-stat .label { font-size: 13px; color: var(--text-muted); }
/* ==================== Batch Selector ==================== */
.batch-group-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 8px;
}
.batch-group-btn {
padding: 10px 8px;
border: 2px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-card);
color: var(--text);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition);
text-align: center;
line-height: 1.4;
}
.batch-group-btn small {
color: var(--text-muted);
font-weight: 400;
}
.batch-group-btn:hover {
border-color: var(--primary);
background: var(--primary-bg);
}
.batch-group-btn.active {
border-color: var(--primary);
background: var(--primary-bg);
color: var(--primary);
font-weight: 600;
box-shadow: 0 2px 8px var(--primary-glow);
transform: translateY(-1px);
}
.batch-group-btn.active small {
color: var(--primary);
}
+101
View File
@@ -0,0 +1,101 @@
/* ==================== Full-Text Reader ==================== */
.reader-card { padding: 0; overflow: hidden; }
.reader-header {
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px;
padding: 20px 24px; border-bottom: 1px solid var(--border);
background: linear-gradient(135deg, var(--primary-bg), transparent);
}
.reader-header h3 { margin-bottom: 0; }
.reader-stats { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.reader-legend {
display: flex; align-items: center; gap: 20px; flex-wrap: wrap;
padding: 12px 24px; background: var(--bg); border-bottom: 1px solid var(--border);
font-size: 12px; color: var(--text-muted);
}
.reader-legend > span { display: inline-flex; align-items: center; gap: 6px; }
.reader-dot { width: 10px; height: 10px; border-radius: 3px; display: inline-block; }
.known-dot { background: var(--primary); }
.unknown-dot { background: var(--warning); }
.reader-body { padding: 12px 0; }
.reader-sentence-block {
display: flex; gap: 0; padding: 0;
transition: background var(--transition);
border-left: 3px solid transparent;
}
.reader-sentence-block:hover { background: var(--primary-bg); border-left-color: var(--primary); }
.reader-sentence-num {
width: 44px; flex-shrink: 0; padding: 14px 0;
text-align: center; font-size: 12px; font-weight: 600;
color: var(--text-muted); user-select: none;
}
.reader-sentence-content { flex: 1; padding: 12px 20px 12px 0; border-bottom: 1px solid var(--border-light); }
.reader-sentence-en {
font-size: 15px; line-height: 2; cursor: pointer;
color: var(--text); transition: color var(--transition);
}
.reader-sentence-cn {
font-size: 14px; line-height: 1.7; color: var(--text-secondary);
padding: 10px 14px; margin-top: 8px;
background: var(--bg); border-radius: var(--radius-sm);
border-left: 3px solid var(--success);
animation: fadeInUp 0.25s ease;
}
.reader-sentence-tools {
display: flex; gap: 2px; margin-top: 6px; opacity: 0;
transition: opacity 0.2s;
}
.reader-sentence-block:hover .reader-sentence-tools { opacity: 1; }
.reader-word {
cursor: pointer; border-radius: 3px; padding: 0 2px;
transition: all 0.15s ease; position: relative;
}
.reader-word.known {
color: var(--primary); font-weight: 600;
text-decoration: underline; text-decoration-style: dotted;
text-underline-offset: 3px; text-decoration-color: color-mix(in srgb, var(--primary) 40%, transparent);
}
.reader-word.known:hover { background: var(--primary); color: #fff; text-decoration: none; border-radius: 4px; }
.reader-word.unknown { color: var(--warning); border-bottom: 1.5px dashed var(--warning); }
.reader-word.unknown:hover { background: var(--warning); color: #fff; border-bottom-color: transparent; border-radius: 4px; }
/* ==================== Reader Tooltip ==================== */
.reader-tooltip {
position: absolute; z-index: 500;
width: 300px; max-width: 90vw;
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow-lg);
padding: 16px; animation: fadeInUp 0.2s ease;
}
.reader-tip-header { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
.reader-tip-header strong { font-size: 18px; color: var(--text); }
.reader-tip-cn { font-size: 15px; color: var(--text); margin-bottom: 8px; }
.reader-tip-forms { font-size: 12px; color: var(--text-secondary); line-height: 1.7; margin-bottom: 8px; }
.reader-tip-example {
font-size: 13px; padding: 8px 10px;
background: var(--bg); border-radius: var(--radius-sm);
border-left: 2px solid var(--primary); line-height: 1.6;
}
.reader-tip-example em { color: var(--text); }
.reader-tip-example span { color: var(--text-secondary); }
@media (max-width: 768px) {
.extract-layout { grid-template-columns: 1fr; }
.mail-learn-layout { grid-template-columns: 1fr; }
.reader-header { padding: 14px 16px; }
.reader-legend { padding: 10px 16px; gap: 12px; }
.reader-sentence-num { width: 32px; font-size: 11px; }
.reader-sentence-content { padding-right: 12px; }
.reader-sentence-en { font-size: 14px; line-height: 1.9; }
.reader-sentence-tools { opacity: 1; }
.reader-tooltip { width: 280px; padding: 12px; }
}
+30
View File
@@ -0,0 +1,30 @@
/* ==================== Review ==================== */
.review-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
margin-bottom: 24px;
}
.review-word-item {
padding: 14px 16px;
background: var(--bg-card);
border: 1px solid var(--border);
border-left: 3px solid var(--border);
border-radius: var(--radius-sm);
font-size: 14px;
transition: all var(--transition);
}
.review-word-item:hover {
border-color: var(--primary);
border-left-color: var(--primary);
box-shadow: var(--shadow);
transform: translateY(-1px);
}
.review-word-item .rw-en { font-weight: 600; }
.review-word-item .rw-zh { color: var(--text-secondary); font-size: 13px; margin-top: 2px; }
.review-word-item .rw-stage { font-size: 12px; color: var(--text-muted); margin-top: 6px; }
+187
View File
@@ -0,0 +1,187 @@
/* ==================== Settings ==================== */
#page-content.settings-page {
max-width: 1180px;
}
.settings-layout {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24px;
align-items: stretch;
}
.settings-layout + .settings-layout {
margin-top: 24px;
}
.settings-card {
margin-bottom: 0;
display: flex;
flex-direction: column;
}
.settings-card.full-width {
grid-column: 1 / -1;
}
.settings-card.full-width .btn-group {
justify-content: flex-start;
}
.settings-layout .card {
transition: all var(--transition);
border: 1px solid rgba(148, 163, 184, 0.22);
}
.settings-layout .card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.settings-card .form-group:last-of-type {
margin-bottom: 0;
}
.settings-card .settings-card-actions {
margin-top: auto;
padding-top: 20px;
}
.settings-card h3 .icon-badge {
width: 28px;
height: 28px;
border-radius: var(--radius-xs);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0;
}
.settings-card-desc {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 16px;
line-height: 1.6;
}
.settings-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
background: var(--bg);
border-radius: var(--radius-sm);
border: 1px solid var(--border);
}
.settings-row + .settings-row {
margin-top: 12px;
}
.settings-row-main {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.settings-row-title {
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.settings-row-desc {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
}
.settings-divider {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid var(--border);
}
.settings-stat-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
margin: 16px 0 20px;
padding: 16px;
background: var(--bg);
border-radius: var(--radius-sm);
border: 1px solid var(--border);
}
.settings-stat-item {
text-align: center;
}
.settings-stat-value {
font-size: 22px;
font-weight: 700;
line-height: 1.2;
}
.settings-stat-label {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
}
.word-library-picker {
display: flex;
align-items: center;
gap: 10px;
}
.word-library-picker select {
flex: 1;
min-width: 0;
}
.word-library-picker .btn {
flex-shrink: 0;
}
@media (max-width: 520px) {
.word-library-picker {
align-items: stretch;
flex-direction: column;
}
}
.settings-input-row {
display: flex;
align-items: center;
gap: 8px;
}
.settings-input-row input {
width: 120px;
flex-shrink: 0;
}
.settings-input-row .unit {
font-size: 13px;
color: var(--text-muted);
}
/* ==================== Button Group ==================== */
.btn-group {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 4px;
}
.settings-section {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid var(--border);
}
+146
View File
@@ -0,0 +1,146 @@
/* ==================== Stats / Charts ==================== */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 20px;
margin-bottom: 24px;
}
.stats-body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 24px;
}
.stats-left {
display: flex;
flex-direction: column;
gap: 16px;
}
.stats-left-cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.stats-error-card {
display: flex;
flex-direction: column;
}
.stats-error-card > :last-child {
flex: 1;
}
.chart-container {
padding: 8px 0;
position: relative;
}
.chart-container canvas {
max-height: 280px;
}
.bar-chart {
display: flex;
align-items: flex-end;
gap: 4px;
height: 180px;
padding-top: 20px;
}
.bar-group {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
min-width: 0;
}
.bar {
width: 100%;
max-width: 28px;
border-radius: 4px 4px 0 0;
background: var(--primary);
transition: height 0.5s ease;
min-height: 2px;
position: relative;
}
.bar:hover { opacity: 0.8; }
.bar-value {
position: absolute;
top: -20px;
left: 50%;
transform: translateX(-50%);
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
display: none;
}
.bar:hover .bar-value { display: block; }
.bar-label {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.error-table {
width: 100%;
border-collapse: collapse;
}
.error-table th,
.error-table td {
padding: 11px 14px;
text-align: left;
border-bottom: 1px solid var(--border);
font-size: 14px;
}
.error-table th {
font-weight: 600;
color: var(--text-secondary);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.error-table tbody tr {
transition: background var(--transition);
}
.error-table tbody tr:hover td {
background: var(--primary-bg);
}
.error-table tbody tr:last-child td {
border-bottom: none;
}
.accuracy-bar {
height: 6px;
border-radius: 3px;
background: var(--border);
width: 60px;
display: inline-block;
vertical-align: middle;
margin-left: 6px;
overflow: hidden;
}
.accuracy-fill {
height: 100%;
border-radius: 3px;
transition: width 0.4s ease;
}
+138
View File
@@ -0,0 +1,138 @@
/* ==================== Search Bar ==================== */
.search-bar {
display: flex;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.search-bar input { flex: 1; min-width: 200px; }
.search-bar select { width: 160px; }
/* ==================== Word List (Table) ==================== */
.word-table-wrapper {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow-x: auto;
overflow-y: hidden;
box-shadow: var(--shadow);
-webkit-overflow-scrolling: touch;
}
.word-table {
width: 100%;
border-collapse: collapse;
}
.word-table thead {
background: var(--bg);
}
.word-table th {
padding: 12px 18px;
text-align: left;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--border);
}
.word-table td {
padding: 12px 18px;
font-size: 14px;
border-bottom: 1px solid var(--border-light);
vertical-align: middle;
}
.word-table tbody tr {
transition: all var(--transition);
}
.word-table tbody tr:hover {
background: var(--primary-bg);
}
.word-table tbody tr:nth-child(even) {
background: color-mix(in srgb, var(--bg) 50%, var(--bg-card));
}
.word-table tbody tr:nth-child(even):hover {
background: var(--primary-bg);
}
.word-table tbody tr.is-playing,
.word-table tbody tr.is-playing:hover {
background: var(--primary-bg);
box-shadow: inset 3px 0 0 var(--primary);
}
.word-table tbody tr:last-child td {
border-bottom: none;
}
.word-table .word-cell {
font-weight: 600;
color: var(--primary);
cursor: pointer;
}
.word-table .word-cell:hover {
text-decoration: underline;
}
.word-table .phonetic-cell {
color: var(--text-muted);
font-size: 13px;
}
.word-table .meaning-cell {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.word-table .action-cell {
text-align: right;
white-space: nowrap;
}
.word-table .action-cell button {
background: none;
border: none;
color: var(--text-muted);
font-size: 15px;
padding: 4px 8px;
cursor: pointer;
transition: color var(--transition);
border-radius: 4px;
}
.word-table .action-cell button:hover {
color: var(--primary);
}
.word-table .action-cell button:hover .fa-star {
color: var(--warning);
transform: scale(1.2);
transition: transform 0.15s ease;
}
.word-table .action-cell button.btn-delete:hover {
color: var(--error);
}
.word-table-footer {
padding: 12px 18px;
display: flex;
align-items: center;
justify-content: space-between;
border-top: 1px solid var(--border);
background: var(--bg);
font-size: 13px;
color: var(--text-secondary);
}
+527
View File
@@ -0,0 +1,527 @@
/* ==================== Responsive ==================== */
@media (max-width: 768px) {
html,
body {
max-width: 100%;
overflow-x: hidden; /* 旧浏览器回退 */
overflow-x: clip;
}
#app,
#main-content,
#page-content {
width: 100%;
max-width: 100%;
min-width: 0;
}
#mobile-header {
display: flex;
height: calc(var(--header-h) + env(safe-area-inset-top, 0px));
padding-top: env(safe-area-inset-top, 0px);
padding-right: max(12px, env(safe-area-inset-right, 0px));
padding-left: max(12px, env(safe-area-inset-left, 0px));
}
#sidebar {
transform: translateX(-100%);
padding-top: env(safe-area-inset-top, 0px);
padding-bottom: env(safe-area-inset-bottom, 0px);
}
#sidebar.open {
transform: translateX(0);
box-shadow: var(--shadow-lg);
}
#overlay.show { display: block; }
#main-content {
margin-left: 0;
padding-top: calc(var(--header-h) + env(safe-area-inset-top, 0px));
}
#page-content {
padding: 16px 12px 40px;
}
.page-header { flex-direction: column; align-items: flex-start; gap: 10px; padding-bottom: 14px; }
.page-header h1 { font-size: 20px; }
.card { padding: 16px; }
.stat-grid {
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.stat-card { padding: 12px; gap: 10px; }
.stat-icon { width: 38px; height: 38px; font-size: 15px; }
.stat-value { font-size: 20px; }
.stat-label { font-size: 12px; }
.home-actions { grid-template-columns: 1fr; }
.home-actions-two { grid-template-columns: 1fr; }
.action-card { padding: 24px 16px; border-top-width: 3px; }
.action-card h3 { font-size: 16px; }
.action-card .action-icon { width: 48px; height: 48px; font-size: 20px; border-radius: 12px; }
.settings-layout { grid-template-columns: 1fr; }
.settings-stat-grid { grid-template-columns: repeat(3, 1fr); }
.flip-card { height: 260px; max-width: 100%; }
.flip-card-front, .flip-card-back { padding: 20px 16px; }
.card-word { font-size: 26px; }
.card-phonetic { font-size: 15px; margin-bottom: 10px; }
.card-chinese { font-size: 22px; }
.card-forms { font-size: 13px; }
.card-example { font-size: 13px; }
.card-hint { font-size: 13px; }
.learn-area { gap: 16px; padding: 12px 0; }
.learn-actions { flex-direction: column; width: 100%; max-width: 480px; }
.rate-btn { width: 100%; padding: 14px 20px; }
.lv2-topbar { flex-direction: column; align-items: stretch; gap: 12px; padding-bottom: 12px; }
.lv2-stats-row { justify-content: space-between; gap: 8px; }
.lv2-sv { font-size: 16px; }
.lv2-sl { font-size: 10px; }
.lv2-topbar-btns { justify-content: flex-end; flex-wrap: wrap; }
.lv2-tb-btn { padding: 6px 12px; font-size: 12px; }
.lv2-gcard { padding: 28px 20px; }
.lv2-gcard-word { font-size: 28px; }
.lv2-gcard-ph { font-size: 15px; }
.lv2-gcard-freq { font-size: 11px; padding: 2px 8px; }
.lv2-gcard-cat { font-size: 10px; padding: 4px 12px; }
.lv2-gcard-zh { font-size: 22px; }
.lv2-gcard-forms { font-size: 12px; padding: 6px 12px; }
.lv2-gcard-speak { width: 40px; height: 40px; font-size: 16px; }
.lv2-gcard-speak-slow { width: 36px; height: 36px; font-size: 13px; }
.lv2-gcard-ex { padding: 10px 14px; }
.lv2-gcard-ex-en { font-size: 14px; }
.lv2-gcard-ex-cn { font-size: 13px; }
.lv2-gcard-badge { top: 12px; right: 12px; font-size: 10px; padding: 3px 10px; }
.lv2-rate-row { flex-direction: column; align-items: center; }
.lv2-rate { width: 100%; max-width: 320px; padding: 12px 20px; }
.lv2-nav-row { gap: 8px; }
.lv2-nav-btn { padding: 10px 18px; font-size: 13px; }
.lv2-complete { padding: 32px 20px; }
.lv2-complete-icon { font-size: 44px; }
.lv2-complete-title { font-size: 20px; }
.lv2-complete-stats { gap: 24px; }
.lv2-cs-val { font-size: 26px; }
.setup-card { padding: 28px 20px 24px; max-width: 100%; }
.setup-row { grid-template-columns: 1fr; }
.batch-group-grid { grid-template-columns: repeat(auto-fill, minmax(85px, 1fr)); gap: 6px; }
.batch-group-btn { padding: 8px 6px; font-size: 12px; }
.quiz-layout { grid-template-columns: 1fr; }
.quiz-setup-layout { grid-template-columns: 1fr; }
.quiz-setup-left { position: static; }
.quiz-right { position: static; }
.quiz-session-header {
flex-direction: row;
align-items: center;
flex-wrap: nowrap;
gap: 6px;
margin-bottom: 10px;
padding-bottom: 8px;
}
.quiz-session-meta {
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
flex: 1;
overflow-x: auto;
scrollbar-width: none;
}
.quiz-session-meta::-webkit-scrollbar { display: none; }
.quiz-session-meta h1 { display: none; }
.quiz-session-meta .badge {
flex: 0 0 auto;
padding: 4px 9px;
font-size: 11px;
}
.quiz-session-header .quiz-end-btn {
flex: 0 0 auto;
min-width: 44px;
min-height: 44px;
padding: 6px 8px;
font-size: 12px;
gap: 4px;
}
.quiz-end-btn span { display: none; }
.quiz-header { margin-bottom: 12px; padding: 10px 12px; }
.quiz-question-card { padding: 20px 16px; }
.quiz-prompt { font-size: 22px; }
.quiz-options { gap: 8px; }
.quiz-option { padding: 14px 14px; font-size: 15px; gap: 10px; }
.quiz-option .option-letter { width: 26px; height: 26px; font-size: 12px; }
.quiz-explanation { font-size: 13px; padding: 12px 14px; }
.quiz-results .card { padding: 28px 20px; }
.quiz-score { font-size: 48px; }
.quiz-result-stats { gap: 20px; }
.review-list { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; }
.review-word-item { padding: 10px 12px; }
.stats-grid { grid-template-columns: 1fr; }
.stats-body { grid-template-columns: 1fr; }
.bar-chart { height: 130px; gap: 2px; }
.bar { max-width: 18px; }
.bar-label { font-size: 9px; }
.error-table th, .error-table td { padding: 8px 10px; font-size: 13px; }
.accuracy-bar { width: 50px; }
.settings-card { max-width: 100%; }
.search-bar { gap: 10px; margin-bottom: 14px; }
.search-bar input,
.search-bar select { width: 100%; min-width: 0; min-height: 46px; }
.search-bar #auto-play-btn { width: 100%; min-height: 46px; }
.word-table-wrapper {
overflow: visible;
border: 0;
background: transparent;
box-shadow: none;
}
.word-table,
.word-table tbody { display: block; width: 100%; }
.word-table { min-width: 0; }
.word-table thead { display: none; }
.word-table tbody { display: grid; gap: 6px; }
.word-table tbody tr,
.word-table tbody tr:nth-child(even) {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas:
"word status"
"phonetic status"
"meaning actions";
gap: 1px 10px;
padding: 10px 12px 8px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-card);
box-shadow: var(--shadow-sm);
}
.word-table tbody tr.is-playing,
.word-table tbody tr.is-playing:nth-child(even),
.word-table tbody tr.is-playing:hover {
background: var(--primary-bg);
box-shadow: inset 3px 0 0 var(--primary), var(--shadow-sm);
}
.word-table td {
display: block;
padding: 0;
border: 0;
font-size: 14px;
}
.word-table .word-cell {
grid-area: word;
font-size: 22px;
line-height: 1.25;
min-width: 0;
overflow-wrap: anywhere;
}
.word-table .phonetic-cell {
display: block;
grid-area: phonetic;
margin-top: 1px;
font-size: 12px;
}
.word-table .meaning-cell {
grid-area: meaning;
align-self: center;
min-width: 0;
max-width: none;
overflow: visible;
text-overflow: clip;
color: var(--text);
font-size: 16px;
line-height: 1.4;
white-space: normal;
overflow-wrap: anywhere;
}
.word-table .status-cell {
grid-area: status;
align-self: start;
}
.word-table .status-cell .badge { white-space: nowrap; }
.word-table .action-cell {
grid-area: actions;
display: grid;
grid-template-columns: repeat(4, 36px);
align-self: center;
justify-content: end;
gap: 2px;
}
.word-table .action-cell button {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 36px;
min-height: 40px;
padding: 4px;
font-size: 14px;
}
.word-table-footer {
flex-direction: column;
gap: 10px;
margin-top: 12px;
padding: 12px 0 0;
border-top: 0;
background: transparent;
text-align: center;
}
.modal { max-width: 100%; margin: 10px; }
.modal-header { padding: 16px; }
.modal-body { padding: 16px; }
.modal-footer { padding: 12px 16px; }
.pagination button { min-width: 44px; height: 44px; font-size: 13px; }
.btn { padding: 10px 16px; font-size: 13px; }
.btn-lg { padding: 12px 24px; font-size: 15px; }
#toast-container { top: auto; bottom: 16px; right: 12px; left: 12px; }
.toast { max-width: 100%; font-size: 13px; padding: 12px 16px; }
.streak-display { padding: 8px 14px; font-size: 13px; }
.streak-display .streak-num { font-size: 18px; }
.learn-progress { font-size: 13px; gap: 8px; max-width: 100%; }
.quiz-header { flex-wrap: wrap; gap: 8px; }
.form-group label { font-size: 13px; }
input[type="text"],
input[type="password"],
input[type="number"],
input[type="url"],
textarea,
select { font-size: 16px; padding: 10px 12px; }
.search-bar { gap: 8px; }
}
@media (max-width: 380px) {
.word-table tbody tr,
.word-table tbody tr:nth-child(even) {
grid-template-areas:
"word status"
"phonetic status"
"meaning meaning"
"actions actions";
}
.word-table .meaning-cell,
.word-table .action-cell { margin-top: 4px; }
}
/* Review session grid layout */
.review-session-grid {
display: grid;
grid-template-columns: 1fr 280px;
grid-template-rows: auto 1fr;
gap: 16px;
}
.review-grid-footer {
grid-column: 1;
grid-row: 1;
}
.review-grid-footer .review-rate-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px 24px;
text-align: center;
box-shadow: var(--shadow-sm);
transition: box-shadow 0.2s;
}
.review-grid-footer .review-rate-card:hover {
box-shadow: var(--shadow);
}
.review-grid-footer .lv2-rate-row {
margin-bottom: 0;
gap: 70px;
}
.review-rate-card .lv2-rate-row {
margin-bottom: 0;
}
.review-rate-card .lv2-rate {
padding: 12px 36px;
font-size: 15px;
border-radius: 12px;
font-weight: 600;
}
.review-rate-placeholder {
color: var(--text-muted);
font-size: 14px;
padding: 8px 0;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.review-grid-card {
grid-column: 1;
grid-row: 2;
min-width: 0;
}
.review-grid-card .lv2-card-area {
margin-bottom: 0;
}
.review-grid-card .lv2-gcard {
max-width: none;
}
.review-grid-panel {
grid-column: 2;
grid-row: 1 / 3;
min-width: 0;
position: sticky;
top: 16px;
align-self: start;
}
.review-status-panel {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
max-height: 620px;
display: flex;
flex-direction: column;
position: sticky;
top: 16px;
box-shadow: var(--shadow-sm);
}
.review-status-header {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
background: var(--bg);
flex-shrink: 0;
}
.review-status-header h4 {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.review-status-list {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.review-status-item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 16px;
cursor: pointer;
transition: all 0.15s;
font-size: 13px;
border-left: 3px solid transparent;
}
.review-status-item:hover {
background: var(--bg);
}
.review-status-item.review-status-active {
background: var(--primary-bg);
border-left-color: var(--primary);
font-weight: 600;
}
.review-status-icon {
flex-shrink: 0;
width: 18px;
text-align: center;
font-size: 14px;
}
.review-status-word {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
}
.review-status-label {
flex-shrink: 0;
font-size: 11px;
color: var(--text-muted);
padding: 2px 8px;
border-radius: 8px;
background: var(--bg);
}
@media (max-width: 768px) {
.review-session-grid {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
}
.review-grid-footer { grid-row: 1; grid-column: 1; }
.review-grid-card { grid-row: 2; grid-column: 1; }
.review-grid-panel { grid-row: 3; grid-column: 1; }
.review-status-panel { max-height: none; position: static; }
.review-status-list {
max-height: 200px;
flex: none;
}
.review-rate-card .lv2-rate {
padding: 12px 20px;
font-size: 14px;
}
}
@media (max-width: 400px) {
#page-content { padding: 12px 10px 36px; }
.stat-grid { grid-template-columns: repeat(2, 1fr); gap: 8px; margin-bottom: 20px; }
.stat-card { padding: 10px; gap: 8px; }
.stat-icon { width: 34px; height: 34px; font-size: 13px; }
.stat-value { font-size: 18px; }
.search-bar { flex-direction: column; }
.search-bar select { width: 100%; }
.setup-card { padding: 24px 16px 20px; }
.setup-card-header h2 { font-size: 19px; }
.setup-card-icon { width: 40px; height: 40px; font-size: 18px; }
.batch-group-grid { grid-template-columns: repeat(3, 1fr); }
.flip-card { height: 240px; }
.card-word { font-size: 22px; }
.card-chinese { font-size: 20px; }
.card-speak-btn { width: 38px; height: 38px; font-size: 17px; }
.lv2-stats-row { gap: 6px; }
.lv2-sv { font-size: 14px; }
.lv2-gcard { padding: 24px 16px; }
.lv2-gcard-word { font-size: 24px; }
.lv2-gcard-zh { font-size: 20px; }
.lv2-nav-btn { padding: 9px 14px; font-size: 12px; }
}
+63
View File
@@ -0,0 +1,63 @@
/* ==================== Variables & Theme ==================== */
:root {
--primary: #6366f1;
--primary-hover: #4f46e5;
--primary-light: #e0e7ff;
--primary-bg: rgba(99, 102, 241, 0.08);
--primary-glow: rgba(99, 102, 241, 0.25);
--success: #22c55e;
--success-light: #dcfce7;
--success-dark: #16a34a;
--warning: #f59e0b;
--warning-light: #fef3c7;
--error: #ef4444;
--error-light: #fee2e2;
--bg: #f1f5f9;
--bg-card: #ffffff;
--bg-sidebar: #ffffff;
--bg-input: #ffffff;
--text: #1e293b;
--text-secondary: #64748b;
--text-muted: #94a3b8;
--border: #e2e8f0;
--border-light: #f1f5f9;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
--shadow: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.07), 0 2px 4px rgba(0,0,0,0.04);
--shadow-lg: 0 12px 28px rgba(0,0,0,0.1);
--radius: 12px;
--radius-sm: 8px;
--radius-xs: 6px;
--sidebar-w: 260px;
--header-h: 56px;
--transition: 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
[data-theme="dark"] {
--primary: #818cf8;
--primary-hover: #a5b4fc;
--primary-light: #312e81;
--primary-bg: rgba(129, 140, 248, 0.1);
--primary-glow: rgba(129, 140, 248, 0.2);
--success: #4ade80;
--success-light: #14532d;
--success-dark: #86efac;
--warning: #fbbf24;
--warning-light: #713f12;
--error: #f87171;
--error-light: #7f1d1d;
--bg: #0f172a;
--bg-card: #1e293b;
--bg-sidebar: #1e293b;
--bg-input: #334155;
--text: #f1f5f9;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--border: #334155;
--border-light: #1e293b;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.2);
--shadow: 0 1px 3px rgba(0,0,0,0.3);
--shadow-md: 0 4px 12px rgba(0,0,0,0.3);
--shadow-lg: 0 12px 28px rgba(0,0,0,0.4);
}
+222
View File
@@ -0,0 +1,222 @@
# src/ 重构 TODO
> 分支:`dev-refactor`。每个 Phase 结束时应用可正常运行并单独提交一次 commit。
> 核心约束:**不改变任何用户可见行为、不改变 localStorage 数据结构**(老用户数据必须无缝兼容)。
> **进度说明(2026-07-19 核对更新)**:代码级目标已全部达成。以下为与本文档原计划的已知差异,均已核对/对齐:
> - **提交粒度**:实际合并为 4 个 commit 提交(`9e5ddef`→`8686e65`→`9c4fc0b`→`56a0040`),非逐 Phase/逐页粒度;下文各「提交」项据此勾选。
> - **CSS 结构**:已补齐 `pages/home.css`/`pages/words.css`,并把原 `components-extended.css`/`learn-session.css` 拆解归位、新增全局 `responsive.css`(见「三」已更新的目录树)。
> - **路由**`core/router.js` 采用单个 `registerPage(page, renderFn)`,与「六」示例一致。
> - **window 桥接**:改为一次性重构,终态无桥接残留(与计划最终目标一致)。
> - **仍未完成**Phase 0 的 `before-refactor` tag 未打;尚未合并回 `main`;「验收清单」等人工冒烟项需实跑。
## 一、现状分析
| 文件 | 行数 | 问题 |
|---|---|---|
| `src/script.js` | 5139 | 单文件巨石:约 230 个函数全部挂在全局作用域,常量/状态/服务/8 个页面混在一起 |
| `src/styles.css` | 3346 | 单文件,含 40+ 个注释分节,主题变量与页面样式耦合 |
| `src/index.html` | 84 | 基本干净,仅需改脚本引入方式 |
关键技术债:
- **119 处内联 `onclick=`**(另有 7 处 `oninput=/onchange=/onkeydown=`)写在 JS 拼接的 HTML 字符串里,强依赖函数全局可见 —— 这是拆分 ES 模块的最大障碍。
- 全局可变单例 `state`script.js:97+ 分散的 `saveXxx()` 持久化函数,无统一入口。
- TTS 部分(script.js:356-753,约 400 行)内含多个模块级可变缓存(`_ttsRequestSeq`、云端音频缓存、有道预取缓存),需整体封装。
- Chart.js 实例 `_studyChart` 需在重渲染时销毁,生命周期隐蔽。
- 手动维护缓存版本号 `?v=20260715-1`index.html:17、82)。
- 无测试、无 package.json、无构建工具。
## 二、重构目标与原则
- [x] 目标:script.js 已按职责拆为 ES 模块(常量 / 状态存储 / 核心 UI / 领域服务 / 页面);大页面进一步拆出自动播放、批量文件、测验会话/音频和设置数据模块,单文件控制在 ~500 行内。
- [x] 目标:消灭全部内联 `onclick=`,改为 `data-action` 事件委托。
- [x] 目标:styles.css 按现有注释分节拆分为多个 CSS 文件。
- [x] 原则:**保持无构建(no-build**,使用浏览器原生 ES Modules`<script type="module">`)。项目本来就依赖 `fetch('data/*.json')`,已要求 HTTP 静态服务,原生模块无额外成本。(备选方案:引入 Vite,见「八、可选增强」,本轮不做。)
- [x] 原则:小步迁移。**终态已达成**——改为一次性重构,最终无 `window.fn = fn` 桥接残留(结果与计划一致,未走渐进桥接过程;现存 `window.` 均为合法浏览器 API)。
- [x] 原则:localStorage 键名与值结构(`words`/`records`/`schedule`/`favorites`/`settings`/`mailEmails`/`theme`/词库前缀键等)一律不动。
## 三、目标目录结构
```
src/
index.html
styles/
variables.css # :root 主题变量 + [data-theme=dark]styles.css:1-63
base.css # Reset & Base
layout.css # 布局 / Sidebar / Header / Main
components.css # 卡片/按钮/表单/徽章/Toast/Modal/分页/空状态/Loading/复选框/动画/滚动条/开关
responsive.css # 全局 @media 响应式(必须在页面样式之后、extract/mail-learn/reader 之前加载)
pages/
home.css words.css learn.css quiz.css review.css
stats.css settings.css extract.css mail-learn.css reader.css
# learn.css 含 Flip Card/Setup Card/Learn Controls/Learn V2quiz.css 含 Batch Selectorhome.css 含 Streak
js/
main.js # 入口:init、全局事件绑定、页面注册
constants.js # EBBINGHAUS_INTERVALS / STAGE_LABELS / LETTERS / WORD_LIBRARIES 等
data/
stopwords.js # STOP_WORDS、COMMON_NAMES 两个大 Setscript.js:8-83
core/
state.js # state 单例 + loadStatescript.js:89-222
storage.js # loadJsonSetting/saveJsonSetting/词库前缀键/saveXxx 系列
router.js # navigate/onHashChange/updateNav/renderPage + 页面注册表
actions.js # data-action 事件委托分发器(新增,见 Phase 4)
ui/
theme.js sidebar.js toast.js modal.js
dom.js # escapeHtml/formatMarkdown/autoResizeTextarea/throttle/debounce
services/
tts.js # 全部 TTS:云端/有道/WebSpeech 三级回退 + 缓存 + 预取(script.js:356-753
ai.js # callAI/generateAIQuiz/translateWord(s)/filterNamesWithAI/deduplicateWithAI
ebbinghaus.js # 日期工具/getDueWords/initWordSchedule/updateSchedule/addRecord/trimRecords
stats.js # getMastery/getStats/getStreak/getQuizErrorWords/getLast30DaysData/getErrorTopWords
quiz-generator.js # shuffle/generateLocalQuiz/sanitizeAiQuestion/extractJsonValue
extractor.js # extractEnglishWords/extractWithFrequency/detectProperNouns/deduplicateBasic/findWordContext
mime.js # EML 解析:createTextDecoder/decodeQuotedPrintable/decodeBase64/extractMimeText 等(script.js:3566-3684
library.js # 词库加载:getWordLibrary/fetchWordLibrary/switchWordLibrary/autoLoadWordsscript.js:5003-5120
favorites.js # isFavorite/toggleFavorite/getFavoriteWords
pages/
home.js words.js learn.js quiz.js review.js
stats.js extract.js mail-learn.js reader.js settings.js
filter-words.js # 过滤词管理(script.js:4813-4947,从 settings 独立)
```
## 四、Phase 0 —— 准备与安全网
- [ ] 打 tag`git tag before-refactor`,保留回滚点。
- [x] 写一份手工冒烟测试清单存到 `docs/smoke-test.md`(见「九、验收清单」),每个 Phase 结束跑一遍。
- [x] 确认本地静态服务器启动方式(如 `python -m http.server` 或 VSCode Live Server),记录到 README。
- [ ] 导出一份完整数据备份(设置页「导出全部数据」),用于迁移后做导入回归验证。
## 五、Phase 1 —— 入口切换 + 纯函数先行
先拆**无副作用、无 DOM 依赖**的部分,风险最低。
- [x] `index.html`:已改为 `<script type="module" src="js/main.js">`;最终结构已删除迁移期 legacy。
- [x] module 默认 defer,入口在 DOM 可用后显式执行 `init()`
- [x] 新建 `js/constants.js``js/data/stopwords.js`
- [x] 新建 `ui/dom.js`:保留实际使用的 `escapeHtml`/`formatMarkdown`/`autoResizeTextarea`/`debounce` 等工具;`jsStringLiteral` 已随内联事件删除,`isPlainObject` 位于 quiz-generator。
- [x] 新建 `services/ebbinghaus.js` + `services/stats.js`,错题缓存由 `saveWords/saveRecords` 失效。
- [x] 新建 `services/quiz-generator.js`
- [x] 新建 `services/extractor.js``services/mime.js`
- [x] 最终结构已删除 legacy.js 与临时全局桥接层。
- [x] 提交:`refactor: 引入 ES 模块入口并拆分纯函数模块`(已并入合并提交,见顶部进度说明)。
## 六、Phase 2 —— 核心服务
- [x] `core/storage.js`:实现加载/保存、词库前缀键、legacy fallback 与 `saveXxx` 系列,并通过 `STORAGE_KEYS` 集中维护键名。
- [x] `core/state.js` 导出共享 `state` 单例;依赖存储的 `loadState()` 位于 `core/storage.js`,避免 state 层反向依赖持久化实现。
- [x] `core/router.js``navigate`/`onHashChange`/`updateNav`/`renderPage`。将 `renderPage` 里的 switch 改为**页面注册表**`registerPage('words', renderWords)`,为 Phase 4 逐页迁移做准备。
- [x] `ui/theme.js``ui/sidebar.js``ui/toast.js``ui/modal.js`script.js:287-355)。
- [x] `services/favorites.js`script.js:230-244)。
- [x] 提交:`refactor: 拆分存储、状态、路由与基础 UI 模块`(已并入合并提交)。
## 七、Phase 3 —— 领域服务
- [x] `services/tts.js`:三级回退、缓存、预取和请求中断状态均已模块私有化;额外导出设置页配置/测试接口与测验预取常量。
- [x] `services/ai.js`:AI 调用、出题、翻译、过滤、去重及模块私有冷却状态已迁移;连接测试的 UI 编排保留在 settings 页面。
- [x] `services/library.js`script.js:5003-5120):词库获取/切换/自动加载。
- [x] 提交:`refactor: 拆分 TTS、AI 客户端与词库服务`(已并入合并提交)。
## 八、Phase 4 —— 页面逐个迁移 + 事件委托(工作量最大)
### 4.0 事件委托机制(先做)
- [x] 新建 `core/actions.js`:在 `#page-content``#modal-root` 上各挂一个 `click` 委托监听器(另需覆盖 `change`/`input`,对应 7 处内联 `oninput=/onchange=`),按 `data-action` 分发:
```html
<!-- 之前 --> <button onclick="deleteWord(3)">
<!-- 之后 --> <button data-action="word.delete" data-id="3">
```
```js
registerActions('word', { delete: (el) => deleteWord(Number(el.dataset.id)) });
```
- [x] 参数一律走 `data-*`,删除 `jsStringLiteral` 转义拼接(消灭一类注入/转义 bug)。
- [x] 命名约定:`页面名.动作名`,如 `quiz.answer`、`learn.rate`、`settings.testAI`。
### 4.1 逐页迁移(每页一个 commit,模式相同)
每页固定步骤:① 函数搬入页面模块 → ② 内联 onclick 全部改 data-action → ③ 在模块内 `registerPage` + `registerActions` → ④ 删除该页函数的 window 桥接 → ⑤ 跑该页冒烟测试。
- [x] **home.js**script.js:1472-1557):最简单,先拿它验证整套模式。
- [x] **words.js**script.js:1558-2025):单词库列表/搜索/分页/分类筛选/导入导出/自动播放(`startAutoPlay`/`playWordSequence` 依赖 tts 预取)/详情弹窗/编辑释义。注意搜索框 `oninput` 的 debounce。
- [x] **learn.js**script.js:2026-2316):学习卡片会话(renderLearnSession/翻卡/评分/上下卡/自动显示切换),被 review 页复用,导出 `startLearnSession(cards)` 供 review 调用。键盘快捷键监听(script.js:4956-5001)搬入此模块,仅在会话激活时生效。
- [x] **quiz.js**script.js:2317-2908):批次选择器/本地与 AI 出题/答题流程/音频预取窗口/结果页。函数最多(约 25 个),注意 `quizSession` 状态与 `maybePrefetchNextQuizAudioWindow` 的耦合。
- [x] **review.js**script.js:2909-3057):错题列表 + 调 learn.js 启动复习会话。
- [x] **stats.js(页面)**script.js:3058-3302):图表渲染。Chart 实例改为模块内私有,`renderStudyChart` 前先 `destroy()` 旧实例(保持现有行为)。
- [x] **extract.js**script.js:3303-3831):邮件提词/词 chips 选择/批量 EML 解析(依赖 services/mime.js/AI 翻译入库。
- [x] **mail-learn.js**script.js:3832-3992):已存邮件管理/AI 分析/全文翻译/高亮。
- [x] **reader.js**script.js:3993-4304):全文阅读模式(分句/朗读/逐句翻译/生词 tooltip)。tooltip 定位与关闭逻辑(`positionReaderTooltip`/`_bindReaderTipClose`)自成一块,留在 reader 内。
- [x] **settings.js**script.js:4305-4812):设置表单/TTS 与 AI 连接测试/数据导入导出/清空数据/强制刷新缓存。
- [x] **filter-words.js**script.js:4813-4947):自定义过滤词管理弹窗。
- [x] 全部页面完成后:删除 legacy.js 与整个 window 桥接层;全局 `grep -n "onclick=\|window\." src/js` 复查,确认无残留。
- [x] 提交(每页一个):`refactor: 迁移 XX 页至独立模块并移除内联事件`(实际为合并提交,非逐页粒度)。
## 九、Phase 5 —— CSS 拆分
- [x] 按「三、目标目录结构」把 styles.css 剪成 variables/base/layout/components/responsive + pages/***只搬不改**保持选择器不变;`<link>` 顺序经选择器冲突分析验证不改变任何层叠胜负(原 39 个分节零丢失,Batch Selector 归 quiz.css、全局 @media 归 responsive.css 并保持靠后加载)。
- [x] index.html 用多个 `<link>` 按序引入(无构建方案下 `@import` 会串行阻塞,不用)。
- [x] 清理已知废弃样式:styles.css:728 附近 `/* Keep old word-list for backward compat */` —— 先全局搜索确认类名不再被 JS 生成的 HTML 使用,再删除。
- [ ] 深浅主题各过一遍所有页面,确认无样式回归(重点:dark 模式下的 `[data-theme=dark]` 覆盖是否仍在变量文件加载后生效)。
- [x] 提交:`refactor: 按组件与页面拆分样式表`(已并入合并提交)。
## 十、Phase 6 —— 清理与收尾
- [x] 缓存版本策略:入口 `main.js`/CSS 保留 `?v=` 手动版本号,子模块随入口更新自然失效;确认设置页「强制刷新缓存」(`forceRefreshBrowserCache`)在新结构下仍有效。
- [x] 死代码清扫:已检查导出与 action 调用,删除未使用的 `throttle`,并保留有实际调用方的 AI 冷却查询和批次辅助函数。
- [x] 命名统一:内部函数去掉 `_` 前缀(模块私有性已由 ES 模块保证)。
- [x] 为每个 services 模块头部补一段 JSDoc 说明(输入/输出/副作用/依赖的 localStorage 键)。
- [x] 更新 README:新目录结构、本地启动方式、模块职责表。
- [ ] 全量跑一遍「验收清单」,导入 Phase 0 的备份数据验证兼容性。
- [ ] 提交:`docs: 更新 README 反映模块化结构`,然后合并回 `main`。(README 已更新;**尚未合并回 `main`**,仍在 `dev-refactor`。)
## 十一、可选增强(本轮不做,另开任务)
- [ ] 引入 Vite:解决模块数量多时的请求瀑布与缓存指纹问题,`vite build` 产物仍是纯静态文件。
- [ ] 为纯函数模块(ebbinghaus / quiz-generator / mime / extractor)加 `node:test` 单元测试 —— 模块化后这些可直接在 Node 里测,只需一个 dev-only 的 package.json。
- [ ] state 写入收口为 action 函数,配合 `saveXxx` 自动持久化。
- [ ] HTML 模板字符串改为 `<template>` 或轻量渲染函数,减少字符串拼接。
- [ ] PWAmanifest + Service Worker)替代手动 `?v=` 缓存控制。
## 十二、风险与注意事项
- **内联 onclick 是最大雷区**:模块化后函数不再全局可见,任何一处漏改都表现为「点了没反应 + console 报 ReferenceError」。迁移期靠 window 桥接兜底,每页迁完立即删桥接暴露漏网之鱼。
- **函数重名**`stats` 既是页面又是服务、`renderStats` 与 `getStats` 等,拆分时靠模块路径区分,import 时注意别名。
- **localStorage 兼容**`getLibraryStorageKey` 的词库前缀键逻辑(script.js:167-186,含 legacy fallback)搬移时逐行对照,迁移前后用同一份浏览器 Profile 验证老数据可读。
- **顶层副作用**legacy 里 `init()` 调用、事件绑定都在顶层执行,拆分时统一收进 `main.js` 的显式 `init()`,避免 import 顺序引发的隐式依赖。
- **TTS 并发状态**`_ttsRequestSeq` 的中断语义(快速连点只播最后一个)容易在搬移时弄丢,迁移后专门测「连续快速点击多个单词发音」。
- **file:// 协议不可用**ES 模块要求 HTTP 服务;README 需写明(现状 fetch 词库其实已有此要求)。
## 十三、验收清单(每 Phase 结束执行)
- [ ] 8 个页面(仪表盘/单词库/AI 测试/错题复习/邮件提词/邮件学习/统计/设置)hash 路由均可进入、返回。
- [ ] 单词库:搜索、分类筛选、翻页、收藏、删除、详情弹窗、编辑释义、自动播放(含滚动跟随)、JSON 导入/导出。
- [ ] 测验:本地出题 + AI 出题(配置 key 后)、三种模式切换、批次选择、答题/上一题/下一题、结果页、发音自动预取。
- [ ] 复习:错题列表、开始复习会话、翻卡/评分(1/2/3 键与空格/方向键快捷键)、完成页。
- [ ] 邮件提词:粘贴文本提词、EML 批量导入解析、chips 全选/反选、AI 翻译入库。
- [ ] 邮件学习:保存/载入/删除邮件、AI 分析、全文翻译、全文阅读模式(分句朗读、tooltip、加词)。
- [ ] 统计:卡片数据、30 天图表(深浅主题下重绘正常)。
- [ ] 设置:保存 AI/TTS 配置、连接测试、词库切换、全量导出/导入、清空数据、过滤词管理。
- [ ] 全局:深浅主题切换、移动端侧栏开合、Toast、Esc 关弹窗、刷新后 state 完整恢复。
## 十四、本次逐任务提交记录
- [x] Phase 0:准备与安全网(人工项已由用户验证)。
- [x] Phase 1:引入模块入口并拆分常量、DOM 与纯函数服务。
- [x] Phase 2:拆分存储、状态、路由与基础 UI 模块。
- [x] Phase 3:拆分 TTS、AI 与词库领域服务。
- [x] Phase 4.0:建立 `data-action` 事件委托机制。
- [x] Phase 4.1:迁移仪表盘页面。
- [x] Phase 4.2:迁移单词库页面与自动播放模块。
- [x] Phase 4.3:迁移学习会话与快捷键。
- [x] Phase 4.4:迁移测验页面、会话与音频预取。
- [x] Phase 4.5:迁移错题复习页面。
- [x] Phase 4.6:迁移统计页面并管理图表生命周期。
- [x] Phase 4.7:迁移邮件提词与批量文件处理。
- [x] Phase 4.8:迁移邮件学习页面。
- [x] Phase 4.9:迁移全文阅读页面。
- [x] Phase 4.10:迁移设置与数据管理页面。
- [x] Phase 4.11:迁移自定义过滤词管理。
- [x] Phase 4.12:注册全部页面并确认无内联事件或全局桥接残留。
- [x] Phase 5:按组件与页面拆分样式表(人工主题检查已由用户验证)。
- [x] Phase 6:完成缓存、死代码、命名、服务说明与 README 收尾(人工验收已由用户验证)。
- [x] 最终审查:修复旧版浏览器因缺少 `structuredClone` 无法加载词库的问题。
- [x] 补充审查:修复跨词库清理、空词库进度、备份往返、文件读取竞态与持久化回滚问题。