init
This commit is contained in:
@@ -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 Key(password 输入框)
|
||||||
|
- API URL(格式:`https://tohub.com/v1`)
|
||||||
|
- Model(默认 `gemini-3-flash`)
|
||||||
|
- 内置词库自动加载开关
|
||||||
|
- 数据管理(导出/导入备份、清空数据)
|
||||||
|
|
||||||
|
### 技术要求
|
||||||
|
|
||||||
|
- 不用数据库,数据存放在浏览器 localStorage
|
||||||
|
- 纯前端,无需构建工具
|
||||||
|
- 响应式设计,支持移动端
|
||||||
|
- 明暗主题切换
|
||||||
@@ -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
@@ -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 "$@"
|
||||||
@@ -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。
|
||||||
@@ -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
@@ -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>
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -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)); }); }
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
};
|
||||||
@@ -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); }
|
||||||
|
|
||||||
@@ -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'
|
||||||
|
]);
|
||||||
@@ -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); }
|
||||||
@@ -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(' + ') : '暂无来源';
|
||||||
|
}
|
||||||
@@ -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="将英文邮件内容粘贴到此处... 系统会自动提取英文单词(过滤常见停用词), 您可以选择需要的单词加入学习列表。" 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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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>` : ''}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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> 需重点复习(正确率 < 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));
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 Worker,Cache 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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>');
|
||||||
|
}
|
||||||
@@ -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">×</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(); }
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "ai-english-refactor",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"serve": "python -m http.server 8000 --directory .."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
@@ -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); }
|
||||||
|
|
||||||
@@ -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; }
|
||||||
|
|
||||||
@@ -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; }
|
||||||
|
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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; }
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 V2;quiz.css 含 Batch Selector;home.css 含 Streak
|
||||||
|
js/
|
||||||
|
main.js # 入口:init、全局事件绑定、页面注册
|
||||||
|
constants.js # EBBINGHAUS_INTERVALS / STAGE_LABELS / LETTERS / WORD_LIBRARIES 等
|
||||||
|
data/
|
||||||
|
stopwords.js # STOP_WORDS、COMMON_NAMES 两个大 Set(script.js:8-83)
|
||||||
|
core/
|
||||||
|
state.js # state 单例 + loadState(script.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/autoLoadWords(script.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>` 或轻量渲染函数,减少字符串拼接。
|
||||||
|
- [ ] PWA(manifest + 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] 补充审查:修复跨词库清理、空词库进度、备份往返、文件读取竞态与持久化回滚问题。
|
||||||
Reference in New Issue
Block a user