Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e069894c4e | ||
|
|
5f86f17ac4 |
@@ -282,6 +282,7 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才
|
||||
- #4 独立阅读选择小样位于 `spikes/selection/`,`python spikes/selection/serve.py` 默认仅本机 5184。桌面鼠标/键盘与 11 项测试已验证,#4 已获用户验收并关闭;真实手机长按/手柄/滚动详细证据仍未提供;禁止把窄屏桌面当作真机验收。Intl.Segmenter 只用于 UI 范围验证,不替代 #3 NLP;释义保存只在内存。固定 LinguaCafe 源码对照和与 v1 的差异记录见架构 Wiki。
|
||||
- 2026-09-11 用户确认正式 NLP/词典采用全 Go。#5 已验收并合入 main;#6 使用 Go WordNet 解析和词形候选、Go Unicode 原文分片、schema v4 共享词典资源表,不调用 Python NLP。WordNet 3.0 ZIP 来源与摘要见 `server/wordnet-resource.json`,许可保留在 `server/WORDNET-LICENSE.txt`。词形候选不等于上下文消歧,不自动合并个人学习状态;#3 Python 小样只保留历史验证。当前词典仅英语释义,个人释义输入为临时草稿,持久化归 #7。
|
||||
- 2026-09-11 用户确认 #7 个人词条口径(三项由 Agent 定案):身份为「学习者+语言+规范化词形」,大小写合并但**不按 lemma/候选合并**(`dog` 与 `dogs` 是两条记录);首次保存默认「新词」;状态为 新词/学习中/已知/忽略,只有「学习中」带 1~7 级,对应原版 stage 2/1/0/-1~-7;例句只保存手输内容,不自动关联原文句子。schema v5 新增 `lexgo_terms`(唯一键加状态/等级检查约束),个人释义与共享词典分离且不进入审计日志;等级编辑 UI 归 #8/#12。
|
||||
- 2026-09-15 用户确认 #14 显示与键盘口径:`theme ∈ {浅色,深色,跟随系统}`(默认跟随系统)与正文字号 `{标准,大,特大}`(1.0/1.15/1.3)**按账号保存在本机** `lexgo-learner-display:<账号 id>`,切换账号即换成该账号偏好或默认,退出回到默认,**不跨设备同步**;字号经 `--reader-font-scale` 只作用于阅读面(正文、释义内容、复习卡),不做全局缩放;深色用 `html[data-theme]` + Element Plus 的 `html.dark`,`style.css` 的 `:root` 是文件内仅有的颜色字面量;阅读位置按账号+章节保存滚动比例与该章 `content_sha256`,**正文换新版本后不恢复**;复习页 `空格`/`Enter` 显示答案、`1/2/3` 评分,输入类控件与聚焦按钮的按键不被劫持,带修饰键不拦截;移动验证用 390×844+`hasTouch` 的 Playwright `mobile` 项目(桌面项目 `testIgnore: mobile-*`),**真机长按选择与手感仍需人工确认**,不得用模拟设备结果冒充真机。本单无 schema 与接口变化。
|
||||
- 2026-09-15 用户确认 #13 完成阅读与进度口径:`POST /api/v1/chapters/:id/complete` 只记已读、**不批量改变词语状态或等级**,只有 `ready` 章节可标记(其他 409),重复调用返回同一行且带 `duplicate`(不移动时间、不重复计数);完成记录保存标记时的 `content_sha256`,**正文新版本后该章回到未读**(记录保留,重读后更新同一行),只改标题不影响,章节删除随外键级联;`GET /api/v1/progress` 统计只含本人与当前语言,已读与分母都只算可阅读(`ready`)章节,已知/学习中/新词/忽略分开计数,`dueNow` 与到期复习队列共用 `dueTermsQuery` 与同一服务端时钟;schema 升到 v7(新表 `lexgo_chapter_progress`,不用 ALTER TABLE),需显式 migrate。不做每日目标、日历、难度评分、统计导出与取消已读,也不做 X10 批量标已知。
|
||||
- 2026-09-15 既有缺陷记录:#32 编辑章节正文回到曾经用过的版本返回 500(`lexgo_ingest_jobs` 请求键与 `edit:<章节>:<内容摘要>` 冲突),已定位未修复,方案待用户确认;#13 的真实验证脚本因此自建 fixture 而不改写既有正文。
|
||||
- 2026-09-11 用户确认 #12 词汇库口径:`GET /api/v1/terms` 列表同时匹配规范化身份键、显示原文与个人释义,大小写不敏感(`term` 为二进制排序规则,查询先转小写),`%`/`_`/`\` 按字面值转义,`status` 与 `kind` 可与搜索组合;分页 `page ≥ 1`、`limit ≤ 100`(默认 20)、按最近更新倒序;`PATCH /api/v1/terms/:id` 复用同一套释义/例句与状态/等级校验,**身份不可编辑**,只有状态或等级变化才重排复习时间,历史作答记录与计数保留;筛选与页码写入 `/vocab` URL。原型编辑页的「来自某章节」**不实现**(词条按身份存储、不引用章节);CSV(X04)、复习范围筛选(X11)、删除与批量清理(X14)不在范围内。
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
面向自托管场景的阅读式语言学习项目,规划提供内容导入、阅读查词、词汇与短语、复习和实例管理。
|
||||
|
||||
已确认:**DevHarness 轻量模式、MySQL 8、go-admin 管理端**。工程基础 #2 已通过验收:两端用户名登录、学习账号管理、可撤销会话和本人英语空空间。管理端基于指定 go-admin/go-admin-ui 选用模块,学习端为独立 Vue 3 + TypeScript + Vite 工程,共用 Go 后端和 MySQL 8.4.3。#18 登录日志与操作审计已通过用户验收,支持管理员查询和 90 天保留清理。#5 粘贴导入与章节原文阅读已验收并合入 main;#6 全 Go 英语词典与点词查义已通过用户验收,PR #25 已合入 main;#7 个人词条与学习状态已通过用户验收,PR #26 已合入 main;#8 到期单词复习已通过用户验收,PR #27 已合入 main;#9 上传 TXT、校验编码后导入本人书库已通过用户验收,PR #28 已合入 main;#10 编辑与删除本人书籍章节已通过用户验收,PR #29 已合入 main;#11 连续短语的选择、保存与复习已通过用户验收,PR #30 已合入 main;#12 词汇库的搜索、筛选与编辑已通过用户验收,PR #31 已合入 main。#13 完成章节与个人基础进度已实现,待用户验收(新增 schema v7 与显式迁移)。试用交付仍未实现。MVP 定位为“支持多账号、数据独立的自托管学习工具”,先邀请少量用户使用;F01~F12 已确认,X 系列后置。
|
||||
已确认:**DevHarness 轻量模式、MySQL 8、go-admin 管理端**。工程基础 #2 已通过验收:两端用户名登录、学习账号管理、可撤销会话和本人英语空空间。管理端基于指定 go-admin/go-admin-ui 选用模块,学习端为独立 Vue 3 + TypeScript + Vite 工程,共用 Go 后端和 MySQL 8.4.3。#18 登录日志与操作审计已通过用户验收,支持管理员查询和 90 天保留清理。#5 粘贴导入与章节原文阅读已验收并合入 main;#6 全 Go 英语词典与点词查义已通过用户验收,PR #25 已合入 main;#7 个人词条与学习状态已通过用户验收,PR #26 已合入 main;#8 到期单词复习已通过用户验收,PR #27 已合入 main;#9 上传 TXT、校验编码后导入本人书库已通过用户验收,PR #28 已合入 main;#10 编辑与删除本人书籍章节已通过用户验收,PR #29 已合入 main;#11 连续短语的选择、保存与复习已通过用户验收,PR #30 已合入 main;#12 词汇库的搜索、筛选与编辑已通过用户验收,PR #31 已合入 main。#13 完成章节与个人基础进度已通过用户验收,PR #33 已合入 main(schema v7 需显式迁移)。#14 桌面与手机体验、主题与键盘操作已实现,待用户验收。试用交付仍未实现。MVP 定位为“支持多账号、数据独立的自托管学习工具”,先邀请少量用户使用;F01~F12 已确认,X 系列后置。
|
||||
|
||||
- [文档入口](docs/README.md) · [线上 Wiki](https://git.ilapage.cn/OPC/lexgo/wiki/Home)
|
||||
- [英语分词与离线词典验证小样](spikes/english/README.md)(#3 已验收,独立本机入口)
|
||||
- [阅读选择验证小样](spikes/selection/README.md)(#4 已验收,真机详细测试证据缺口保留)
|
||||
- [项目档案](docs/00-project-profile.md) · [需求总览](docs/09-product-requirements-overview.md)
|
||||
- [工作量估算](docs/10-workload-estimate.md):#2、#3、#4、#5、#6、#7、#8、#9、#10、#11、#12、#18 已验收,#13 已实现待验收,原规划中的 #5 已完成;剩余 #14、#15 与新增 #21、#24 按工单复核;后续结合集成结果重估,旧全量研究仅供参考。
|
||||
- [工作量估算](docs/10-workload-estimate.md):#2、#3、#4、#5、#6、#7、#8、#9、#10、#11、#12、#13、#18 已验收,#14 已实现待验收,原规划中的 #5 已完成;剩余 #15 与新增 #21、#24 按工单复核;后续结合集成结果重估,旧全量研究仅供参考。
|
||||
- [四阶段实施总览 #16](https://git.ilapage.cn/OPC/lexgo/issues/16):14 张单元工单,工程基础 → 技术验证 → 首条学习闭环 → 补齐 MVP;原型 v1 已获用户验收。两端使用账号(用户名)+密码登录,不要求邮箱。
|
||||
- [原型工单 #1](https://git.ilapage.cn/OPC/lexgo/issues/1):Quant-UX 桌面/手机原型 v1,预览入口与审核记录见工单及需求总览。
|
||||
- [工作流](docs/01-workflow.md) · [开发与验证](docs/04-local-development-and-verification.md)
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Project-Profile
|
||||
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Project-Profile.-
|
||||
wiki_revision: 78760fdce51217ebd047a05207d327b4874691d2
|
||||
synchronized_at: 2026-09-15T03:50:38Z
|
||||
wiki_revision: 0c0354499b49ef5973ffcdc47e9eba40f447536b
|
||||
synchronized_at: 2026-09-15T06:30:56Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# LexGo 项目档案
|
||||
@@ -170,3 +170,7 @@ server 是 go-admin 的选用模块接入:原样保留 SysUser、SysDept、必
|
||||
## #12 当前工程状态(2026-09-15)
|
||||
|
||||
#11已通过用户验收并合入main。#12词汇库的搜索、筛选与编辑已按用户确认的契约实现,并于2026-09-15通过用户验收,PR #31已fast-forward-only合入main。本单没有数据库结构变化,schema仍为v6。`GET /api/v1/terms`提供本人当前语言的分页列表,搜索同时匹配规范化身份键、显示原文与个人释义且大小写不敏感(term列为二进制排序规则,查询先转小写),通配符按字面值转义,状态与类型可与搜索组合,分页默认20、上限100并按最近更新倒序;`PATCH /api/v1/terms/:id`复用同一套释义/例句与状态/等级校验,身份不可编辑,只有状态或等级变化才重排复习时间,历史作答记录与计数保留。学习端新增生词本页面,含搜索、筛选、分页、空态、无结果清除与编辑对话框(学习中显示1~7等级选择器),筛选与页码写入URL;表单字段抽成共享组件与阅读器面板复用。原型编辑页的「来自某章节」不实现,因为词条按身份存储、不引用章节。第4阶段已完成#9、#10、#11与#12;剩余#13~#15与#21、#24尚未实施。
|
||||
|
||||
## #13 当前工程状态(2026-09-15)
|
||||
|
||||
#12已通过用户验收并合入main。#13章节完成与个人基础进度已按用户确认的契约实现,并于2026-09-15通过用户验收,PR #33已fast-forward-only合入main。schema升到v7,新增lexgo_chapter_progress(一章一行、按content_sha256做内容版本门控、级联到章节与账号),仍然只使用可重放的CREATE TABLE IF NOT EXISTS,迁移需显式执行。POST /api/v1/chapters/:id/complete只记已读、不改变任何词语状态或等级,只有处理完成的章节可标记,重复调用返回同一条记录且不重复计数;GET /api/v1/progress按本人与当前语言统计已读章节(分子与分母都只含可阅读章节)、已知、学习中、新词、忽略、已保存词条与待复习,并给出每本书的已读进度,其中待复习与到期复习队列共用同一查询与同一服务端时钟。学习端新增进度页面与阅读页的显式「标记本章已读」,书籍页显示已读标记。正文改成新版本后该章自动回到未读并可在重读后更新同一条记录,删除章节时记录随之消失。第4阶段已完成#9、#10、#11、#12与#13;剩余#14、#15与#21、#24尚未实施。此外发现并单独记录了既有缺陷#32(编辑章节正文回到曾用版本返回500,已定位未修复,修复方案待用户确认)。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: 9bc2de753de2249c586306cdc27b566052941da2
|
||||
synchronized_at: 2026-09-15T04:31:24Z
|
||||
wiki_revision: a4fd3873678017ea046a6e708141d7f8140edcef
|
||||
synchronized_at: 2026-09-15T07:11:02Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -344,7 +344,7 @@ schema v6 新增 `lexgo_term_reviews`(每个个人词条一行排期:`due_at
|
||||
|
||||
学习端 `stores/vocabulary.ts` 保存列表状态与筛选(打开/关闭编辑对话框、从阅读器返回都不会丢),`views/VocabView.vue` 提供搜索框与显式搜索按钮、状态与类型筛选、分页、空态与「没有匹配的词条 + 清除搜索与筛选」,并把筛选与页码同步到 `/vocab?...`(`router.replace`)。释义/例句/状态/等级四个字段抽成 `components/TermFormFields.vue`,与阅读器面板共用;等级选择器只在词汇库编辑对话框中出现(阅读器面板仍只有四个状态)。
|
||||
|
||||
## #13 章节完成与个人基础进度(2026-09-15)
|
||||
## #13 章节完成与个人基础进度(2026-09-15,已验收并合入 main)
|
||||
|
||||
schema v7 新增 `lexgo_chapter_progress`:一章一行(`chapter_id` 主键),带 `owner_id`、`book_id`、`language`、`read_sha256`(标记时该章 `content_sha256` 的快照)、`read_at` 与时间戳,外键级联到章节、书籍与账号。仍然只加新表,不用 `ALTER TABLE ADD COLUMN`,保持每个版本 DDL 可重放。
|
||||
|
||||
@@ -356,3 +356,18 @@ schema v7 新增 `lexgo_chapter_progress`:一章一行(`chapter_id` 主键
|
||||
`server/app/lexgo/progress.go` 集中实现:`CompleteChapter`(事务内锁定章节,缺行则插入,内容相同则原样返回,内容不同则把同一行推进到新版本)、`readAtByChapter`(批量取章节的已读时间,只认可「标记快照=当前内容」且章节 `ready`)、`LearnerProgressFor`(阅读分母与分子都只算 `ready` 章节,词条按四种状态分组,`dueNow` 复用 `dueTermsQuery`)。`review.go` 抽出 `dueTermsQuery`,到期复习队列和进度页共用同一谓词与同一服务端时钟,避免两处漂移。`ChapterSummary` 增加 `readAt` 字段,`BookDetail`、`ChapterDetail` 与章节编辑响应都会解析它,所以列表、阅读器和编辑后的状态一致。
|
||||
|
||||
学习端新增 `/progress` 页面与导航「进度」(`stores/progress.ts`、`views/ProgressView.vue`):已读章节、待复习、已知词、学习中、新词、忽略六张卡片,加每本书的已读进度与进入书籍的链接、已保存词条总数、刷新与失败重试、无书时的引导。阅读器正文下方新增显式的「标记本章已读」区块(`ReaderView.vue`,`data-testid="mark-read"`),显示已读时间与重复标记提示;书籍页章节列表对已读章节显示「已读」标记(`BookView.vue`)。`stores/library.ts` 增加 `markChapterRead`,只把 `readAt` 写回章节列表与打开中的阅读器,不动其他字段。
|
||||
|
||||
## #14 桌面与手机体验、主题与键盘(2026-09-15)
|
||||
|
||||
**没有 schema 变化、没有新接口**:本单只改学习端与样式。事实先行:用带触摸的 390×844 移动视口逐个打开登录/书库/导入/书籍/阅读/查词面板/生词本/复习/进度,`scrollWidth` 全部等于 `innerWidth`,没有溢出元素——前几单的响应式基础成立,本单把它固化成自动化移动测试,并补上缺失的偏好与键盘操作。
|
||||
|
||||
| 新增文件 | 职责 |
|
||||
|---|---|
|
||||
| `learner/src/stores/preferences.ts` | 主题(浅色/深色/跟随系统)与正文字号(标准/大/特大);按账号存在 `lexgo-learner-display:<账号 id>` |
|
||||
| `learner/src/components/DisplaySettings.vue` | 站点头部统一的「显示」下拉,展示当前值并可键盘操作 |
|
||||
| `learner/src/composables/readingPosition.ts` | 按账号+章节保存滚动比例与该章 `content_sha256` |
|
||||
| `learner/src/composables/reviewShortcuts.ts` | 复习页键盘映射与「不抢输入框」的判断 |
|
||||
|
||||
`style.css` 收敛为一套语义调色板:`:root` 里 53 个变量是**文件内仅有的颜色字面量**,其余规则全部走 `var(...)`;`html[data-theme='dark']` 覆盖同一批变量,`html.dark` 同时映射 Element Plus 的暗色变量(`main.ts` 引入 `element-plus/theme-chalk/dark/css-vars.css`),所以页面与组件跟随同一个选择。字号经 `--reader-font-scale` 只作用于阅读面(章节正文、释义面板内容、复习卡的词与释义),不做全局缩放。`ReaderView.vue` 在章节就绪后把正文下方的位置恢复到上次比例(正文版本变化则不恢复,等待可滚动后再应用,最多约 1 秒后放弃),并在滚动时按 400ms 防抖保存。`ReviewView.vue` 加了 `useReviewShortcuts` 与一行快捷键提示;`DisplaySettings` 出现在七个学习页面的头部。
|
||||
|
||||
移动测试项目:`playwright.config.ts` 增加 `mobile`(390×844,`isMobile` + `hasTouch`,Chromium),桌面项目用 `testIgnore` 排除 `mobile-*.spec.ts`,移动项目用 `testMatch` 只跑它们。`e2e/mobile-fixtures.ts` 提供移动端共用的 mock、无溢出检查与经 CDP `Input.dispatchTouchEvent` 的真实触摸滑动。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: 67b43213bdbf87c5af03c324d40ccc1b37a11ea6
|
||||
synchronized_at: 2026-09-15T04:31:24Z
|
||||
wiki_revision: 5a44b1713b3fd2374baa78314872539f3e2e23ab
|
||||
synchronized_at: 2026-09-15T07:11:02Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -276,3 +276,17 @@ exact优先;未命中再按WordNet异常表/词尾规则查候选,词性顺
|
||||
- **待复习**使用与到期复习队列完全相同的谓词(状态为新词或学习中、且 `due_at` 不晚于当前服务端时钟)与同一时钟,页面数字与复习队列 `total` 一致。
|
||||
|
||||
**范围边界**:不含每日目标、日历、连续天数、难度评分;不含统计导出;不做「取消已读」(正文版本变化会自然回到未读);不包含复习范围筛选(X11)与练习模式(X08)。
|
||||
|
||||
## #14 显示偏好、阅读位置与键盘操作规则(2026-09-15)
|
||||
|
||||
**主题**:可选浅色、深色、跟随系统,默认跟随系统。显式选择不受系统变化影响;选择「跟随系统」时监听系统配色变化并实时切换。深色只改变颜色变量,不改变布局与功能。
|
||||
|
||||
**正文字号**:三档「标准 / 大 / 特大」(1.0 / 1.15 / 1.3 倍),作用于阅读面(章节正文、释义面板正文与例句、复习卡的词与释义)。这是有意的边界:不做全局 UI 缩放,避免窄屏下导航与按钮被挤坏。
|
||||
|
||||
**偏好的保存与账号隔离**:主题与字号保存在本机,键包含账号 id,因此**切换账号会立刻换成该账号自己的偏好或默认值,A 的选择不会带给 B**;未登录的登录页使用系统默认;退出登录后回到默认。**不跨设备同步**(范围外)。存储值读不出、不是合法取值或来自旧格式时回退到默认,而不是部分套用或抛错。
|
||||
|
||||
**保留阅读位置**:按「账号+章节」保存滚动比例,并记录当时的 `content_sha256`。再次打开同一章时恢复到该比例;**正文被改成新版本后不恢复**(旧偏移指向别的文字),同账号的其他章节互不影响。位置为 0(还没滚动)不保存;比例按窗口宽度换算,所以在桌面与手机上都能落在相近位置。本机行为,不跨设备。
|
||||
|
||||
**键盘操作**:复习页 `空格`/`Enter` 显示答案,`1` 认识/答对、`2` 不认识/答错、`3` 再学一次;界面给出快捷键提示,并保持「显示答案」与「答对」按钮自动获得焦点,键盘用户可以全程不碰鼠标。按键规则:**输入框、文本域、下拉与可编辑区域内的任何按键都不触发快捷键**;按下的键若属于当前聚焦控件(按钮与链接的 `空格`/`Enter`)则交给浏览器处理,避免重复触发;带修饰键的组合键一律不拦截。阅读器 `Esc` 关闭释义面板,面板内的 `Esc` 只关面板。
|
||||
|
||||
**触摸与滚动**:没有任何阻止默认行为的触摸处理;释义面板在窄屏是底部固定面板,页面为其预留底部空间,关闭后阅读位置不跳动。**长按原生选择与手柄拖动的手感仍需真机确认**,本单的触摸证据来自模拟设备。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Local-Development-and-Verification
|
||||
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Local-Development-and-Verification.-
|
||||
wiki_revision: 8222610b19a406a74771ded2f3edfa94a69f6295
|
||||
synchronized_at: 2026-09-15T04:31:24Z
|
||||
wiki_revision: e6255c24e7b966573a9851b1e2356e370d3daef1
|
||||
synchronized_at: 2026-09-15T07:11:02Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -474,3 +474,22 @@ node --test spikes/english/view.test.mjs
|
||||
**本单顺带记录一个既有缺陷**:编辑章节正文回到曾经用过的版本会返回 500(#32,`lexgo_ingest_jobs` 唯一请求键与 `edit:<章节>:<内容摘要>` 键冲突),不属于本单范围,未修改代码;真实验证脚本因此改为自建 fixture 而不改写既有正文。
|
||||
|
||||
未验证:真实手机触屏与滚动证据仍属既有缺口(#14 承接);阅读进度没有按书排序或搜索(进度页按书籍编号列出);没有大数据量下的统计性能压测(统计为带前缀索引的计数查询)。
|
||||
|
||||
## #14 移动与主题验证(2026-09-15)
|
||||
|
||||
本单**无 schema 变化、无迁移**,纯学习端与文档改动;回退只需回到上一提交。
|
||||
|
||||
| 命令 | 结果 |
|
||||
|---|---|
|
||||
| `cd learner`:`npx vitest --run` / `npx vue-tsc --build` / `npx pnpm run build` / `npx playwright test` | 147 项单测、类型检查、构建、**23 项 E2E**(桌面 19 + 移动 4)全部通过;本单新增 20 单测与 8 项 E2E |
|
||||
| `cd admin`:`npx pnpm test` / `npx pnpm lint` | 31 项与 lint 通过;管理端无改动 |
|
||||
| `python -m unittest discover -s tests` / `python dev_scripts/harness.py check --strict` | 56 项与严格检查通过 |
|
||||
| `LEXGO_TEST_DB_NAME=lexgo_test_issue13 python scripts/server.py test-integration` | 70 项通过(后端未改动,仍复跑一遍确认) |
|
||||
|
||||
**移动项目**:`npx playwright test --project=mobile` 使用 390×844、`isMobile` 与 `hasTouch` 的 Chromium。覆盖内容:八个界面(书库/导入/书籍/阅读/查词面板/生词本/复习/进度)在 390px 下 `scrollWidth ≤ innerWidth` 且没有越界元素、每页都能返回书库、显示控件可用手指点开且高度不小于 36px、经 CDP 触摸事件的上滑真的滚动了章节且没有误开释义面板、面板不越过视口且页面为它预留底部空间、关闭面板后阅读位置不跳、深色与特大字号在刷新后保持、退出登录回到默认且同一账号回来仍是自己的选择。
|
||||
|
||||
**主题回归测试**:`e2e/theme.spec.ts` 逐个读回 16 个调色板变量,断言浅色与深色下都解析成非空值、页面背景不是透明、正文与背景不同色、深浅两套取值不同、Element Plus 的 `--el-bg-color` 也跟随。这条用例来自本单的一个真实教训:批量把颜色字面量替换为变量时,调色板自身的定义也被替换成了自引用变量,所有颜色静默失效——几何断言全过,只有焦点环探针把问题暴露出来,所以补了这条变量级回归。
|
||||
|
||||
**真实链路**:真实学习端+真实 Go API+真实 MySQL 走了一遍——粘贴 40 行长章节并处理完成,滚到 45% 后离开再返回,位置被恢复(偏差 < 150px,页面提示「已回到上次阅读位置」);切到深色+特大后刷新仍保持,正文实际字号变大(> 18px);在真实到期卡上按空格显示了答案(**故意不评分**,避免改动 fixture 账号的真实复习计划);另用 390×844 触摸上下文在真实数据上检查了书库/生词本/进度无横向溢出。fixture 书籍在结束时删除;截图见 `.local/evidence/issue14-real-*.png`、`.local/evidence/issue14-mobile-*.png`、`issue14-theme-*.png`、`issue14-keyboard-review.png`。临时用例运行后删除。
|
||||
|
||||
**未验证**:真实手机上的长按选择、手柄拖动、滚动惯性、软键盘遮挡与安全区表现无法由本机自动化产生——本单的触摸证据来自模拟设备;**真机验证仍需人工完成**,这一点在 #4、#11 就已记录,本单未能消除。也未做 Safari/Firefox 等其他引擎的验证(项目既有测试只在 Chromium 上跑)。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Product-Requirements-Overview
|
||||
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Product-Requirements-Overview.-
|
||||
wiki_revision: abf6629dfe44d838dd235b11543d9fdf94429899
|
||||
synchronized_at: 2026-09-15T04:31:25Z
|
||||
wiki_revision: 912089b338bf3c978add09069e39e7fb19c94240
|
||||
synchronized_at: 2026-09-15T07:11:02Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 产品需求总览
|
||||
@@ -278,6 +278,12 @@ F09 的词汇库已于 2026-09-15 通过用户验收:生词本分页列出本
|
||||
|
||||
## #13 交付范围更新(2026-09-15)
|
||||
|
||||
F11 的完成阅读与基础进度已实现,待用户验收:阅读页提供显式「标记本章已读」(只记已读、不批量改变词语状态),书籍页章节列表显示已读,新的进度页面显示已读章节(分母只含可阅读章节)、待复习、已知词、学习中、新词、忽略与已保存词条数,以及每本书的已读进度;重复标记不重复计数,待复习与到期复习队列共用同一规则与时钟。正文改成新版本后该章回到未读并可在重读后更新同一记录;删除章节时记录随之消失。本次新增 schema v7(一张新表),需要显式迁移。
|
||||
F11 的完成阅读与基础进度已于 2026-09-15 通过用户验收:阅读页提供显式「标记本章已读」(只记已读、不批量改变词语状态),书籍页章节列表显示已读,新的进度页面显示已读章节(分母只含可阅读章节)、待复习、已知词、学习中、新词、忽略与已保存词条数,以及每本书的已读进度;重复标记不重复计数,待复习与到期复习队列共用同一规则与时钟。正文改成新版本后该章回到未读并可在重读后更新同一记录;删除章节时记录随之消失。本次新增 schema v7(一张新表),需要显式迁移。
|
||||
|
||||
仍未实现并留给后续工单:桌面与手机体验补齐(#14)、自托管试用交付与完整恢复(#15)、书籍音频与封面附件(#21)、书库列表优化(#24)。每日目标、日历、难度评分、统计导出明确不在范围内。另记录既有缺陷 #32(编辑章节正文回到曾用版本返回 500,已定位未修复,方案待用户确认)。
|
||||
|
||||
## #14 交付范围更新(2026-09-15)
|
||||
|
||||
F12 的电脑与手机基础体验已实现,待用户验收:新增「显示」设置(浅色/深色/跟随系统;正文标准/大/特大),偏好按账号保存在本机、账号切换不串、不跨设备同步;阅读位置按账号与章节保留,正文换新版本后不套用旧位置;复习页支持空格显示答案与 1/2/3 评分,输入框内不误触全局快捷键,焦点环可见;手机端以 390×844 触摸视口验证八个界面无横向溢出、均可返回、触摸滑动可滚动且不误开释义面板。本次无数据库与接口变化。
|
||||
|
||||
仍未实现并留给后续工单:自托管试用交付与完整恢复(#15)、书籍音频与封面附件(#21)、书库列表优化(#24),以及已定位未修复的缺陷 #32。PWA、离线能力、自定义配色、全局 UI 缩放与偏好跨设备同步明确不在范围内。**真实手机的长按选择与操作手感仍需人工确认**,本单的触摸证据来自模拟设备。
|
||||
|
||||
+5
-3
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Home
|
||||
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Home
|
||||
wiki_revision: 9ee88d82494e22e689086b053770c4f4620d3657
|
||||
synchronized_at: 2026-09-15T04:31:22Z
|
||||
wiki_revision: 591ce1b3b16af9401062af925ba159ebdc4b8c6a
|
||||
synchronized_at: 2026-09-15T07:11:01Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# LexGo 文档入口
|
||||
@@ -83,4 +83,6 @@ Quant-UX 原型 v1 已通过用户验收。[桌面预览](https://qux.ilapage.cn
|
||||
|
||||
#12 词汇库的搜索、筛选与编辑已于 2026-09-15 通过用户验收:生词本分页列出本人保存的词语与短语,可按关键词(匹配词形、原文或个人释义,大小写不敏感)与状态/类型筛选,并可编辑释义、例句、状态与 1~7 学习等级。编辑沿用与阅读器相同的领域规则:身份不可改、只有状态或等级变化才重排复习时间、历史作答记录保留;筛选与页码写入 URL,刷新或返回列表都保留。原型的「来自某章节」不实现,因为词条按身份存储、不引用章节。本次没有数据库结构变化;PR #31 已 fast-forward-only 合入 main。
|
||||
|
||||
#13 完成章节与个人基础进度已实现,待用户验收:阅读页正文下方提供显式的「标记本章已读」,只记录已读、不批量改变词语状态;重复标记返回同一条记录、不重复计数。书籍页章节列表显示「已读」,新的「进度」页面显示已读章节(只统计可阅读章节,形如「3 / 8 章(可阅读)」)、待复习、已知词、学习中、新词、忽略与已保存词条数,并按书显示已读进度;待复习与到期复习队列使用同一规则与同一时钟。正文被改成新版本后该章会回到未读(记录保留),重新标记即更新同一条记录;删除章节时记录随之消失。本次新增 schema v7(一张新表),需要显式迁移;开发库迁移前后各表计数一致。真实 API+MySQL 37 项检查与浏览器页面数字对照数据库直查全部一致。
|
||||
#13 完成章节与个人基础进度已于 2026-09-15 通过用户验收:阅读页正文下方提供显式的「标记本章已读」,只记录已读、不批量改变词语状态;重复标记返回同一条记录、不重复计数。书籍页章节列表显示「已读」,新的「进度」页面显示已读章节(只统计可阅读章节,形如「3 / 8 章(可阅读)」)、待复习、已知词、学习中、新词、忽略与已保存词条数,并按书显示已读进度;待复习与到期复习队列使用同一规则与同一时钟。正文被改成新版本后该章会回到未读(记录保留),重新标记即更新同一条记录;删除章节时记录随之消失。本次新增 schema v7(一张新表),需要显式迁移;开发库迁移前后各表计数一致。真实 API+MySQL 37 项检查与浏览器页面数字对照数据库直查全部一致;PR #33 已 fast-forward-only 合入 main。
|
||||
|
||||
#14 桌面与手机体验、主题与键盘操作已实现,待用户验收:新增「显示」设置(浅色/深色/跟随系统,正文标准/大/特大),偏好按账号保存在本机,切换账号不会串;阅读位置按账号与章节保留,正文换成新版本后不再套用旧位置;复习页支持空格显示答案、1/2/3 评分,输入框内不会误触快捷键,所有可交互元素有可见焦点环。手机端用 390×844 触摸视口验证八个界面无横向溢出、都能返回、触摸滑动滚动而不误开释义面板,深色与特大字号刷新后保持。本次没有数据库与接口变化。真实手机的长按选择与手感仍需人工确认。
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
const user = { id: 42, username: 'fictional-keyboard', role: 'learner' }
|
||||
const item = { id: 11, term: 'curiosity', originalForm: 'curiosity', definition: '好奇心;求知欲', examples: ['Curious minds ask.'], status: 'new', level: 0, kind: 'word', wordCount: 1, dueAt: '2026-01-02T00:00:00Z', reviewCount: 0 }
|
||||
|
||||
async function mockApi(page: Page): Promise<void> {
|
||||
await page.route('**/api/v1/**', async route => {
|
||||
const url = new URL(route.request().url())
|
||||
const path = url.pathname
|
||||
const method = route.request().method()
|
||||
let data: unknown = null
|
||||
if (path === '/api/v1/login') data = { token: 'fictional-session', user }
|
||||
else if (path === '/api/v1/me') data = user
|
||||
else if (path === '/api/v1/space') data = { ownerId: user.id, language: 'en' }
|
||||
else if (path === '/api/v1/books') data = { items: [] }
|
||||
else if (path === '/api/v1/reviews/queue') data = { items: [item], total: 1 }
|
||||
else if (path.endsWith('/answers') && method === 'POST') {
|
||||
data = { result: 'applied', duplicate: false, grade: 'correct', requeued: false, statusBefore: 'new', statusAfter: 'learning', levelBefore: 0, levelAfter: 1, dueAtBefore: item.dueAt, dueAtAfter: '2026-01-09T00:00:00Z', item }
|
||||
} else if (path === '/api/v1/terms' && method === 'GET') {
|
||||
data = { items: [{ ...item, updatedAt: '2026-01-02T00:00:00Z' }], total: 1, page: 1, limit: 20 }
|
||||
}
|
||||
await route.fulfill({ json: { code: 200, data } })
|
||||
})
|
||||
}
|
||||
|
||||
/** Presses Tab until the target holds the focus, so reachability is what is being tested. */
|
||||
async function tabUntilFocused(page: Page, target: ReturnType<Page['getByTestId']>, limit = 15): Promise<boolean> {
|
||||
for (let step = 0; step < limit; step += 1) {
|
||||
if (await target.evaluate(element => element === document.activeElement).catch(() => false)) return true
|
||||
await page.keyboard.press('Tab')
|
||||
}
|
||||
return target.evaluate(element => element === document.activeElement).catch(() => false)
|
||||
}
|
||||
|
||||
async function login(page: Page): Promise<void> {
|
||||
await page.goto('/')
|
||||
await page.getByLabel('账号').fill(user.username)
|
||||
await page.getByLabel('密码', { exact: true }).fill('fictional-password')
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
|
||||
}
|
||||
|
||||
test('the due review can be driven from the keyboard alone', async ({ page }) => {
|
||||
await mockApi(page)
|
||||
await login(page)
|
||||
await page.locator('.site-header nav').getByRole('link', { name: '到期复习' }).click()
|
||||
await expect(page.getByTestId('review-card')).toBeVisible()
|
||||
await expect(page.getByTestId('review-shortcuts')).toContainText('空格')
|
||||
|
||||
// The first action already holds the focus, so a keyboard user can keep going.
|
||||
await expect(page.getByTestId('review-reveal')).toBeFocused()
|
||||
|
||||
// Space shows the answer, and the answer panel is announced as text.
|
||||
await page.keyboard.press('Space')
|
||||
await expect(page.getByTestId('review-definition')).toContainText('好奇心')
|
||||
await expect(page.getByTestId('review-correct')).toBeFocused()
|
||||
|
||||
// A digit grades the card without touching the mouse.
|
||||
await page.keyboard.press('1')
|
||||
await expect(page.getByTestId('review-summary')).toBeVisible()
|
||||
await page.screenshot({ path: '../.local/evidence/issue14-keyboard-review.png' })
|
||||
|
||||
// Tab reaches the closing action within a few presses, not after walking the whole page.
|
||||
expect(await tabUntilFocused(page, page.getByTestId('review-finish'), 6)).toBe(true)
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('typing in a form never triggers a global shortcut', async ({ page }) => {
|
||||
await mockApi(page)
|
||||
await login(page)
|
||||
await page.locator('.site-header nav').getByRole('link', { name: '生词本' }).click()
|
||||
await expect(page.locator('.vocab-row')).toHaveCount(1)
|
||||
const search = page.getByLabel('搜索词 / 释义')
|
||||
await search.click()
|
||||
await search.type('curiosity 1 2 quit')
|
||||
await expect(search).toHaveValue('curiosity 1 2 quit')
|
||||
// The list only changes when the search is submitted, not while typing.
|
||||
await expect(page.locator('.vocab-row')).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('the display control shows a visible focus ring', async ({ page }) => {
|
||||
await mockApi(page)
|
||||
await login(page)
|
||||
const trigger = page.getByTestId('display-trigger')
|
||||
// Reached by keyboard, which is what makes the focus ring appear in the first place.
|
||||
expect(await tabUntilFocused(page, trigger)).toBe(true)
|
||||
const outline = await trigger.evaluate(element => {
|
||||
const style = getComputedStyle(element)
|
||||
return { width: style.outlineWidth, style: style.outlineStyle }
|
||||
})
|
||||
expect(parseFloat(outline.width)).toBeGreaterThanOrEqual(2)
|
||||
expect(outline.style).not.toBe('none')
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { login, mockLearnerApi, touchSwipe } from './mobile-fixtures'
|
||||
|
||||
// Touch must scroll the chapter instead of being swallowed by the selection handling, and a tap
|
||||
// must still open the word panel. This is emulated touch input through Chromium, not a real phone.
|
||||
test('a touch swipe scrolls the chapter and does not open the word panel', async ({ page }) => {
|
||||
await mockLearnerApi(page)
|
||||
await login(page)
|
||||
await page.getByRole('link', { name: 'Fictional mobile book' }).click()
|
||||
await page.locator('.chapter-list a.chapter-name').first().click()
|
||||
await expect(page.locator('.reader-text')).toBeVisible()
|
||||
// The words arrive with the token response, so wait for them instead of sampling once.
|
||||
await expect.poll(async () => page.locator('.reader-word').count()).toBeGreaterThan(3)
|
||||
|
||||
// A swipe upwards scrolls the page down and leaves the panel closed.
|
||||
await touchSwipe(page, 640, 240)
|
||||
await expect.poll(async () => page.evaluate(() => window.scrollY)).toBeGreaterThan(0)
|
||||
expect(await page.locator('.lookup-panel').count()).toBe(0)
|
||||
await page.screenshot({ path: '../.local/evidence/issue14-mobile-scrolled.png' })
|
||||
|
||||
// A tap on a word still opens the panel, which sits above the text without covering it.
|
||||
await page.locator('.reader-word').nth(1).tap()
|
||||
const panel = page.locator('.lookup-panel')
|
||||
await expect(panel).toBeVisible()
|
||||
const panelBox = (await panel.boundingBox())!
|
||||
expect(panelBox.x).toBeGreaterThanOrEqual(0)
|
||||
expect(panelBox.x + panelBox.width).toBeLessThanOrEqual(391)
|
||||
// The page reserves room for the fixed sheet, so the last paragraph can still be read.
|
||||
const padding = await page.evaluate(() => getComputedStyle(document.querySelector('.reader-page') as Element).paddingBottom)
|
||||
expect(parseFloat(padding)).toBeGreaterThan(100)
|
||||
|
||||
// Closing the panel keeps the reading position instead of jumping away.
|
||||
const before = await page.evaluate(() => window.scrollY)
|
||||
await page.getByRole('button', { name: '关闭释义' }).tap()
|
||||
await expect(panel).toHaveCount(0)
|
||||
expect(Math.abs((await page.evaluate(() => window.scrollY)) - before)).toBeLessThan(40)
|
||||
})
|
||||
|
||||
test('the display choice survives a reload and belongs to the account', async ({ page }) => {
|
||||
await mockLearnerApi(page)
|
||||
await login(page)
|
||||
await page.getByTestId('display-trigger').tap()
|
||||
await page.getByRole('menu').getByText('深色', { exact: true }).tap()
|
||||
await page.getByTestId('display-trigger').tap()
|
||||
await page.getByRole('menu').getByText('特大', { exact: true }).tap()
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
|
||||
|
||||
const readerScale = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--reader-font-scale').trim())
|
||||
expect(readerScale).toBe('1.3')
|
||||
|
||||
// Reloading keeps both choices: they are stored, not just applied.
|
||||
await page.reload()
|
||||
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
|
||||
expect(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--reader-font-scale').trim())).toBe('1.3')
|
||||
|
||||
// The reading text really is larger, which is what the choice promises.
|
||||
await page.getByRole('link', { name: 'Fictional mobile book' }).click()
|
||||
await page.locator('.chapter-list a.chapter-name').first().click()
|
||||
await expect(page.locator('.reader-text')).toBeVisible()
|
||||
const size = await page.locator('.reader-text').evaluate(element => parseFloat(getComputedStyle(element).fontSize))
|
||||
expect(size).toBeGreaterThan(16)
|
||||
await page.screenshot({ path: '../.local/evidence/issue14-mobile-dark-large.png' })
|
||||
|
||||
// Signing out and back in as another account must not inherit the first account's choice.
|
||||
await page.getByRole('button', { name: '退出登录' }).tap()
|
||||
await expect(page.getByRole('heading', { name: '欢迎回来' })).toBeVisible()
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
|
||||
await page.getByLabel('账号').fill('fictional-mobile')
|
||||
await page.getByLabel('密码', { exact: true }).fill('fictional-password')
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
|
||||
// The same account keeps its own choice.
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
// Shared fixtures for the mobile (touch, 390x844) specs.
|
||||
import { expect, type Page } from '@playwright/test'
|
||||
|
||||
export const mobileUser = { id: 42, username: 'fictional-mobile', role: 'learner' }
|
||||
export const chapterText = [
|
||||
'Curiosity opens the first door.',
|
||||
'The second door stays closed until the reader asks.',
|
||||
'A third line gives the page something to scroll.',
|
||||
'Every paragraph is fictional and only used by the mobile checks.',
|
||||
'Reading on a phone needs real scrolling, not a fixed frame.',
|
||||
'The lookup panel must never swallow a swipe.',
|
||||
'A tap still opens one word at a time.',
|
||||
'Long enough text keeps the page taller than a phone screen.',
|
||||
'The last line is here so the page can be scrolled to its end.',
|
||||
].join('\n') + '\n'
|
||||
|
||||
/** A small mocked API with one book, one ready chapter and one due word. */
|
||||
export async function mockLearnerApi(page: Page): Promise<void> {
|
||||
const book = { id: 7, title: 'Fictional mobile book', language: 'en', chapterCount: 1, pendingCount: 0, processingCount: 0, readyCount: 1, failedCount: 0, createdAt: '', updatedAt: '' }
|
||||
const chapter = {
|
||||
id: 3, bookId: 7, ordinal: 1, title: 'Fictional mobile chapter', status: 'ready', charCount: chapterText.length,
|
||||
errorReason: '', errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '',
|
||||
}
|
||||
const term = { id: 11, term: 'curiosity', originalForm: 'curiosity', definition: '好奇心;求知欲', examples: ['Curious minds ask.'], status: 'new', level: 0, kind: 'word', wordCount: 1, updatedAt: '2026-01-02T00:00:00Z' }
|
||||
|
||||
await page.route('**/api/v1/**', async route => {
|
||||
const url = new URL(route.request().url())
|
||||
const path = url.pathname
|
||||
let data: unknown = null
|
||||
if (path === '/api/v1/login') data = { token: 'fictional-session', user: mobileUser }
|
||||
else if (path === '/api/v1/me') data = mobileUser
|
||||
else if (path === '/api/v1/space') data = { ownerId: mobileUser.id, language: 'en' }
|
||||
else if (path === '/api/v1/books') data = { items: [book] }
|
||||
else if (path === '/api/v1/books/7') data = { book, chapters: [chapter] }
|
||||
else if (path === '/api/v1/chapters/3/tokens') {
|
||||
const tokens: unknown[] = []
|
||||
let offset = 0
|
||||
for (const piece of chapterText.match(/[A-Za-z]+|\s+|[^A-Za-z\s]+/g) ?? []) {
|
||||
const start = offset
|
||||
offset += piece.length
|
||||
tokens.push({
|
||||
text: piece, start, end: offset, startUtf16: start, endUtf16: offset,
|
||||
kind: /^[A-Za-z]+$/.test(piece) ? 'word' : /^\s+$/.test(piece) ? 'space' : 'punctuation',
|
||||
})
|
||||
}
|
||||
// The reader follows the tokens only while they describe the chapter's content version.
|
||||
data = { textSha256: 'e'.repeat(64), tokens }
|
||||
} else if (path === '/api/v1/chapters/3') {
|
||||
data = { book, chapter: { ...chapter, contentSha256: 'e'.repeat(64), originalText: chapterText }, navigation: { previousChapterId: null, nextChapterId: null } }
|
||||
} else if (path === '/api/v1/terms/lookup') {
|
||||
data = { query: 'curiosity', terms: [{ term: 'curiosity', entries: [{ partOfSpeech: 'noun', definition: '好奇心' }] }], candidates: [] }
|
||||
} else if (path === '/api/v1/terms' && route.request().method() === 'GET') {
|
||||
data = { items: [term], total: 1, page: 1, limit: 20 }
|
||||
} else if (path === '/api/v1/reviews/queue') {
|
||||
data = { items: [{ ...term, dueAt: '2026-01-02T00:00:00Z', reviewCount: 0 }], total: 1 }
|
||||
} else if (path === '/api/v1/progress') {
|
||||
data = { readChapters: 0, totalChapters: 1, knownTerms: 0, learningTerms: 0, newTerms: 1, ignoredTerms: 0, savedTerms: 1, dueNow: 1, books: [{ id: 7, title: book.title, readChapters: 0, totalChapters: 1 }] }
|
||||
}
|
||||
await route.fulfill({ json: { code: 200, data } })
|
||||
})
|
||||
}
|
||||
|
||||
export async function login(page: Page): Promise<void> {
|
||||
await page.goto('/')
|
||||
await page.getByLabel('账号').fill(mobileUser.username)
|
||||
await page.getByLabel('密码', { exact: true }).fill('fictional-password')
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
|
||||
}
|
||||
|
||||
/** True when nothing sticks out sideways; the phone must never scroll left or right. */
|
||||
export async function overflowOffenders(page: Page): Promise<string[]> {
|
||||
return page.evaluate(() => {
|
||||
const width = window.innerWidth
|
||||
const offenders: string[] = []
|
||||
for (const element of Array.from(document.querySelectorAll<HTMLElement>('body *'))) {
|
||||
const box = element.getBoundingClientRect()
|
||||
if (box.width === 0 && box.height === 0) continue
|
||||
if (box.right > width + 1 || box.left < -1) {
|
||||
offenders.push(`${element.tagName.toLowerCase()}.${(element.className || '').toString().split(' ').slice(0, 2).join('.')}`)
|
||||
}
|
||||
}
|
||||
return offenders
|
||||
})
|
||||
}
|
||||
|
||||
/** A real touch swipe through CDP, which Chromium turns into scrolling. */
|
||||
export async function touchSwipe(page: Page, fromY: number, toY: number): Promise<void> {
|
||||
const client = await page.context().newCDPSession(page)
|
||||
const send = (type: string, y: number) => client.send('Input.dispatchTouchEvent', {
|
||||
type,
|
||||
touchPoints: type === 'touchEnd' ? [] : [{ x: 195, y }],
|
||||
})
|
||||
await send('touchStart', fromY)
|
||||
const steps = 8
|
||||
for (let step = 1; step <= steps; step += 1) {
|
||||
await send('touchMove', fromY + ((toY - fromY) * step) / steps)
|
||||
}
|
||||
await send('touchEnd', toY)
|
||||
await client.detach()
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { login, mockLearnerApi, overflowOffenders } from './mobile-fixtures'
|
||||
|
||||
// Every learner surface on a phone viewport: nothing sticks out sideways and every page can be
|
||||
// left again. The measured truth in #14 was that the layouts already fit; this locks that in.
|
||||
test('every page fits the phone width and can be left again', async ({ page }) => {
|
||||
await mockLearnerApi(page)
|
||||
await login(page)
|
||||
|
||||
const surfaces: { name: string; open: () => Promise<void>; back: () => Promise<void> }[] = [
|
||||
{
|
||||
name: '书库',
|
||||
open: async () => { await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible() },
|
||||
back: async () => { await page.locator('.site-header nav').getByRole('link', { name: '我的书库' }).click() },
|
||||
},
|
||||
{
|
||||
name: '导入',
|
||||
open: async () => { await page.getByRole('button', { name: '导入内容' }).click(); await expect(page.getByRole('heading', { name: '导入英文内容' })).toBeVisible() },
|
||||
back: async () => { await page.locator('.site-header nav').getByRole('link', { name: '我的书库' }).click() },
|
||||
},
|
||||
{
|
||||
name: '书籍',
|
||||
open: async () => { await page.getByRole('link', { name: 'Fictional mobile book' }).click(); await expect(page.locator('.chapter-list')).toBeVisible() },
|
||||
back: async () => { await page.getByRole('link', { name: '我的书库' }).first().click() },
|
||||
},
|
||||
{
|
||||
name: '阅读',
|
||||
open: async () => {
|
||||
await page.getByRole('link', { name: 'Fictional mobile book' }).click()
|
||||
await page.locator('.chapter-list a.chapter-name').first().click()
|
||||
await expect(page.locator('.reader-text')).toBeVisible()
|
||||
},
|
||||
back: async () => { await page.locator('.breadcrumb').getByRole('link', { name: '我的书库' }).click() },
|
||||
},
|
||||
{
|
||||
name: '查词面板',
|
||||
open: async () => {
|
||||
await page.getByRole('link', { name: 'Fictional mobile book' }).click()
|
||||
await page.locator('.chapter-list a.chapter-name').first().click()
|
||||
await expect(page.locator('.reader-text')).toBeVisible()
|
||||
await page.locator('.reader-word').first().click()
|
||||
await expect(page.locator('.lookup-panel')).toBeVisible()
|
||||
},
|
||||
back: async () => { await page.locator('.site-header nav').getByRole('link', { name: '我的书库' }).click() },
|
||||
},
|
||||
{
|
||||
name: '生词本',
|
||||
open: async () => { await page.locator('.site-header nav').getByRole('link', { name: '生词本' }).click(); await expect(page.getByRole('heading', { name: '生词本' })).toBeVisible() },
|
||||
back: async () => { await page.locator('.site-header nav').getByRole('link', { name: '我的书库' }).click() },
|
||||
},
|
||||
{
|
||||
name: '复习',
|
||||
open: async () => { await page.locator('.site-header nav').getByRole('link', { name: '到期复习' }).click(); await expect(page.getByTestId('review-card')).toBeVisible() },
|
||||
back: async () => { await page.locator('.site-header nav').getByRole('link', { name: '我的书库' }).click() },
|
||||
},
|
||||
{
|
||||
name: '进度',
|
||||
open: async () => { await page.locator('.site-header nav').getByRole('link', { name: '进度' }).click(); await expect(page.getByRole('heading', { name: '我的进度' })).toBeVisible() },
|
||||
back: async () => { await page.locator('.site-header nav').getByRole('link', { name: '我的书库' }).click() },
|
||||
},
|
||||
]
|
||||
|
||||
for (const surface of surfaces) {
|
||||
await surface.open()
|
||||
expect(await overflowOffenders(page), `${surface.name} must not overflow sideways`).toEqual([])
|
||||
const widths = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, inner: window.innerWidth }))
|
||||
expect(widths.scroll, `${surface.name} must not scroll sideways`).toBeLessThanOrEqual(widths.inner)
|
||||
await page.screenshot({ path: `../.local/evidence/issue14-mobile-${surface.name}.png`, fullPage: true })
|
||||
await surface.back()
|
||||
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('the display control is reachable on a phone', async ({ page }) => {
|
||||
await mockLearnerApi(page)
|
||||
await login(page)
|
||||
const trigger = page.getByTestId('display-trigger')
|
||||
await expect(trigger).toBeVisible()
|
||||
const box = (await trigger.boundingBox())!
|
||||
// A finger needs a target of at least 38 pixels; the control is in the header on every page.
|
||||
expect(box.height).toBeGreaterThanOrEqual(36)
|
||||
await trigger.tap()
|
||||
await expect(page.getByRole('menu').getByText('主题')).toBeVisible()
|
||||
await page.getByRole('menu').getByText('深色', { exact: true }).tap()
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
|
||||
await page.screenshot({ path: '../.local/evidence/issue14-mobile-dark.png' })
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
const user = { id: 42, username: 'fictional-theme', role: 'learner' }
|
||||
|
||||
// The palette is one indirection deep: a CSS variable that references itself silently resolves to
|
||||
// nothing and every surface loses its colour. These checks read the values back from the browser.
|
||||
const palette = ['--bg', '--surface', '--surface-input', '--text', '--text-muted', '--border', '--accent',
|
||||
'--accent-marker', '--notice-bg', '--status-ready-bg', '--status-read-bg', '--status-failed-bg', '--word-learning-bg',
|
||||
'--saved', '--sense-bg', '--el-color-primary']
|
||||
|
||||
async function mockApi(page: Page): Promise<void> {
|
||||
await page.route('**/api/v1/**', async route => {
|
||||
const path = new URL(route.request().url()).pathname
|
||||
let data: unknown = null
|
||||
if (path === '/api/v1/login') data = { token: 'fictional-session', user }
|
||||
else if (path === '/api/v1/me') data = user
|
||||
else if (path === '/api/v1/space') data = { ownerId: user.id, language: 'en' }
|
||||
else if (path === '/api/v1/books') data = { items: [] }
|
||||
await route.fulfill({ json: { code: 200, data } })
|
||||
})
|
||||
}
|
||||
|
||||
async function login(page: Page): Promise<void> {
|
||||
await page.goto('/')
|
||||
await page.getByLabel('账号').fill(user.username)
|
||||
await page.getByLabel('密码', { exact: true }).fill('fictional-password')
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
|
||||
}
|
||||
|
||||
test('every palette colour resolves, in both themes', async ({ page }) => {
|
||||
await mockApi(page)
|
||||
await login(page)
|
||||
|
||||
const read = () => page.evaluate(names => {
|
||||
const root = getComputedStyle(document.documentElement)
|
||||
const values: Record<string, string> = {}
|
||||
for (const name of names) values[name] = root.getPropertyValue(name).trim()
|
||||
// The palette is declared on :root, so that is where the painted surface is read.
|
||||
const html = getComputedStyle(document.documentElement)
|
||||
return {
|
||||
values,
|
||||
body: html.backgroundColor,
|
||||
text: getComputedStyle(document.body).color,
|
||||
}
|
||||
}, palette)
|
||||
|
||||
const light = await read()
|
||||
for (const name of palette) {
|
||||
expect(light.values[name], `${name} must resolve in the light theme`).not.toBe('')
|
||||
}
|
||||
// The page really paints with them: an unresolved background would be transparent.
|
||||
expect(light.body).not.toBe('rgba(0, 0, 0, 0)')
|
||||
expect(light.body).not.toBe('transparent')
|
||||
expect(light.text).not.toBe(light.body)
|
||||
await page.screenshot({ path: '../.local/evidence/issue14-theme-light.png' })
|
||||
|
||||
await page.getByTestId('display-trigger').click()
|
||||
await page.getByRole('menu').getByText('深色', { exact: true }).click()
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
|
||||
|
||||
const dark = await read()
|
||||
for (const name of palette) {
|
||||
expect(dark.values[name], `${name} must resolve in the dark theme`).not.toBe('')
|
||||
}
|
||||
// The dark theme is a different surface, not the same one relabelled.
|
||||
expect(dark.values['--bg']).not.toBe(light.values['--bg'])
|
||||
expect(dark.body).not.toBe(light.body)
|
||||
expect(dark.text).not.toBe(dark.body)
|
||||
// Element Plus follows the same choice, so its components do not stay on a white card.
|
||||
const epBackground = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--el-bg-color').trim())
|
||||
expect(epBackground).not.toBe('')
|
||||
await page.screenshot({ path: '../.local/evidence/issue14-theme-dark.png' })
|
||||
|
||||
// Back to light by hand, and the surface returns to the original value.
|
||||
await page.getByTestId('display-trigger').click()
|
||||
await page.getByRole('menu').getByText('浅色', { exact: true }).click()
|
||||
expect((await read()).body).toBe(light.body)
|
||||
})
|
||||
@@ -12,6 +12,16 @@ export default defineConfig({
|
||||
// under parallel workers must not fail an assertion that the app itself would pass.
|
||||
expect: { timeout: 15000 },
|
||||
use: { baseURL: 'http://127.0.0.1:5173', headless: true, trace: 'off' },
|
||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'], channel: 'chrome' } }],
|
||||
projects: [
|
||||
// Desktop behaviour, including the keyboard paths.
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'], channel: 'chrome' }, testIgnore: /mobile-.*\.spec\.ts$/ },
|
||||
// A touch phone viewport: the same pages at 390x844 with touch enabled. This is an emulated
|
||||
// device, not a real phone; the hand-feel of a long-press selection still needs a person.
|
||||
{
|
||||
name: 'mobile',
|
||||
testMatch: /mobile-.*\.spec\.ts$/,
|
||||
use: { ...devices['Pixel 7'], channel: 'chrome', viewport: { width: 390, height: 844 } },
|
||||
},
|
||||
],
|
||||
webServer: { command: 'npm run dev -- --host 127.0.0.1', url: 'http://127.0.0.1:5173', reuseExistingServer: !process.env.CI },
|
||||
})
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import DisplaySettings from '../components/DisplaySettings.vue'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
import {
|
||||
applyPreference, defaultPreference, DISPLAY_KEY_PREFIX, FONT_SCALES, readPreference, resolveTheme,
|
||||
useDisplayStore, writePreference, type DisplayPreference,
|
||||
} from '../stores/preferences'
|
||||
|
||||
const user = { id: 42, username: 'fictional-display', role: 'learner' as const }
|
||||
|
||||
/** A controllable stand-in for the operating system colour scheme. */
|
||||
function fakeSystem(initialDark: boolean) {
|
||||
const listeners = new Set<() => void>()
|
||||
const query = {
|
||||
matches: initialDark,
|
||||
media: '(prefers-color-scheme: dark)',
|
||||
addEventListener: (_: string, handler: () => void) => { listeners.add(handler) },
|
||||
removeEventListener: (_: string, handler: () => void) => { listeners.delete(handler) },
|
||||
}
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => query))
|
||||
return {
|
||||
set(dark: boolean) {
|
||||
query.matches = dark
|
||||
for (const handler of listeners) handler()
|
||||
},
|
||||
listenerCount: () => listeners.size,
|
||||
}
|
||||
}
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
|
||||
describe('display preference rules', () => {
|
||||
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
|
||||
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); wrapper?.unmount() })
|
||||
|
||||
it('keeps each account apart and never shares a choice', () => {
|
||||
const storage = localStorage
|
||||
writePreference(storage, 42, { theme: 'dark', font: 'xlarge' })
|
||||
expect(readPreference(storage, 42)).toEqual({ theme: 'dark', font: 'xlarge' })
|
||||
// Another account has never chosen anything, so it gets the defaults.
|
||||
expect(readPreference(storage, 43)).toEqual(defaultPreference())
|
||||
expect(Object.keys(storage)).toContain(`${DISPLAY_KEY_PREFIX}42`)
|
||||
})
|
||||
|
||||
it('falls back to the default for unreadable or unknown stored values', () => {
|
||||
const storage = localStorage
|
||||
storage.setItem(`${DISPLAY_KEY_PREFIX}42`, '{not json')
|
||||
expect(readPreference(storage, 42)).toEqual(defaultPreference())
|
||||
storage.setItem(`${DISPLAY_KEY_PREFIX}42`, JSON.stringify({ theme: 'neon', font: 'huge' }))
|
||||
expect(readPreference(storage, 42)).toEqual(defaultPreference())
|
||||
storage.setItem(`${DISPLAY_KEY_PREFIX}42`, JSON.stringify({ theme: 'dark' }))
|
||||
expect(readPreference(storage, 42)).toEqual({ theme: 'dark', font: 'standard' })
|
||||
// Without an account there is nothing to read or write.
|
||||
expect(readPreference(storage, null)).toEqual(defaultPreference())
|
||||
writePreference(storage, null, { theme: 'dark', font: 'large' })
|
||||
expect(Object.keys(storage).some(key => key.endsWith(':null'))).toBe(false)
|
||||
})
|
||||
|
||||
it('resolves the system choice and applies it to the document', () => {
|
||||
expect(resolveTheme('system', true)).toBe('dark')
|
||||
expect(resolveTheme('system', false)).toBe('light')
|
||||
expect(resolveTheme('light', true)).toBe('light')
|
||||
expect(resolveTheme('dark', false)).toBe('dark')
|
||||
|
||||
const root = document.createElement('div')
|
||||
const preference: DisplayPreference = { theme: 'dark', font: 'large' }
|
||||
expect(applyPreference(root, preference, false)).toBe('dark')
|
||||
expect(root.dataset.theme).toBe('dark')
|
||||
expect(root.dataset.themeChoice).toBe('dark')
|
||||
expect(root.classList.contains('dark')).toBe(true)
|
||||
expect(root.style.getPropertyValue('--reader-font-scale')).toBe(FONT_SCALES.large)
|
||||
|
||||
applyPreference(root, { theme: 'system', font: 'standard' }, true)
|
||||
expect(root.dataset.theme).toBe('dark')
|
||||
expect(root.dataset.themeChoice).toBe('system')
|
||||
expect(root.style.getPropertyValue('--reader-font-scale')).toBe('1')
|
||||
|
||||
applyPreference(root, { theme: 'light', font: 'xlarge' }, true)
|
||||
expect(root.dataset.theme).toBe('light')
|
||||
// Light removes the Element Plus dark class, so its components follow too.
|
||||
expect(root.classList.contains('dark')).toBe(false)
|
||||
expect(root.style.getPropertyValue('--reader-font-scale')).toBe(FONT_SCALES.xlarge)
|
||||
})
|
||||
})
|
||||
|
||||
describe('display store', () => {
|
||||
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
|
||||
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); wrapper?.unmount() })
|
||||
|
||||
it('applies the signed-in account preference and saves later changes for it', () => {
|
||||
const system = fakeSystem(false)
|
||||
useSessionStore().user = { ...user }
|
||||
localStorage.setItem(`${DISPLAY_KEY_PREFIX}42`, JSON.stringify({ theme: 'dark', font: 'large' }))
|
||||
const display = useDisplayStore()
|
||||
display.load(42)
|
||||
expect(display.theme).toBe('dark')
|
||||
expect(display.font).toBe('large')
|
||||
expect(display.isDark).toBe(true)
|
||||
expect(document.documentElement.dataset.theme).toBe('dark')
|
||||
|
||||
display.setFont('xlarge')
|
||||
expect(JSON.parse(localStorage.getItem(`${DISPLAY_KEY_PREFIX}42`)!)).toEqual({ theme: 'dark', font: 'xlarge' })
|
||||
system.set(true)
|
||||
// An explicit choice ignores the system change.
|
||||
expect(document.documentElement.dataset.theme).toBe('dark')
|
||||
})
|
||||
|
||||
it('follows the system while it is chosen, and stops when it is not', () => {
|
||||
const system = fakeSystem(false)
|
||||
useSessionStore().user = { ...user }
|
||||
const display = useDisplayStore()
|
||||
display.load(42)
|
||||
expect(document.documentElement.dataset.theme).toBe('light')
|
||||
system.set(true)
|
||||
expect(document.documentElement.dataset.theme).toBe('dark')
|
||||
display.setTheme('light')
|
||||
system.set(false)
|
||||
system.set(true)
|
||||
expect(document.documentElement.dataset.theme).toBe('light')
|
||||
expect(system.listenerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('switches preferences with the account instead of carrying them over', async () => {
|
||||
fakeSystem(false)
|
||||
localStorage.setItem(`${DISPLAY_KEY_PREFIX}42`, JSON.stringify({ theme: 'dark', font: 'xlarge' }))
|
||||
const session = useSessionStore()
|
||||
session.user = { ...user }
|
||||
const display = useDisplayStore()
|
||||
await flushPromises()
|
||||
expect(display.isDark).toBe(true)
|
||||
// The second account never chose anything: it must not inherit the first one's dark theme.
|
||||
session.user = { id: 43, username: 'other', role: 'learner' as const }
|
||||
await flushPromises()
|
||||
expect(display.theme).toBe('system')
|
||||
expect(display.font).toBe('standard')
|
||||
expect(document.documentElement.dataset.theme).toBe('light')
|
||||
// Signing out returns to the default as well.
|
||||
session.user = null
|
||||
await flushPromises()
|
||||
expect(display.theme).toBe('system')
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('display settings control', () => {
|
||||
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
|
||||
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); wrapper?.unmount() })
|
||||
|
||||
it('shows the current choice and stores a new one', async () => {
|
||||
fakeSystem(false)
|
||||
useSessionStore().user = { ...user }
|
||||
const display = useDisplayStore()
|
||||
wrapper = mount(DisplaySettings)
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[data-testid="display-trigger"]').text()).toContain('跟随系统')
|
||||
expect(wrapper.get('[data-testid="display-trigger"]').text()).toContain('标准')
|
||||
|
||||
display.setTheme('dark')
|
||||
display.setFont('large')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[data-testid="display-trigger"]').text()).toContain('深色')
|
||||
expect(wrapper.get('[data-testid="display-trigger"]').text()).toContain('大')
|
||||
// The control is reachable by keyboard and announces itself.
|
||||
const trigger = wrapper.get('[data-testid="display-trigger"]')
|
||||
expect(trigger.attributes('aria-label')).toBe('显示设置')
|
||||
expect(trigger.attributes('role')).toBe('button')
|
||||
expect(trigger.attributes('tabindex')).toBe('0')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import ReaderView from '../views/ReaderView.vue'
|
||||
import ReviewView from '../views/ReviewView.vue'
|
||||
import { POSITION_KEY_PREFIX } from '../composables/readingPosition'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
|
||||
const user = { id: 42, username: 'fictional-experience', role: 'learner' as const }
|
||||
const sha = 'c'.repeat(64)
|
||||
const text = 'Curiosity opens the first door.\nThe second door stays closed.\n'
|
||||
|
||||
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
|
||||
let wrapper: VueWrapper | undefined
|
||||
|
||||
const reviewItem = {
|
||||
id: 5, term: 'curiosity', originalForm: 'Curiosity', definition: '好奇心', examples: ['Curious minds ask.'],
|
||||
status: 'new' as const, level: 0, kind: 'word' as const, wordCount: 1, dueAt: '2026-09-15T00:00:00Z', reviewCount: 0,
|
||||
}
|
||||
|
||||
function mockApi(handlers: Record<string, (init?: RequestInit) => unknown>): MockInstance {
|
||||
return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
|
||||
const url = String(input)
|
||||
for (const [fragment, handler] of Object.entries(handlers)) {
|
||||
if (url.includes(fragment)) return ok(handler(init))
|
||||
}
|
||||
return ok({})
|
||||
})
|
||||
}
|
||||
|
||||
async function mountWith(component: unknown, path: string) {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: { template: '<div>LibraryStub</div>' } },
|
||||
{ path: '/chapters/:id', component: component as never },
|
||||
{ path: '/review', component: component as never },
|
||||
],
|
||||
})
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
wrapper = mount(component as never, { attachTo: document.body, global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('review shortcuts in the page', () => {
|
||||
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
|
||||
afterEach(() => { vi.restoreAllMocks(); wrapper?.unmount() })
|
||||
|
||||
it('reveals and grades from the keyboard', async () => {
|
||||
const answers: string[] = []
|
||||
useSessionStore().user = { ...user }
|
||||
mockApi({
|
||||
'/reviews/queue': () => ({ items: [reviewItem], total: 1 }),
|
||||
'/answers': (init) => {
|
||||
answers.push(JSON.parse(String(init?.body)).grade)
|
||||
return {
|
||||
result: 'applied', duplicate: false, grade: 'correct', requeued: false, statusBefore: 'new', statusAfter: 'learning',
|
||||
levelBefore: 0, levelAfter: 1, dueAtBefore: reviewItem.dueAt, dueAtAfter: '2026-09-20T00:00:00Z', item: reviewItem,
|
||||
}
|
||||
},
|
||||
})
|
||||
const view = await mountWith(ReviewView, '/review')
|
||||
expect(view.find('[data-testid="review-card"]').exists()).toBe(true)
|
||||
expect(view.get('[data-testid="review-shortcuts"]').text()).toContain('空格')
|
||||
|
||||
// Space shows the answer instead of grading anything.
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }))
|
||||
await flushPromises()
|
||||
expect(view.get('[data-testid="review-definition"]').text()).toContain('好奇心')
|
||||
expect(answers).toEqual([])
|
||||
|
||||
// A digit grades it; the queue then has nothing left.
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: '1', bubbles: true }))
|
||||
await flushPromises()
|
||||
expect(answers).toEqual(['correct'])
|
||||
expect(view.find('[data-testid="review-summary"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores shortcuts typed into a field and with a modifier held', async () => {
|
||||
const answers: string[] = []
|
||||
useSessionStore().user = { ...user }
|
||||
mockApi({
|
||||
'/reviews/queue': () => ({ items: [reviewItem], total: 1 }),
|
||||
'/answers': (init) => {
|
||||
answers.push(JSON.parse(String(init?.body)).grade)
|
||||
return { result: 'applied', duplicate: false, grade: 'wrong', requeued: true, statusBefore: 'new', statusAfter: 'new', levelBefore: 0, levelAfter: 0, dueAtBefore: reviewItem.dueAt, dueAtAfter: reviewItem.dueAt, item: reviewItem }
|
||||
},
|
||||
})
|
||||
const view = await mountWith(ReviewView, '/review')
|
||||
view.get('[data-testid="review-reveal"]').trigger('click')
|
||||
await flushPromises()
|
||||
// A key press inside an input belongs to the input, wherever the learner is typing.
|
||||
const field = document.createElement('input')
|
||||
document.body.append(field)
|
||||
field.dispatchEvent(new KeyboardEvent('keydown', { key: '2', bubbles: true }))
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: '2', ctrlKey: true, bubbles: true }))
|
||||
await flushPromises()
|
||||
expect(answers).toEqual([])
|
||||
field.remove()
|
||||
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: '2', bubbles: true }))
|
||||
await flushPromises()
|
||||
expect(answers).toEqual(['wrong'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('reading position in the reader', () => {
|
||||
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
|
||||
afterEach(() => { vi.restoreAllMocks(); wrapper?.unmount() })
|
||||
|
||||
function readerApi() {
|
||||
return mockApi({
|
||||
'/tokens': () => ({ tokens: [{ start: 0, end: 9, text: 'Curiosity', kind: 'word' }] }),
|
||||
'/chapters/7': () => ({
|
||||
book: { id: 3, title: 'Fictional reader', language: 'en' },
|
||||
chapter: {
|
||||
id: 7, bookId: 3, ordinal: 1, title: 'Fictional chapter', status: 'ready', charCount: 31, errorReason: '',
|
||||
errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '', contentSha256: sha, originalText: text,
|
||||
},
|
||||
navigation: { previousChapterId: null, nextChapterId: null },
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
it('restores the saved position for the same account and version', async () => {
|
||||
useSessionStore().user = { ...user }
|
||||
localStorage.setItem(`${POSITION_KEY_PREFIX}42:7`, JSON.stringify({ ratio: 0.5, sha, at: '2026-09-15T03:00:00Z' }))
|
||||
const scrollTo = vi.fn()
|
||||
vi.stubGlobal('scrollTo', scrollTo)
|
||||
// A chapter long enough to have a scrolled position.
|
||||
Object.defineProperty(document.documentElement, 'scrollHeight', { value: 4800, configurable: true })
|
||||
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true })
|
||||
readerApi()
|
||||
const view = await mountWith(ReaderView, '/chapters/7')
|
||||
expect(view.find('.reader-text').exists()).toBe(true)
|
||||
await new Promise(resolve => setTimeout(resolve, 90))
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 2000 })
|
||||
await flushPromises()
|
||||
expect(view.get('[data-testid="position-notice"]').text()).toContain('上次阅读位置')
|
||||
})
|
||||
|
||||
it('does not restore a position taken from another content version', async () => {
|
||||
useSessionStore().user = { ...user }
|
||||
localStorage.setItem(`${POSITION_KEY_PREFIX}42:7`, JSON.stringify({ ratio: 0.5, sha: 'd'.repeat(64) }))
|
||||
const scrollTo = vi.fn()
|
||||
vi.stubGlobal('scrollTo', scrollTo)
|
||||
Object.defineProperty(document.documentElement, 'scrollHeight', { value: 4800, configurable: true })
|
||||
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true })
|
||||
readerApi()
|
||||
const view = await mountWith(ReaderView, '/chapters/7')
|
||||
await new Promise(resolve => setTimeout(resolve, 90))
|
||||
expect(scrollTo).not.toHaveBeenCalled()
|
||||
expect(view.find('[data-testid="position-notice"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
clearPosition, currentRatio, normalizeRatio, POSITION_KEY_PREFIX, positionKey, readPosition, writePosition,
|
||||
} from '../composables/readingPosition'
|
||||
import { ignoresShortcut, shortcutFor } from '../composables/reviewShortcuts'
|
||||
|
||||
const sha = 'a'.repeat(64)
|
||||
const otherSha = 'b'.repeat(64)
|
||||
|
||||
describe('reading position storage', () => {
|
||||
it('stores one position per account and chapter', () => {
|
||||
const storage = localStorage
|
||||
storage.clear()
|
||||
writePosition(storage, 42, 7, sha, 0.5, new Date('2026-09-15T03:00:00Z'))
|
||||
expect(readPosition(storage, 42, 7, sha)).toEqual({ ratio: 0.5, sha, at: '2026-09-15T03:00:00.000Z' })
|
||||
// Another chapter, another account and the signed-out state know nothing about it.
|
||||
expect(readPosition(storage, 42, 8, sha)).toBeNull()
|
||||
expect(readPosition(storage, 43, 7, sha)).toBeNull()
|
||||
expect(readPosition(storage, null, 7, sha)).toBeNull()
|
||||
expect(positionKey(null, 7)).toBeNull()
|
||||
expect(positionKey(42, 0)).toBeNull()
|
||||
expect(Object.keys(storage)).toEqual([`${POSITION_KEY_PREFIX}42:7`])
|
||||
})
|
||||
|
||||
it('drops a position taken from another content version', () => {
|
||||
const storage = localStorage
|
||||
storage.clear()
|
||||
writePosition(storage, 42, 7, sha, 0.4)
|
||||
expect(readPosition(storage, 42, 7, otherSha)).toBeNull()
|
||||
// A position without a version is never restored either.
|
||||
expect(readPosition(storage, 42, 7, '')).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores unreadable values and never restores the very top', () => {
|
||||
const storage = localStorage
|
||||
storage.clear()
|
||||
storage.setItem(`${POSITION_KEY_PREFIX}42:7`, '{broken')
|
||||
expect(readPosition(storage, 42, 7, sha)).toBeNull()
|
||||
storage.setItem(`${POSITION_KEY_PREFIX}42:7`, JSON.stringify({ ratio: 'half', sha }))
|
||||
expect(readPosition(storage, 42, 7, sha)).toBeNull()
|
||||
expect(normalizeRatio(0)).toBeNull()
|
||||
expect(normalizeRatio(-1)).toBeNull()
|
||||
expect(normalizeRatio(Number.NaN)).toBeNull()
|
||||
expect(normalizeRatio(3)).toBe(1)
|
||||
expect(normalizeRatio(0.25)).toBe(0.25)
|
||||
// A position of zero is not worth storing, so the top of a chapter needs no key at all.
|
||||
storage.clear()
|
||||
writePosition(storage, 42, 7, sha, 0)
|
||||
expect(storage.getItem(`${POSITION_KEY_PREFIX}42:7`)).toBeNull()
|
||||
writePosition(storage, 42, 7, sha, 0.5)
|
||||
clearPosition(storage, 42, 7)
|
||||
expect(storage.getItem(`${POSITION_KEY_PREFIX}42:7`)).toBeNull()
|
||||
})
|
||||
|
||||
it('turns a scroll offset into a ratio and clamps it', () => {
|
||||
expect(currentRatio({ scrollY: 0, innerHeight: 800 }, 4800)).toBe(0)
|
||||
expect(currentRatio({ scrollY: 1000, innerHeight: 800 }, 4800)).toBeCloseTo(0.25, 5)
|
||||
expect(currentRatio({ scrollY: 4000, innerHeight: 800 }, 4800)).toBe(1)
|
||||
// A chapter shorter than the window cannot be scrolled.
|
||||
expect(currentRatio({ scrollY: 0, innerHeight: 900 }, 900)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('review keyboard shortcuts', () => {
|
||||
it('maps space and enter to revealing and digits to grading', () => {
|
||||
expect(shortcutFor(' ', false)).toBe('reveal')
|
||||
expect(shortcutFor('Enter', false)).toBe('reveal')
|
||||
// Before the answer is on screen a grade key means nothing.
|
||||
expect(shortcutFor('1', false)).toBeNull()
|
||||
expect(shortcutFor('1', true)).toBe('correct')
|
||||
expect(shortcutFor('2', true)).toBe('wrong')
|
||||
expect(shortcutFor('3', true)).toBe('again')
|
||||
expect(shortcutFor('4', true)).toBeNull()
|
||||
expect(shortcutFor('q', true)).toBeNull()
|
||||
// Once revealed, space no longer reveals anything.
|
||||
expect(shortcutFor(' ', true)).toBeNull()
|
||||
})
|
||||
|
||||
it('never fires while the learner is typing or using a control', () => {
|
||||
const form = document.createElement('form')
|
||||
const input = document.createElement('input')
|
||||
const textarea = document.createElement('textarea')
|
||||
const button = document.createElement('button')
|
||||
const contenteditable = document.createElement('div')
|
||||
contenteditable.setAttribute('contenteditable', 'true')
|
||||
const link = document.createElement('a')
|
||||
link.setAttribute('href', '/vocab')
|
||||
const plain = document.createElement('p')
|
||||
form.append(input, textarea, button, contenteditable, link, plain)
|
||||
for (const element of [input, textarea, contenteditable]) {
|
||||
expect(ignoresShortcut(element, '1')).toBe(true)
|
||||
expect(ignoresShortcut(element, ' ')).toBe(true)
|
||||
}
|
||||
// A focused button or link owns Space and Enter itself, but not the digit keys.
|
||||
expect(ignoresShortcut(button, ' ')).toBe(true)
|
||||
expect(ignoresShortcut(button, 'Enter')).toBe(true)
|
||||
expect(ignoresShortcut(button, '1')).toBe(false)
|
||||
expect(ignoresShortcut(link, ' ')).toBe(true)
|
||||
expect(ignoresShortcut(link, '2')).toBe(false)
|
||||
expect(ignoresShortcut(plain, '1')).toBe(false)
|
||||
expect(ignoresShortcut(null, '1')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { ElDropdown, ElDropdownItem, ElDropdownMenu } from 'element-plus'
|
||||
import { FONT_CHOICES, FONT_LABELS, THEME_CHOICES, THEME_LABELS, useDisplayStore } from '../stores/preferences'
|
||||
|
||||
// One header control for both display choices. The menu shows the current values, so the
|
||||
// learner can tell what is active without opening anything else.
|
||||
const display = useDisplayStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDropdown trigger="click" data-testid="display-settings" @command="(value: string) => {
|
||||
const [group, choice] = value.split(':')
|
||||
if (group === 'theme') display.setTheme(choice as never)
|
||||
if (group === 'font') display.setFont(choice as never)
|
||||
}">
|
||||
<button type="button" class="display-trigger" aria-label="显示设置" data-testid="display-trigger">
|
||||
显示
|
||||
<span class="display-current">{{ THEME_LABELS[display.theme] }} · {{ FONT_LABELS[display.font] }}</span>
|
||||
</button>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem disabled>主题</ElDropdownItem>
|
||||
<ElDropdownItem
|
||||
v-for="choice in THEME_CHOICES"
|
||||
:key="choice"
|
||||
:command="`theme:${choice}`"
|
||||
:class="{ 'is-current': display.theme === choice }"
|
||||
>{{ THEME_LABELS[choice] }}{{ display.theme === choice ? ' ✓' : '' }}</ElDropdownItem>
|
||||
<ElDropdownItem divided disabled>正文字号</ElDropdownItem>
|
||||
<ElDropdownItem
|
||||
v-for="choice in FONT_CHOICES"
|
||||
:key="choice"
|
||||
:command="`font:${choice}`"
|
||||
:class="{ 'is-current': display.font === choice }"
|
||||
>{{ FONT_LABELS[choice] }}{{ display.font === choice ? ' ✓' : '' }}</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</template>
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Remembers where the reader stopped in a chapter. The position belongs to one account and one
|
||||
* chapter, and it is tied to the chapter's content version: after the text is replaced, the old
|
||||
* offset would point at different words, so it is dropped instead of restored.
|
||||
*
|
||||
* The value is a scroll ratio rather than a pixel offset, because the same chapter is read at
|
||||
* different window widths (desktop and phone) where a pixel offset would land in the wrong place.
|
||||
* This is local to the browser: it is not synced across devices.
|
||||
*/
|
||||
export const POSITION_KEY_PREFIX = 'lexgo-learner-position:'
|
||||
|
||||
export interface ReadingPosition {
|
||||
ratio: number
|
||||
sha: string
|
||||
at: string
|
||||
}
|
||||
|
||||
export function positionKey(userId: number | null, chapterId: number): string | null {
|
||||
if (userId === null || !Number.isInteger(chapterId) || chapterId <= 0) return null
|
||||
return `${POSITION_KEY_PREFIX}${userId}:${chapterId}`
|
||||
}
|
||||
|
||||
/** Clamps a stored ratio into [0,1]; anything else is treated as "no usable position". */
|
||||
export function normalizeRatio(value: unknown): number | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return null
|
||||
if (value <= 0) return null
|
||||
return Math.min(value, 1)
|
||||
}
|
||||
|
||||
export function readPosition(storage: Storage, userId: number | null, chapterId: number, sha: string): ReadingPosition | null {
|
||||
const key = positionKey(userId, chapterId)
|
||||
if (key === null) return null
|
||||
try {
|
||||
const raw = storage.getItem(key)
|
||||
if (raw === null) return null
|
||||
const parsed = JSON.parse(raw) as Partial<ReadingPosition> | null
|
||||
const ratio = normalizeRatio(parsed?.ratio)
|
||||
// A different content version means the offset describes words that are no longer there.
|
||||
if (ratio === null || !parsed || parsed.sha !== sha) return null
|
||||
return { ratio, sha, at: typeof parsed.at === 'string' ? parsed.at : '' }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writePosition(storage: Storage, userId: number | null, chapterId: number, sha: string, ratio: number, now = new Date()): void {
|
||||
const key = positionKey(userId, chapterId)
|
||||
const usable = normalizeRatio(ratio)
|
||||
if (key === null || usable === null || !sha) return
|
||||
try {
|
||||
storage.setItem(key, JSON.stringify({ ratio: usable, sha, at: now.toISOString() } satisfies ReadingPosition))
|
||||
} catch {
|
||||
// A full or disabled store must not break reading.
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPosition(storage: Storage, userId: number | null, chapterId: number): void {
|
||||
const key = positionKey(userId, chapterId)
|
||||
if (key === null) return
|
||||
try {
|
||||
storage.removeItem(key)
|
||||
} catch {
|
||||
// Ignored for the same reason as above.
|
||||
}
|
||||
}
|
||||
|
||||
export interface ReadingPositionOptions {
|
||||
/** The scrollable document the reader lives in. */
|
||||
view: () => Window
|
||||
/** The current scrollable height, used to turn a pixel offset into a ratio. */
|
||||
scrollHeight: () => number
|
||||
}
|
||||
|
||||
/** Turns the current window scroll offset into the ratio that is stored. */
|
||||
export function currentRatio(view: Pick<Window, 'scrollY' | 'innerHeight'>, scrollHeight: number): number {
|
||||
const scrollable = scrollHeight - view.innerHeight
|
||||
if (scrollable <= 0) return 0
|
||||
return Math.min(Math.max(view.scrollY / scrollable, 0), 1)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { onScopeDispose } from 'vue'
|
||||
|
||||
export type ReviewShortcut = 'reveal' | 'correct' | 'wrong' | 'again'
|
||||
|
||||
export interface ReviewShortcutOptions {
|
||||
/** Called for a shortcut that applies in the current card state. */
|
||||
onShortcut: (action: ReviewShortcut) => void
|
||||
/** False while the card is busy or an answer failed; then no shortcut is accepted. */
|
||||
enabled: () => boolean
|
||||
/** Whether the answer is already on screen. */
|
||||
revealed: () => boolean
|
||||
}
|
||||
|
||||
const GRADE_KEYS: Record<string, ReviewShortcut> = { '1': 'correct', '2': 'wrong', '3': 'again' }
|
||||
|
||||
/**
|
||||
* True when the key belongs to whatever currently has the focus instead of the review.
|
||||
*
|
||||
* Typing fields and dropdowns own every key. A focused button or link owns Space and Enter,
|
||||
* because the browser already activates it there and a second handler would answer twice — but a
|
||||
* digit is not a button key, so grading from the keyboard keeps working while a button is focused.
|
||||
*/
|
||||
export function ignoresShortcut(target: EventTarget | null, key: string): boolean {
|
||||
const element = target as HTMLElement | null
|
||||
if (!element || typeof element.closest !== 'function') return false
|
||||
if (element.isContentEditable) return true
|
||||
if (element.closest('input, textarea, select, [contenteditable="true"], .el-select, .el-dropdown') !== null) return true
|
||||
if (key === ' ' || key === 'Enter') {
|
||||
return element.closest('button, a[href], [role="button"]') !== null
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Maps one key press to a review action, or null when the key is not ours. */
|
||||
export function shortcutFor(key: string, revealed: boolean): ReviewShortcut | null {
|
||||
if (key === ' ' || key === 'Enter') return revealed ? null : 'reveal'
|
||||
if (!revealed) return null
|
||||
return GRADE_KEYS[key] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyboard operations for the due review: Space or Enter shows the answer, then 1, 2 and 3 grade
|
||||
* it. A modifier key means the learner is using a browser or system shortcut, so it is left alone.
|
||||
*/
|
||||
export function useReviewShortcuts(options: ReviewShortcutOptions) {
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return
|
||||
if (!options.enabled()) return
|
||||
if (ignoresShortcut(event.target, event.key)) return
|
||||
const action = shortcutFor(event.key, options.revealed())
|
||||
if (action === null) return
|
||||
event.preventDefault()
|
||||
options.onShortcut(action)
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', onKeydown)
|
||||
onScopeDispose(() => document.removeEventListener('keydown', onKeydown))
|
||||
}
|
||||
@@ -8,6 +8,10 @@ import 'element-plus/es/components/radio/style/css'
|
||||
import 'element-plus/es/components/radio-group/style/css'
|
||||
import 'element-plus/es/components/select/style/css'
|
||||
import 'element-plus/es/components/option/style/css'
|
||||
import 'element-plus/es/components/dropdown/style/css'
|
||||
import 'element-plus/es/components/dropdown-menu/style/css'
|
||||
import 'element-plus/es/components/dropdown-item/style/css'
|
||||
import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).use(createPinia()).use(router).mount('#app')
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { useSessionStore } from './session'
|
||||
|
||||
/** The three display choices the learner can make. */
|
||||
export type ThemeChoice = 'light' | 'dark' | 'system'
|
||||
export type FontChoice = 'standard' | 'large' | 'xlarge'
|
||||
|
||||
export const THEME_CHOICES: ThemeChoice[] = ['light', 'dark', 'system']
|
||||
export const FONT_CHOICES: FontChoice[] = ['standard', 'large', 'xlarge']
|
||||
|
||||
export const THEME_LABELS: Record<ThemeChoice, string> = { light: '浅色', dark: '深色', system: '跟随系统' }
|
||||
export const FONT_LABELS: Record<FontChoice, string> = { standard: '标准', large: '大', xlarge: '特大' }
|
||||
|
||||
/**
|
||||
* The reader text scale. It multiplies the reading surfaces only (chapter text, lookup content,
|
||||
* review card), so a larger step never squeezes the navigation or the buttons in a narrow window.
|
||||
*/
|
||||
export const FONT_SCALES: Record<FontChoice, string> = { standard: '1', large: '1.15', xlarge: '1.3' }
|
||||
|
||||
export interface DisplayPreference {
|
||||
theme: ThemeChoice
|
||||
font: FontChoice
|
||||
}
|
||||
|
||||
/** Preferences are stored per account, so switching accounts cannot carry one across. */
|
||||
export const DISPLAY_KEY_PREFIX = 'lexgo-learner-display:'
|
||||
|
||||
export function displayKey(userId: number | null): string | null {
|
||||
return userId === null ? null : `${DISPLAY_KEY_PREFIX}${userId}`
|
||||
}
|
||||
|
||||
export function defaultPreference(): DisplayPreference {
|
||||
return { theme: 'system', font: 'standard' }
|
||||
}
|
||||
|
||||
function isTheme(value: unknown): value is ThemeChoice {
|
||||
return typeof value === 'string' && (THEME_CHOICES as string[]).includes(value)
|
||||
}
|
||||
|
||||
function isFont(value: unknown): value is FontChoice {
|
||||
return typeof value === 'string' && (FONT_CHOICES as string[]).includes(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one account's stored display preference. Anything unreadable, unknown or from an older
|
||||
* shape falls back to the default instead of throwing or half-applying a stored value.
|
||||
*/
|
||||
export function readPreference(storage: Storage, userId: number | null): DisplayPreference {
|
||||
const key = displayKey(userId)
|
||||
if (key === null) return defaultPreference()
|
||||
try {
|
||||
const raw = storage.getItem(key)
|
||||
if (raw === null) return defaultPreference()
|
||||
const parsed = JSON.parse(raw) as Partial<DisplayPreference> | null
|
||||
const preference = defaultPreference()
|
||||
if (parsed && isTheme(parsed.theme)) preference.theme = parsed.theme
|
||||
if (parsed && isFont(parsed.font)) preference.font = parsed.font
|
||||
return preference
|
||||
} catch {
|
||||
return defaultPreference()
|
||||
}
|
||||
}
|
||||
|
||||
export function writePreference(storage: Storage, userId: number | null, preference: DisplayPreference): void {
|
||||
const key = displayKey(userId)
|
||||
if (key === null) return
|
||||
try {
|
||||
storage.setItem(key, JSON.stringify({ theme: preference.theme, font: preference.font }))
|
||||
} catch {
|
||||
// A full or disabled store must not break the page: the choice still applies for this visit.
|
||||
}
|
||||
}
|
||||
|
||||
/** "system" resolves through the operating system preference; the other two are explicit. */
|
||||
export function resolveTheme(theme: ThemeChoice, prefersDark: boolean): 'light' | 'dark' {
|
||||
if (theme === 'system') return prefersDark ? 'dark' : 'light'
|
||||
return theme
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a preference to the document: the palette attribute, the Element Plus dark class and
|
||||
* the reading font scale. The dark class is what Element Plus components read.
|
||||
*/
|
||||
export function applyPreference(root: HTMLElement, preference: DisplayPreference, prefersDark: boolean): 'light' | 'dark' {
|
||||
const resolved = resolveTheme(preference.theme, prefersDark)
|
||||
root.dataset.theme = resolved
|
||||
root.dataset.themeChoice = preference.theme
|
||||
root.classList.toggle('dark', resolved === 'dark')
|
||||
root.style.setProperty('--reader-font-scale', FONT_SCALES[preference.font])
|
||||
return resolved
|
||||
}
|
||||
|
||||
export function systemPrefersDark(): boolean {
|
||||
return typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
: false
|
||||
}
|
||||
|
||||
export const useDisplayStore = defineStore('display', () => {
|
||||
const session = useSessionStore()
|
||||
const preference = ref<DisplayPreference>(defaultPreference())
|
||||
const resolved = ref<'light' | 'dark'>('light')
|
||||
let listening: MediaQueryList | null = null
|
||||
|
||||
const theme = computed(() => preference.value.theme)
|
||||
const font = computed(() => preference.value.font)
|
||||
const isDark = computed(() => resolved.value === 'dark')
|
||||
|
||||
function storage(): Storage | null {
|
||||
try {
|
||||
return window.localStorage
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function paint(): void {
|
||||
if (typeof document === 'undefined') return
|
||||
resolved.value = applyPreference(document.documentElement, preference.value, systemPrefersDark())
|
||||
}
|
||||
|
||||
/** Watches the operating system only while the choice actually follows it. */
|
||||
function followSystem(): void {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return
|
||||
listening?.removeEventListener('change', paint)
|
||||
listening = null
|
||||
if (preference.value.theme !== 'system') return
|
||||
listening = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
listening.addEventListener('change', paint)
|
||||
}
|
||||
|
||||
/** Loads the account's own preference (or the default) and applies it at once. */
|
||||
function load(userId: number | null): void {
|
||||
const store = storage()
|
||||
preference.value = store ? readPreference(store, userId) : defaultPreference()
|
||||
paint()
|
||||
followSystem()
|
||||
}
|
||||
|
||||
function save(next: Partial<DisplayPreference>): void {
|
||||
if (next.theme !== undefined && isTheme(next.theme)) preference.value.theme = next.theme
|
||||
if (next.font !== undefined && isFont(next.font)) preference.value.font = next.font
|
||||
const store = storage()
|
||||
if (store) writePreference(store, session.user?.id ?? null, preference.value)
|
||||
paint()
|
||||
followSystem()
|
||||
}
|
||||
|
||||
watch(() => session.user?.id ?? null, (next, previous) => {
|
||||
if (next !== previous) load(next)
|
||||
}, { immediate: true })
|
||||
|
||||
return { preference, theme, font, resolved, isDark, load, save, setTheme: (value: ThemeChoice) => save({ theme: value }), setFont: (value: FontChoice) => save({ font: value }) }
|
||||
})
|
||||
+221
-82
@@ -1,152 +1,273 @@
|
||||
:root {
|
||||
/* One palette for both themes. Every surface, text and status colour in the rules below
|
||||
resolves through these variables, so the dark theme only overrides values, never layout.
|
||||
These are the only literal colours in the file. */
|
||||
--bg: #f7f5ee;
|
||||
--surface: #fffdf8;
|
||||
--surface-raised: #fffdf7;
|
||||
--surface-input: #fffefa;
|
||||
--text: #233d31;
|
||||
--text-strong: #264a35;
|
||||
--text-muted: #748073;
|
||||
--text-faint: #687568;
|
||||
--border: #e0e3d8;
|
||||
--border-strong: #d9decf;
|
||||
--border-input: #d6dccf;
|
||||
--accent: #315c43;
|
||||
--accent-soft: #eef2eb;
|
||||
--accent-marker: #bc803d;
|
||||
--accent-gold: #ba8345;
|
||||
--brand-panel: #284c38;
|
||||
--brand-panel-text: #f7f5ee;
|
||||
--brand-panel-muted: #d4dfd2;
|
||||
--brand-panel-faint: #c4d1c0;
|
||||
--brand-panel-line: #d6b980;
|
||||
--status-pending-bg: #f5f2e4;
|
||||
--status-pending-border: #e2dcc2;
|
||||
--status-pending-text: #7a6a35;
|
||||
--status-processing-bg: #eaf1f7;
|
||||
--status-processing-border: #c9dcea;
|
||||
--status-processing-text: #35566e;
|
||||
--status-ready-bg: #eef2eb;
|
||||
--status-ready-border: #cbd9c9;
|
||||
--status-ready-text: #315c43;
|
||||
--status-failed-bg: #fff0e7;
|
||||
--status-failed-border: #ebc3a8;
|
||||
--status-failed-text: #8b4324;
|
||||
--status-read-bg: #e7f0e9;
|
||||
--status-read-border: #b9d3bf;
|
||||
--status-read-text: #2c5b3d;
|
||||
--notice-bg: #fff0e7;
|
||||
--notice-border: #ebc3a8;
|
||||
--notice-text: #8b4324;
|
||||
--highlight: #eef2eb;
|
||||
--sense-bg: #fbf7ee;
|
||||
--sense-border: #d6b980;
|
||||
--sense-text: #6b5b3e;
|
||||
--sense-muted: #8b794e;
|
||||
--saved: #2f6b45;
|
||||
--word-learning-bg: #f6e7c9;
|
||||
--word-known-bg: #dee9d6;
|
||||
--word-ignored-text: #949c94;
|
||||
--word-selected-bg: #e3e9d9;
|
||||
--reader-font-scale: 1;
|
||||
|
||||
font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
||||
color: #233d31;
|
||||
background: #f7f5ee;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
--el-color-primary: #315c43;
|
||||
--el-color-primary: var(--accent);
|
||||
--el-color-primary-light-3: #597f66;
|
||||
--el-color-primary-light-5: #8da493;
|
||||
--el-color-primary-light-7: #bdccc0;
|
||||
--el-color-primary-light-9: #eef2eb;
|
||||
--el-color-primary-dark-2: #264a35;
|
||||
--el-color-primary-light-9: var(--accent-soft);
|
||||
--el-color-primary-dark-2: var(--text-strong);
|
||||
--el-border-radius-base: 8px;
|
||||
--el-font-size-base: 15px;
|
||||
}
|
||||
|
||||
/* Dark palette. The Element Plus dark class is toggled by the display store, so components
|
||||
follow the same choice as the page palette. */
|
||||
html[data-theme='dark'] {
|
||||
--bg: #171a17;
|
||||
--surface: #1f231f;
|
||||
--surface-raised: #1c201c;
|
||||
--surface-input: #242824;
|
||||
--text: #e7ece4;
|
||||
--text-strong: #cfe0d3;
|
||||
--text-muted: #9fae9c;
|
||||
--text-faint: #8e9c8c;
|
||||
--border: #333a33;
|
||||
--border-strong: #3b433b;
|
||||
--border-input: #3f483f;
|
||||
--accent: #7db28f;
|
||||
--accent-soft: #24312a;
|
||||
--accent-marker: #d7a45f;
|
||||
--accent-gold: #d7a45f;
|
||||
--brand-panel: #10140f;
|
||||
--brand-panel-text: #e7ece4;
|
||||
--brand-panel-muted: #b9c7b6;
|
||||
--brand-panel-faint: #97a695;
|
||||
--brand-panel-line: #d7a45f;
|
||||
--status-pending-bg: #33301f;
|
||||
--status-pending-border: #4a442b;
|
||||
--status-pending-text: #e0cf94;
|
||||
--status-processing-bg: #1d2a33;
|
||||
--status-processing-border: #2f4756;
|
||||
--status-processing-text: #a8cbe0;
|
||||
--status-ready-bg: #22301f;
|
||||
--status-ready-border: #35492f;
|
||||
--status-ready-text: #a9d0ac;
|
||||
--status-failed-bg: #33231b;
|
||||
--status-failed-border: #4d3527;
|
||||
--status-failed-text: #e9ad86;
|
||||
--status-read-bg: #1f3024;
|
||||
--status-read-border: #33493a;
|
||||
--status-read-text: #a5d3b1;
|
||||
--notice-bg: #33231b;
|
||||
--notice-border: #4d3527;
|
||||
--notice-text: #e9ad86;
|
||||
--highlight: #26332a;
|
||||
--sense-bg: #23261d;
|
||||
--sense-border: #4a4227;
|
||||
--sense-text: #d9cba6;
|
||||
--sense-muted: #c0ad7f;
|
||||
--saved: #8ec69f;
|
||||
--word-learning-bg: #3b3520;
|
||||
--word-known-bg: #253120;
|
||||
--word-ignored-text: #7d867d;
|
||||
--word-selected-bg: #2b382d;
|
||||
}
|
||||
|
||||
/* Element Plus reads its own dark variables; the shared palette keeps both in step. */
|
||||
html.dark {
|
||||
--el-bg-color: var(--surface);
|
||||
--el-bg-color-overlay: var(--surface-raised);
|
||||
--el-text-color-primary: var(--text);
|
||||
--el-text-color-regular: var(--text);
|
||||
--el-text-color-secondary: var(--text-muted);
|
||||
--el-border-color: var(--border);
|
||||
--el-border-color-light: var(--border);
|
||||
--el-fill-color-blank: var(--surface-input);
|
||||
--el-fill-color-light: var(--accent-soft);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 320px; }
|
||||
button, input { font: inherit; }
|
||||
button:focus-visible, a:focus-visible { outline: 3px solid #bc803d; outline-offset: 4px; }
|
||||
|
||||
a { color: inherit; }
|
||||
.brand { font-family: Georgia, serif; font-size: 30px; font-weight: 700; letter-spacing: -1px; text-decoration: none; }
|
||||
.brand-dot { color: #ba8345; }
|
||||
.brand-dot { color: var(--accent-gold); }
|
||||
.login-page { min-height: 100dvh; display: grid; grid-template-columns: minmax(340px, 1fr) minmax(400px, 1fr); }
|
||||
.welcome { background: #284c38; color: #f7f5ee; padding: 46px 10%; display: flex; flex-direction: column; justify-content: space-between; }
|
||||
.welcome .brand { color: #f7f5ee; }
|
||||
.welcome { background: var(--brand-panel); color: var(--brand-panel-text); padding: 46px 10%; display: flex; flex-direction: column; justify-content: space-between; }
|
||||
.welcome .brand { color: var(--brand-panel-text); }
|
||||
.welcome-copy { padding: 80px 0; max-width: 450px; }
|
||||
.welcome-copy h2 { font-family: Georgia, 'Microsoft YaHei', serif; font-weight: 400; font-size: clamp(32px, 3.3vw, 52px); line-height: 1.55; letter-spacing: 2px; margin: 26px 0; }
|
||||
.welcome-copy p { font-size: 16px; color: #d4dfd2; line-height: 1.9; }
|
||||
.book-mark { width: 60px; height: 60px; color: #d6b980; }
|
||||
.welcome-foot { font-size: 13px; letter-spacing: 2px; color: #c4d1c0; }
|
||||
.welcome-copy p { font-size: 16px; color: var(--brand-panel-muted); line-height: 1.9; }
|
||||
.book-mark { width: 60px; height: 60px; color: var(--brand-panel-line); }
|
||||
.welcome-foot { font-size: 13px; letter-spacing: 2px; color: var(--brand-panel-faint); }
|
||||
.login-main { display: grid; place-items: center; padding: 48px 28px; }
|
||||
.login-form { width: min(100%, 360px); }
|
||||
.eyebrow { color: #687568; font-size: 12px; letter-spacing: 3px; }
|
||||
.eyebrow { color: var(--text-faint); font-size: 12px; letter-spacing: 3px; }
|
||||
h1 { font-size: 30px; font-weight: 600; margin: 14px 0; letter-spacing: 1px; }
|
||||
.subtle { color: #748073; font-size: 14px; line-height: 1.8; }
|
||||
.subtle { color: var(--text-muted); font-size: 14px; line-height: 1.8; }
|
||||
.login-form form { margin-top: 36px; }
|
||||
.field { display: block; margin-bottom: 22px; }
|
||||
.field label { display: block; font-size: 14px; margin-bottom: 10px; }
|
||||
.el-input { --el-input-height: 46px; --el-input-bg-color: #fffefa; --el-input-border-color: #d6dccf; }
|
||||
.el-input { --el-input-height: 46px; --el-input-bg-color: var(--surface-input); --el-input-border-color: var(--border-input); }
|
||||
.el-button { min-height: 42px; }
|
||||
.login-submit { width: 100%; margin-top: 8px; height: 48px; letter-spacing: 4px; }
|
||||
.notice { padding: 12px 15px; background: #fff0e7; border: 1px solid #ebc3a8; color: #8b4324; border-radius: 6px; font-size: 14px; line-height: 1.6; }
|
||||
.site-header { padding: 24px max(24px, calc((100vw - 1200px) / 2)); border-bottom: 1px solid #e0e3d8; display: flex; align-items: center; gap: 32px; background: #fffdf7; }
|
||||
.notice { padding: 12px 15px; background: var(--notice-bg); border: 1px solid var(--notice-border); color: var(--notice-text); border-radius: 6px; font-size: 14px; line-height: 1.6; }
|
||||
.site-header { padding: 24px max(24px, calc((100vw - 1200px) / 2)); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 32px; background: var(--surface-raised); }
|
||||
.site-header nav { flex: 1; }
|
||||
.active-nav { font-size: 15px; text-decoration: none; padding: 13px 0; border-bottom: 2px solid #315c43; }
|
||||
.active-nav { font-size: 15px; text-decoration: none; padding: 13px 0; border-bottom: 2px solid var(--accent); }
|
||||
.account { display: flex; align-items: center; gap: 18px; font-size: 14px; }
|
||||
.account-name { max-width: 200px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.library { max-width: 1120px; margin: 60px auto; padding: 0 28px; }
|
||||
.library-title { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 38px; }
|
||||
.language { border: 1px solid #d9decf; padding: 9px 17px; border-radius: 20px; font-size: 13px; background: #fffdf7; }
|
||||
.empty-library { background: #fffdf8; border: 1px solid #e0e3d8; border-radius: 12px; min-height: 360px; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 44px 20px; text-align: center; }
|
||||
.empty-library .book-mark { color: #6e8967; width: 56px; height: 56px; }
|
||||
.language { border: 1px solid var(--border-strong); padding: 9px 17px; border-radius: 20px; font-size: 13px; background: var(--surface-raised); }
|
||||
.empty-library { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; min-height: 360px; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 44px 20px; text-align: center; }
|
||||
.empty-library .book-mark { color: var(--accent); width: 56px; height: 56px; }
|
||||
.empty-library h2 { font-weight: 500; font-size: 21px; margin: 26px 0 4px; }
|
||||
.loading { padding: 80px 24px; text-align: center; color: #748073; }
|
||||
.loading { padding: 80px 24px; text-align: center; color: var(--text-muted); }
|
||||
/* Import / book / reader pages share one shell. */
|
||||
.page { max-width: 1120px; margin: 60px auto; padding: 0 28px; }
|
||||
.page-title { display: flex; align-items: center; justify-content: space-between; gap: 18px; flex-wrap: wrap; margin-bottom: 28px; }
|
||||
.page-title h1 { margin: 14px 0 6px; overflow-wrap: anywhere; }
|
||||
.page-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.library-actions { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
|
||||
.link-button { display: inline-flex; align-items: center; min-height: 42px; padding: 0 8px; color: #315c43; }
|
||||
.breadcrumb { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 0 0 4px; font-size: 14px; color: #748073; }
|
||||
.breadcrumb a { color: #315c43; }
|
||||
.link-button { display: inline-flex; align-items: center; min-height: 42px; padding: 0 8px; color: var(--accent); }
|
||||
.breadcrumb { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 0 0 4px; font-size: 14px; color: var(--text-muted); }
|
||||
.breadcrumb a { color: var(--accent); }
|
||||
/* Library book cards. */
|
||||
.book-grid { list-style: none; margin: 0; padding: 0; display: grid; gap: 18px; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); }
|
||||
.book-card { display: flex; flex-direction: column; gap: 10px; background: #fffdf8; border: 1px solid #e0e3d8; border-radius: 12px; padding: 22px; }
|
||||
.book-card { display: flex; flex-direction: column; gap: 10px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 22px; }
|
||||
.book-card .subtle { margin: 0; }
|
||||
.book-title { font-family: Georgia, 'Microsoft YaHei', serif; font-size: 21px; font-weight: 600; color: #233d31; text-decoration: none; overflow-wrap: anywhere; }
|
||||
.book-title:hover { color: #315c43; text-decoration: underline; }
|
||||
.status-summary { align-self: flex-start; margin: 0; padding: 6px 13px; border-radius: 20px; background: #eef2eb; color: #3d5b48; font-size: 13px; }
|
||||
.book-title { font-family: Georgia, 'Microsoft YaHei', serif; font-size: 21px; font-weight: 600; color: var(--text); text-decoration: none; overflow-wrap: anywhere; }
|
||||
.book-title:hover { color: var(--accent); text-decoration: underline; }
|
||||
.status-summary { align-self: flex-start; margin: 0; padding: 6px 13px; border-radius: 20px; background: var(--accent-soft); color: var(--accent); font-size: 13px; }
|
||||
/* Chapter and job status, identical vocabulary for both. */
|
||||
.status-chip { display: inline-flex; align-items: center; white-space: nowrap; padding: 5px 13px; border: 1px solid transparent; border-radius: 20px; font-size: 13px; }
|
||||
.status-pending { background: #f5f2e4; border-color: #e2dcc2; color: #7a6a35; }
|
||||
.status-processing { background: #eaf1f7; border-color: #c9dcea; color: #35566e; }
|
||||
.status-ready { background: #eef2eb; border-color: #cbd9c9; color: #315c43; }
|
||||
.status-failed { background: #fff0e7; border-color: #ebc3a8; color: #8b4324; }
|
||||
.status-read { background: #e7f0e9; border-color: #b9d3bf; color: #2c5b3d; }
|
||||
.status-pending { background: var(--status-pending-bg); border-color: var(--status-pending-border); color: var(--status-pending-text); }
|
||||
.status-processing { background: var(--status-processing-bg); border-color: var(--status-processing-border); color: var(--status-processing-text); }
|
||||
.status-ready { background: var(--accent-soft); border-color: var(--status-ready-border); color: var(--accent); }
|
||||
.status-failed { background: var(--notice-bg); border-color: var(--notice-border); color: var(--notice-text); }
|
||||
.status-read { background: var(--status-read-bg); border-color: var(--status-read-border); color: var(--status-read-text); }
|
||||
/* Completing a chapter: one explicit action, with the reading rule spelled out. */
|
||||
.chapter-complete { display: flex; align-items: center; justify-content: space-between; gap: 18px; flex-wrap: wrap; margin-top: 22px; padding: 18px 22px; background: #fffdf8; border: 1px solid #e0e3d8; border-radius: 12px; }
|
||||
.chapter-complete { display: flex; align-items: center; justify-content: space-between; gap: 18px; flex-wrap: wrap; margin-top: 22px; padding: 18px 22px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; }
|
||||
.chapter-complete .subtle { margin: 6px 0 0; }
|
||||
.read-state { margin: 0; font-weight: 600; color: #2c5b3d; }
|
||||
.read-state { margin: 0; font-weight: 600; color: var(--status-read-text); }
|
||||
/* Progress page. */
|
||||
.progress-stats { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); margin-top: 6px; }
|
||||
.stat-card { background: #fffdf8; border: 1px solid #e0e3d8; border-radius: 12px; padding: 20px 22px; }
|
||||
.stat-card h2 { margin: 0 0 10px; font-size: 15px; font-weight: 600; color: #3d5b48; letter-spacing: 0; }
|
||||
.stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 20px 22px; }
|
||||
.stat-card h2 { margin: 0 0 10px; font-size: 15px; font-weight: 600; color: var(--accent); letter-spacing: 0; }
|
||||
.stat-card .subtle { margin: 6px 0 0; }
|
||||
.stat-value { margin: 0; font-family: Georgia, 'Microsoft YaHei', serif; font-size: 30px; line-height: 1.2; color: #233d31; }
|
||||
.stat-value { margin: 0; font-family: Georgia, 'Microsoft YaHei', serif; font-size: 30px; line-height: 1.2; color: var(--text); }
|
||||
.progress-books { margin-top: 34px; }
|
||||
.progress-books h2 { margin: 0 0 14px; font-size: 19px; }
|
||||
.progress-book-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.progress-book-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; background: #fffdf8; border: 1px solid #e0e3d8; border-radius: 12px; padding: 16px 18px; }
|
||||
.progress-book-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 16px 18px; }
|
||||
/* Import form. */
|
||||
.import-form { max-width: 720px; background: #fffdf8; border: 1px solid #e0e3d8; border-radius: 12px; padding: 28px; }
|
||||
.import-form { max-width: 720px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 28px; }
|
||||
.field-label { display: block; font-size: 14px; margin-bottom: 10px; }
|
||||
.fixed-value { margin: 0; padding: 12px 15px; border: 1px solid #d6dccf; border-radius: 8px; background: #fffefa; font-size: 15px; }
|
||||
.field-error { margin: 8px 0 0; color: #8b4324; font-size: 13px; line-height: 1.6; }
|
||||
.counter { margin: 8px 0 0; color: #748073; font-size: 13px; }
|
||||
.fixed-value { margin: 0; padding: 12px 15px; border: 1px solid var(--border-input); border-radius: 8px; background: var(--surface-input); font-size: 15px; }
|
||||
.field-error { margin: 8px 0 0; color: var(--notice-text); font-size: 13px; line-height: 1.6; }
|
||||
.counter { margin: 8px 0 0; color: var(--text-muted); font-size: 13px; }
|
||||
.form-actions { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-top: 14px; }
|
||||
.import-form .el-textarea__inner { min-height: 220px; line-height: 1.9; }
|
||||
.book-select { width: 100%; }
|
||||
/* Book chapters. */
|
||||
.chapter-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||
.chapter-row { display: grid; grid-template-columns: 40px minmax(0, 1fr) auto auto; align-items: center; gap: 14px; background: #fffdf8; border: 1px solid #e0e3d8; border-radius: 12px; padding: 16px 18px; }
|
||||
.chapter-ordinal { font-family: Georgia, serif; font-size: 17px; color: #8b9a8b; text-align: center; }
|
||||
.chapter-row { display: grid; grid-template-columns: 40px minmax(0, 1fr) auto auto; align-items: center; gap: 14px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 16px 18px; }
|
||||
.chapter-ordinal { font-family: Georgia, serif; font-size: 17px; color: var(--text-faint); text-align: center; }
|
||||
.chapter-info { min-width: 0; }
|
||||
.chapter-name { display: inline-block; font-size: 16px; font-weight: 500; color: #233d31; text-decoration: none; overflow-wrap: anywhere; }
|
||||
a.chapter-name:hover { color: #315c43; text-decoration: underline; }
|
||||
.chapter-meta { margin: 4px 0 0; color: #748073; font-size: 13px; overflow-wrap: anywhere; }
|
||||
.chapter-name { display: inline-block; font-size: 16px; font-weight: 500; color: var(--text); text-decoration: none; overflow-wrap: anywhere; }
|
||||
a.chapter-name:hover { color: var(--accent); text-decoration: underline; }
|
||||
.chapter-meta { margin: 4px 0 0; color: var(--text-muted); font-size: 13px; overflow-wrap: anywhere; }
|
||||
/* Reader keeps the pasted text exactly as it was, including line breaks and tabs. */
|
||||
.reader-page { max-width: 820px; }
|
||||
.reader-text { white-space: pre-wrap; overflow-wrap: break-word; margin: 26px 0 0; font-family: Georgia, 'Times New Roman', 'Microsoft YaHei', serif; font-size: 17px; line-height: 2; }
|
||||
.processing-hint { padding: 36px 0; color: #748073; }
|
||||
.reader-nav { display: flex; align-items: center; justify-content: space-between; gap: 14px; flex-wrap: wrap; margin-top: 36px; padding-top: 22px; border-top: 1px solid #e0e3d8; }
|
||||
.reader-text { white-space: pre-wrap; overflow-wrap: break-word; margin: 26px 0 0; font-family: Georgia, 'Times New Roman', 'Microsoft YaHei', serif; font-size: calc(17px * var(--reader-font-scale)); line-height: 2; }
|
||||
.processing-hint { padding: 36px 0; color: var(--text-muted); }
|
||||
.reader-nav { display: flex; align-items: center; justify-content: space-between; gap: 14px; flex-wrap: wrap; margin-top: 36px; padding-top: 22px; border-top: 1px solid var(--border); }
|
||||
.reader-page.has-lookup { max-width: 1120px; }
|
||||
.reader-workspace { display: grid; grid-template-columns: minmax(0, 1fr); gap: 32px; align-items: start; }
|
||||
.has-lookup .reader-workspace { grid-template-columns: minmax(0, 1fr) 320px; }
|
||||
.reader-body { min-width: 0; }
|
||||
.reader-word { cursor: pointer; border-radius: 3px; }
|
||||
/* Saved words keep one style per status everywhere they appear. */
|
||||
.reader-word.is-new { border-bottom: 2px dotted #bc803d; }
|
||||
.reader-word.is-learning { background: #f6e7c9; }
|
||||
.reader-word.is-known { background: #dee9d6; }
|
||||
.reader-word.is-ignored { color: #949c94; }
|
||||
.reader-word:hover, .reader-word.is-selected { background: #e3e9d9; color: #264a35; }
|
||||
.reader-word:focus-visible { outline: 2px solid #bc803d; outline-offset: 2px; background: #eef2eb; }
|
||||
.tokens-notice { color: #748073; font-size: 14px; margin-top: 24px; }
|
||||
.lookup-panel { position: sticky; top: 24px; margin-top: 26px; padding: 22px; border: 1px solid #d9decf; border-radius: 12px; background: #fffdf8; max-height: calc(100dvh - 48px); overflow-y: auto; overflow-wrap: anywhere; }
|
||||
.lookup-heading { display: flex; align-items: start; justify-content: space-between; gap: 10px; border-bottom: 1px solid #e0e3d8; padding-bottom: 12px; }
|
||||
.reader-word.is-new { border-bottom: 2px dotted var(--accent-marker); }
|
||||
.reader-word.is-learning { background: var(--word-learning-bg); }
|
||||
.reader-word.is-known { background: var(--word-known-bg); }
|
||||
.reader-word.is-ignored { color: var(--word-ignored-text); }
|
||||
.reader-word:hover, .reader-word.is-selected { background: var(--word-selected-bg); color: var(--text-strong); }
|
||||
.reader-word:focus-visible { outline: 2px solid var(--accent-marker); outline-offset: 2px; background: var(--accent-soft); }
|
||||
.tokens-notice { color: var(--text-muted); font-size: 14px; margin-top: 24px; }
|
||||
.lookup-panel { position: sticky; top: 24px; margin-top: 26px; padding: 22px; border: 1px solid var(--border-strong); border-radius: 12px; background: var(--surface); max-height: calc(100dvh - 48px); overflow-y: auto; overflow-wrap: anywhere; }
|
||||
.lookup-heading { display: flex; align-items: start; justify-content: space-between; gap: 10px; border-bottom: 1px solid var(--border); padding-bottom: 12px; }
|
||||
.lookup-heading h2 { margin: 6px 0; font-family: Georgia, serif; font-size: 25px; }
|
||||
.lookup-content { font-size: 15px; line-height: 1.7; }
|
||||
.lookup-message { color: #8b4324; }
|
||||
.lookup-content { font-size: calc(15px * var(--reader-font-scale)); line-height: 1.7; }
|
||||
.lookup-message { color: var(--notice-text); }
|
||||
.lookup-senses { padding-left: 22px; }
|
||||
.lookup-senses li { padding-left: 3px; margin-bottom: 20px; }
|
||||
.lookup-senses p { margin: 8px 0; }
|
||||
.sense-heading span { color: #748073; font-size: 13px; }
|
||||
.lookup-senses blockquote { border-left: 2px solid #cbd9c9; margin: 10px 0; padding-left: 12px; color: #687568; font-style: italic; }
|
||||
.lookup-range { border-bottom: 1px solid #e0e3d8; padding-bottom: 14px; }
|
||||
.sense-heading span { color: var(--text-muted); font-size: 13px; }
|
||||
.lookup-senses blockquote { border-left: 2px solid var(--status-ready-border); margin: 10px 0; padding-left: 12px; color: var(--text-faint); font-style: italic; }
|
||||
.lookup-range { border-bottom: 1px solid var(--border); padding-bottom: 14px; }
|
||||
.lookup-range p { margin: 0 0 10px; }
|
||||
.lookup-range-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
/* A saved phrase is one unit: the run is underlined and keeps its status colour. */
|
||||
.reader-word.is-phrase { text-decoration: underline; text-decoration-style: double; text-underline-offset: 2px; border-radius: 3px; }
|
||||
.reader-word.is-phrase-selected { outline: 2px solid #bc803d; outline-offset: 1px; }
|
||||
.lookup-term { border-top: 1px solid #e0e3d8; padding-top: 18px; margin-top: 20px; }
|
||||
.reader-word.is-phrase-selected { outline: 2px solid var(--accent-marker); outline-offset: 1px; }
|
||||
.lookup-term { border-top: 1px solid var(--border); padding-top: 18px; margin-top: 20px; }
|
||||
.lookup-term label { display: flex; justify-content: space-between; gap: 10px; font-size: 14px; margin-top: 14px; }
|
||||
.lookup-term-title { display: flex; font-size: 14px; margin: 0; }
|
||||
.lookup-term label span { color: #8b794e; font-size: 12px; }
|
||||
.lookup-term label span { color: var(--sense-muted); font-size: 12px; }
|
||||
.lookup-term .el-radio-group { margin-top: 8px; flex-wrap: wrap; gap: 4px 12px; }
|
||||
.lookup-term .el-textarea { margin-top: 8px; }
|
||||
.lookup-term .el-textarea textarea { font: inherit; line-height: 1.6; }
|
||||
.lookup-saved { color: #2f6b45; font-size: 14px; margin: 12px 0 0; }
|
||||
.lookup-saved { color: var(--saved); font-size: 14px; margin: 12px 0 0; }
|
||||
.lookup-actions { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
|
||||
.review-page { max-width: 680px; }
|
||||
.vocab-page { max-width: 760px; }
|
||||
@@ -155,26 +276,26 @@ a.chapter-name:hover { color: #315c43; text-decoration: underline; }
|
||||
.vocab-search .el-input { max-width: 260px; }
|
||||
.vocab-filter { width: 140px; }
|
||||
.vocab-list { list-style: none; padding: 0; margin: 18px 0 0; }
|
||||
.vocab-row { display: flex; align-items: center; gap: 14px; padding: 14px 16px; margin-bottom: 10px; border: 1px solid #dfe3d6; border-radius: 10px; background: #fffdf8; }
|
||||
.vocab-row { display: flex; align-items: center; gap: 14px; padding: 14px 16px; margin-bottom: 10px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); }
|
||||
.vocab-info { min-width: 0; flex: 1; }
|
||||
.vocab-term { margin: 0; font-family: Georgia, serif; font-size: 18px; overflow-wrap: anywhere; }
|
||||
.vocab-paging { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 18px; }
|
||||
.term-fields label { display: flex; justify-content: space-between; gap: 10px; font-size: 14px; margin-top: 14px; }
|
||||
.term-fields label span { color: #8b794e; font-size: 12px; }
|
||||
.term-fields label span { color: var(--sense-muted); font-size: 12px; }
|
||||
.term-fields .el-textarea { margin-top: 8px; }
|
||||
.term-fields .el-radio-group { margin-top: 8px; flex-wrap: wrap; gap: 4px 12px; }
|
||||
.term-level { display: flex; align-items: center; gap: 10px; margin: 12px 0 0; font-size: 14px; }
|
||||
.term-level-select { width: 120px; }
|
||||
.chapter-notice { margin: 12px 0 0; padding: 10px 14px; border: 1px solid #d9decf; border-radius: 8px; background: #fbf7ee; color: #6b5b3e; }
|
||||
.chapter-notice { margin: 12px 0 0; padding: 10px 14px; border: 1px solid var(--border-strong); border-radius: 8px; background: var(--sense-bg); color: var(--sense-text); }
|
||||
.chapter-list .chapter-row { flex-wrap: wrap; }
|
||||
.review-notice { margin: 10px 0 0; padding: 10px 14px; border: 1px solid #d9decf; border-radius: 8px; background: #fbf7ee; color: #6b5b3e; }
|
||||
.review-card, .review-summary { margin-top: 26px; padding: 28px; border: 1px solid #d9decf; border-radius: 14px; background: #fffdf8; }
|
||||
.review-notice { margin: 10px 0 0; padding: 10px 14px; border: 1px solid var(--border-strong); border-radius: 8px; background: var(--sense-bg); color: var(--sense-text); }
|
||||
.review-card, .review-summary { margin-top: 26px; padding: 28px; border: 1px solid var(--border-strong); border-radius: 14px; background: var(--surface); }
|
||||
.review-summary h2 { margin-top: 0; font-family: Georgia, serif; font-size: 24px; }
|
||||
.review-word { margin: 12px 0; font-family: Georgia, serif; font-size: 34px; }
|
||||
.review-word { margin: 12px 0; font-family: Georgia, serif; font-size: calc(34px * var(--reader-font-scale)); }
|
||||
.review-example { margin: 12px 0; font-family: Georgia, serif; font-size: 19px; line-height: 1.8; }
|
||||
.review-answer { margin: 18px 0; padding-top: 18px; border-top: 1px solid #e0e3d8; }
|
||||
.review-definition { margin: 0 0 12px; font-size: 18px; }
|
||||
.review-answer blockquote { border-left: 2px solid #cbd9c9; margin: 10px 0; padding-left: 12px; color: #687568; font-style: italic; }
|
||||
.review-answer { margin: 18px 0; padding-top: 18px; border-top: 1px solid var(--border); }
|
||||
.review-definition { margin: 0 0 12px; font-size: calc(18px * var(--reader-font-scale)); }
|
||||
.review-answer blockquote { border-left: 2px solid var(--status-ready-border); margin: 10px 0; padding-left: 12px; color: var(--text-faint); font-style: italic; }
|
||||
.review-grades { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 8px; }
|
||||
.review-actions { margin: 16px 0 0; }
|
||||
@media (max-width: 760px) {
|
||||
@@ -199,10 +320,28 @@ a.chapter-name:hover { color: #315c43; text-decoration: underline; }
|
||||
.chapter-row .status-chip, .chapter-row .el-button { grid-column: 2; justify-self: start; }
|
||||
.chapter-complete { flex-direction: column; align-items: stretch; }
|
||||
.chapter-complete .el-button { width: 100%; }
|
||||
.reader-text { font-size: 16px; line-height: 1.95; }
|
||||
.reader-text { font-size: calc(16px * var(--reader-font-scale)); line-height: 1.95; }
|
||||
.has-lookup .reader-workspace { grid-template-columns: minmax(0, 1fr); gap: 20px; }
|
||||
.reader-page.has-lookup { padding-bottom: calc(45dvh + 28px); }
|
||||
.lookup-panel { position: fixed; inset: auto 0 0; z-index: 20; max-height: 45dvh; margin-top: 0; padding: 16px 20px max(20px, env(safe-area-inset-bottom)); border-radius: 16px 16px 0 0; box-shadow: 0 -5px 24px #233d3114; }
|
||||
.lookup-heading { position: sticky; top: -16px; z-index: 1; background: #fffdf8; }
|
||||
.lookup-panel { position: fixed; inset: auto 0 0; z-index: 20; max-height: 45dvh; margin-top: 0; padding: 16px 20px max(20px, env(safe-area-inset-bottom)); border-radius: 16px 16px 0 0; box-shadow: 0 -5px 24px var(--text)14; }
|
||||
.lookup-heading { position: sticky; top: -16px; z-index: 1; background: var(--surface); }
|
||||
.reader-nav .el-button { flex: 1; }
|
||||
}
|
||||
|
||||
/* One visible focus ring for every interactive element, including Element Plus controls. */
|
||||
button:focus-visible, a:focus-visible, [role='button']:focus-visible,
|
||||
.el-button:focus-visible, .el-input__inner:focus-visible, .el-select__wrapper:focus-visible {
|
||||
outline: 3px solid var(--accent-marker);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
/* Header display control: theme and reader font size. */
|
||||
.display-trigger { display: inline-flex; align-items: center; gap: 8px; min-height: 38px; padding: 0 12px; border: 1px solid var(--border-strong); border-radius: 20px; background: var(--surface-raised); color: var(--text); font-size: 13px; cursor: pointer; }
|
||||
.display-trigger:hover { border-color: var(--accent); }
|
||||
/* Element Plus styles arrive after this file for lazy-loaded views, so the ring is stated
|
||||
with enough weight to win the tie while a control from its own library is focused. */
|
||||
.display-trigger:focus-visible, .display-trigger.el-tooltip__trigger:focus-visible { outline: 3px solid var(--accent-marker); outline-offset: 3px; }
|
||||
.display-current { color: var(--text-muted); }
|
||||
.el-dropdown-menu__item.is-current { color: var(--accent); font-weight: 600; }
|
||||
|
||||
/* Discoverable keyboard operations on the review page. */
|
||||
.shortcut-hint { margin: 22px 0 0; color: var(--text-muted); font-size: 13px; }
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
import { ElButton, ElDialog, ElInput, ElMessageBox } from 'element-plus'
|
||||
import { canRetry, statusLabel, TEXT_MAX_CODE_POINTS, textProblem, titleProblem, useLibraryStore, type ChapterSource } from '../stores/library'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
import DisplaySettings from '../components/DisplaySettings.vue'
|
||||
|
||||
const session = useSessionStore()
|
||||
const library = useLibraryStore()
|
||||
@@ -146,6 +147,7 @@ onUnmounted(() => library.closeBook())
|
||||
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
|
||||
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/vocab">生词本</RouterLink> · <RouterLink to="/review">到期复习</RouterLink> · <RouterLink to="/progress">进度</RouterLink></nav>
|
||||
<div class="account">
|
||||
<DisplaySettings />
|
||||
<span class="account-name">{{ session.user.username }}</span>
|
||||
<ElButton text @click="logout">退出登录</ElButton>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
import { ElButton, ElInput, ElOption, ElRadio, ElRadioGroup, ElSelect } from 'element-plus'
|
||||
import { LANGUAGE_LABEL, TEXT_MAX_CODE_POINTS, fileProblem, fileSizeLabel, textProblem, titleProblem, useLibraryStore, type SubmitTarget } from '../stores/library'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
import DisplaySettings from '../components/DisplaySettings.vue'
|
||||
|
||||
const session = useSessionStore()
|
||||
const library = useLibraryStore()
|
||||
@@ -129,6 +130,7 @@ onUnmounted(() => {
|
||||
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
|
||||
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/vocab">生词本</RouterLink> · <RouterLink to="/review">到期复习</RouterLink> · <RouterLink to="/progress">进度</RouterLink></nav>
|
||||
<div class="account">
|
||||
<DisplaySettings />
|
||||
<span class="account-name">{{ session.user.username }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
import { ElButton } from 'element-plus'
|
||||
import DisplaySettings from '../components/DisplaySettings.vue'
|
||||
import BookMark from '../components/BookMark.vue'
|
||||
import { statusSummary, useLibraryStore } from '../stores/library'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
@@ -40,6 +41,7 @@ onMounted(load)
|
||||
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
|
||||
<nav aria-label="学习导航"><RouterLink to="/" class="active-nav">我的书库</RouterLink> · <RouterLink to="/vocab">生词本</RouterLink> · <RouterLink to="/review">到期复习</RouterLink> · <RouterLink to="/progress">进度</RouterLink></nav>
|
||||
<div class="account">
|
||||
<DisplaySettings />
|
||||
<span class="account-name">{{ session.user.username }}</span>
|
||||
<ElButton text @click="logout">退出登录</ElButton>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RouterLink, useRouter } from 'vue-router'
|
||||
import { ElButton } from 'element-plus'
|
||||
import { useProgressStore } from '../stores/progress'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
import DisplaySettings from '../components/DisplaySettings.vue'
|
||||
|
||||
const session = useSessionStore()
|
||||
const progress = useProgressStore()
|
||||
@@ -24,6 +25,7 @@ async function logout() {
|
||||
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
|
||||
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/vocab">生词本</RouterLink> · <RouterLink to="/review">到期复习</RouterLink> · <RouterLink to="/progress">进度</RouterLink></nav>
|
||||
<div class="account">
|
||||
<DisplaySettings />
|
||||
<span class="account-name">{{ session.user.username }}</span>
|
||||
<ElButton text @click="logout">退出登录</ElButton>
|
||||
</div>
|
||||
|
||||
@@ -6,8 +6,10 @@ import { canRetry, statusLabel, useLibraryStore } from '../stores/library'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
import { useReaderLookup, type PhraseSpan, type ReaderToken } from '../composables/useReaderLookup'
|
||||
import { adjustTokenRange, MAX_PHRASE_WORDS, normalizeTokenRange, phrasesAt, rangeOfSpan, wordIndices } from '../composables/readerRange'
|
||||
import { currentRatio, readPosition, writePosition } from '../composables/readingPosition'
|
||||
import { useTextSelection } from '../composables/useTextSelection'
|
||||
import ReaderTokens from '../components/ReaderTokens.vue'
|
||||
import DisplaySettings from '../components/DisplaySettings.vue'
|
||||
import LookupPanel from '../components/LookupPanel.vue'
|
||||
|
||||
const session = useSessionStore()
|
||||
@@ -88,6 +90,56 @@ async function load() {
|
||||
retryError.value = ''
|
||||
rangeNotice.value = ''
|
||||
await library.loadChapter(chapterId.value)
|
||||
restorePosition()
|
||||
}
|
||||
|
||||
// Reading position: remembered per account and chapter, and only while the text is the version
|
||||
// the position was taken from. The restore waits for the chapter to be on screen and for the
|
||||
// browser to lay the paragraph out, otherwise the saved ratio would land in the wrong place.
|
||||
const positionNotice = ref('')
|
||||
let saveTimer: number | undefined
|
||||
|
||||
function storage(): Storage | null {
|
||||
try { return window.localStorage } catch { return null }
|
||||
}
|
||||
|
||||
function restorePosition(): void {
|
||||
positionNotice.value = ''
|
||||
const store = storage()
|
||||
const sha = chapter.value?.contentSha256 ?? ''
|
||||
const current = chapter.value
|
||||
if (!store || !current || current.status !== 'ready' || !sha) return
|
||||
const saved = readPosition(store, session.user?.id ?? null, current.id, sha)
|
||||
if (!saved) return
|
||||
// The chapter may still be laying out when the response arrives, so the restore waits for a
|
||||
// scrollable page instead of trusting one timer; it gives up quietly after a short while.
|
||||
let attempts = 0
|
||||
const apply = () => {
|
||||
const scrollable = document.documentElement.scrollHeight - window.innerHeight
|
||||
if (scrollable <= 0) {
|
||||
attempts += 1
|
||||
if (attempts <= 20) window.setTimeout(apply, 50)
|
||||
return
|
||||
}
|
||||
window.scrollTo({ top: Math.round(saved.ratio * scrollable) })
|
||||
positionNotice.value = '已回到上次阅读位置。'
|
||||
}
|
||||
window.setTimeout(apply, 40)
|
||||
}
|
||||
|
||||
function savePosition(): void {
|
||||
const store = storage()
|
||||
const current = chapter.value
|
||||
if (!store || !current || current.status !== 'ready') return
|
||||
const ratio = currentRatio(window, document.documentElement.scrollHeight)
|
||||
if (ratio <= 0) return
|
||||
writePosition(store, session.user?.id ?? null, current.id, current.contentSha256, ratio)
|
||||
}
|
||||
|
||||
function onScroll(): void {
|
||||
if (saveTimer !== undefined) window.clearTimeout(saveTimer)
|
||||
// Debounced: scrolling must stay smooth and only the resting position is worth storing.
|
||||
saveTimer = window.setTimeout(() => { saveTimer = undefined; savePosition() }, 400)
|
||||
}
|
||||
|
||||
async function retry() {
|
||||
@@ -132,10 +184,19 @@ async function logout() {
|
||||
finally { await router.replace('/login') }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
onMounted(() => {
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
void load()
|
||||
})
|
||||
watch(chapterId, () => { void load() })
|
||||
// Leaving the page releases the chapter so polling stops.
|
||||
onUnmounted(() => library.closeChapter())
|
||||
// Leaving the page releases the chapter so polling stops and stores the last position.
|
||||
// There is no separate unmount hook: this one already handles the page going away.
|
||||
onUnmounted(() => {
|
||||
if (saveTimer !== undefined) window.clearTimeout(saveTimer)
|
||||
savePosition()
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
library.closeChapter()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -144,6 +205,7 @@ onUnmounted(() => library.closeChapter())
|
||||
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
|
||||
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/vocab">生词本</RouterLink> · <RouterLink to="/review">到期复习</RouterLink> · <RouterLink to="/progress">进度</RouterLink></nav>
|
||||
<div class="account">
|
||||
<DisplaySettings />
|
||||
<span class="account-name">{{ session.user.username }}</span>
|
||||
<ElButton text @click="logout">退出登录</ElButton>
|
||||
</div>
|
||||
@@ -222,6 +284,7 @@ onUnmounted(() => library.closeChapter())
|
||||
<div>
|
||||
<p v-if="readAt" class="read-state" data-testid="chapter-read-state">{{ readLabel }}</p>
|
||||
<p v-else class="subtle">读完后标记已读;只记录已读,不改变词语状态。</p>
|
||||
<p v-if="positionNotice" role="status" class="subtle" data-testid="position-notice">{{ positionNotice }}</p>
|
||||
<p v-if="completeNotice" role="status" class="subtle" data-testid="complete-notice">{{ completeNotice }}</p>
|
||||
</div>
|
||||
<ElButton
|
||||
|
||||
@@ -3,6 +3,8 @@ import { onMounted } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { ElButton } from 'element-plus'
|
||||
import ReviewCard from '../components/ReviewCard.vue'
|
||||
import DisplaySettings from '../components/DisplaySettings.vue'
|
||||
import { useReviewShortcuts, type ReviewShortcut } from '../composables/reviewShortcuts'
|
||||
import { useReviewStore, type ReviewItem } from '../stores/review'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
|
||||
@@ -16,6 +18,18 @@ let lastGrade: 'correct' | 'wrong' | 'again' = 'correct'
|
||||
function grade(value: 'correct' | 'wrong' | 'again') { lastGrade = value; void review.answer(value) }
|
||||
function retry() { void review.answer(lastGrade) }
|
||||
|
||||
// Space or Enter shows the answer, then 1, 2 and 3 grade it. The shortcut never fires while the
|
||||
// learner is typing, and a focused button keeps its own native key handling.
|
||||
function onShortcut(action: ReviewShortcut): void {
|
||||
if (action === 'reveal') { review.reveal(); return }
|
||||
grade(action)
|
||||
}
|
||||
useReviewShortcuts({
|
||||
onShortcut,
|
||||
enabled: () => review.current !== null && !review.busy && review.error === '',
|
||||
revealed: () => review.revealed,
|
||||
})
|
||||
|
||||
onMounted(() => { void review.load() })
|
||||
async function logout() {
|
||||
try { await session.logout() }
|
||||
@@ -32,6 +46,7 @@ const requireItem = (value: ReviewItem | null): ReviewItem => value as ReviewIte
|
||||
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
|
||||
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/vocab">生词本</RouterLink> · <RouterLink to="/review" class="active-nav">到期复习</RouterLink> · <RouterLink to="/progress">进度</RouterLink></nav>
|
||||
<div class="account">
|
||||
<DisplaySettings />
|
||||
<span class="account-name">{{ session.user.username }}</span>
|
||||
<ElButton text @click="logout">退出登录</ElButton>
|
||||
</div>
|
||||
@@ -73,6 +88,7 @@ const requireItem = (value: ReviewItem | null): ReviewItem => value as ReviewIte
|
||||
@end="endReview"
|
||||
@retry="retry"
|
||||
/>
|
||||
<p class="shortcut-hint" data-testid="review-shortcuts">键盘:空格 显示答案 · 1 答对 · 2 答错 · 3 再学一次</p>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
import { ElButton, ElDialog, ElInput, ElOption, ElSelect } from 'element-plus'
|
||||
import TermFormFields from '../components/TermFormFields.vue'
|
||||
import DisplaySettings from '../components/DisplaySettings.vue'
|
||||
import { TERM_STATUSES, type TermStatus } from '../composables/useReaderLookup'
|
||||
import { textProblem, titleProblem } from '../stores/library'
|
||||
import { VOCAB_PAGE_SIZE, useVocabularyStore, type VocabularyTerm } from '../stores/vocabulary'
|
||||
@@ -115,6 +116,7 @@ watch(() => [search.value, vocabulary.status, vocabulary.kind, vocabulary.page],
|
||||
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
|
||||
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/vocab" class="active-nav">生词本</RouterLink> · <RouterLink to="/review">到期复习</RouterLink> · <RouterLink to="/progress">进度</RouterLink></nav>
|
||||
<div class="account">
|
||||
<DisplaySettings />
|
||||
<span class="account-name">{{ session.user.username }}</span>
|
||||
<ElButton text @click="logout">退出登录</ElButton>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user