# 雅思学习网页同步测试版 v1

创建日期：2026-10-11

这是 Reading 01–25 与复习小游戏的**独立同步测试副本**。原始网页没有被覆盖或删除。

## 当前可用

- 统一目录与 Reading 01–25 上一篇、下一篇、目录、小游戏导航；手机端“复习小游戏”位于导航链接区，阅读页也保留直达入口；桌面端阅读页和小游戏页仍将它放在导出、导入、立即同步所在的操作区。
- 各篇文章分别保存生词标记与阅读位置。
- 小游戏单独保存词汇状态和状态变更记录。
- 新增独立 `wordlist.html` 单词表：展示单词、本地缓存中已有的英美音标与发音、释义、来源文章和学习状态；支持搜索、按文章筛选、全部/本篇生词/认识/模糊/不认识/未测试筛选及每页 50 个分页。音标暂无的词仍可使用英美发音按钮。
- 单词表中的“学习状态”和“本篇生词”是两组独立数据：可以分别修改；结果与文章页、小游戏、本地保存和 Supabase 同步实时联动。跨文章出现的词会列出实际出现文章；全局生词视图会列出实际标记文章，并可按文章准确增删。点击“复习当前筛选”会把当前筛选词汇交给小游戏，再按小游戏题量抽取本轮内容。
- 标准测试“抽题”和配对小游戏“复习范围”都提供“已学习”筛选；“已学习”严格指已有 `known`（认识）、`maybe`（模糊）或 `unknown`（不认识）个人状态的词，空状态仍算“从未测试”。
- 全量 JSON 导出/导入；兼容旧小游戏 `{ "items": { ... } }` 结果文件。
- Supabase 登录未完成时自动使用 localStorage，并明确显示“请先登录学习账号”。
- 本地保存始终自动进行；Supabase 正式启用后，连续操作会先合并 1.5 秒。当前页面的所有自动同步共用调度器，两轮自动同步启动间隔至少 10 秒；Realtime 不可用时备用轮询为 60 秒，Realtime 正常时每 5 分钟做一次校准。页面处于后台时暂停周期性拉取，回到前台再合并补同步。
- 云端拉取按稳定字段排序并分页读取，不再依赖 PostgREST 单次返回上限；超过安全页数会明确报错，不会把截断结果当成完整数据。
- 页面切到后台或离开页面时只做可靠的本地保存；重新回到页面、网络恢复或下一同步周期时再上传，避免浏览器中断后台请求。
- Reading 页面首次打开会在云端拉取完成后、且用户尚未主动滚动时按保存的 `scrollRatio` 恢复位置；用户已经开始阅读时不会被云端位置强行拉回。
- 新版使用 `ielts-sync-v1` 命名空间，不读取旧阅读网页中的历史生词标记和阅读位置。
- 词典释义已在打包阶段嵌入：能取得的历史有道简明释义直接显示；没有在线缓存的词显示原词表释义或“本地备用释义”，保留有道完整词典入口。外部词典网页内容不复制到本地。手机端和电脑端均只保留有道入口，恢复原先的音频、词典、生词排列。发音地址与释义缓存是两条独立链路，所以网络不可用时也可能出现“能发音但没有有道释义”。

## Supabase 登录与安全

- 网页使用 Project URL 与 Publishable/anon public key；`service_role`/Secret key 永远不能放进浏览器。
- 每台设备首次打开时使用同一个学习账号登录；登录后才会上传和拉取学习数据。
- 登录账号与 Supabase 管理后台账号分开，密码只在网页本地输入，不写入学习文件。
- 数据表已启用 RLS，按 `auth.uid()` 隔离每个用户的数据。
- 当前浏览器在第一次同步尝试开始前会绑定该学习账号；若随后登录不同账号，系统会停止上传并提示，避免断线或部分写入后把两人的本地学习数据混在一起。
- 切勿把 `service_role` 密钥写进任何 HTML 或 JavaScript 文件。
- `supabase-schema-draft.sql` 必须由项目管理员在 Supabase SQL Editor 中审查并执行；网页本身不会自动建表或修改数据库。
- 2026-10-13 已在 Supabase SQL Editor 中只读确认：四张表的 RLS 均已开启，策略按 `auth.uid() = user_id` 限制；`game_records.event_id` 为必填文本字段，并已存在 `game_records_user_event_uidx` 唯一索引。
- 新连接器依赖 `game_records.event_id` 唯一键来防止网络重试产生重复记录；上述线上结构已确认。
- 生词快照使用内部标记 `__ielts_sync_snapshot__` 表示“当前文章的完整集合”，因此即使用户清空一篇文章的全部生词，空集合也能同步到其他设备；该标记不会显示在文章页面。
- 连接器优先使用 Supabase Realtime WebSocket 作为变更通知，再沿用现有拉取/合并逻辑；当前页面的所有自动触发会合并到统一调度器，两轮自动同步启动间隔至少 10 秒；连接失败或断线时自动回退 60 秒轮询，连接稳定后每 5 分钟做一次安全校准拉取。后台页面暂停周期性拉取。

### Supabase Realtime（首次部署需执行）

- `supabase-realtime-publication-v1.sql` 将四张同步表加入 `supabase_realtime` publication；执行后末尾查询应返回四行。
- 未执行该 SQL、Realtime 不可用或网络断线时，网页仍会使用原有轮询同步，不会丢失待同步记录。

### 生词快照事务 RPC（已接入）

- `supabase-reading-marks-rpc-v1.sql` 创建 `replace_reading_marks_v1`：同一事务中校验输入、删除旧快照、写入内部快照标记和全部当前生词；任一步失败都会整体回滚。
- 函数使用 `SECURITY INVOKER`，并将 `search_path` 限制为 `pg_catalog`；业务表与 `auth.uid()` 均使用完整 schema 名，只授予 `authenticated` 执行权限，表级 RLS 仍然生效。
- 2026-10-13 已在 Supabase SQL Editor 确认 `prosecdef=false`、`search_path=["search_path=pg_catalog"]`、`authenticated_execute=true`、`anon_execute=false`。
- `supabase-provider.js` 已仅将 `reading_marks` 的写入切换到该 RPC；其他三张表的写入和全部拉取逻辑保持不变。
- RPC 缺失、权限错误或执行失败时，请求会直接失败并保留本地 pending，等待后续自动或手动重试；客户端不会静默回退到非事务的“先删后写”。
- 新环境的部署顺序必须是：先执行并核验 `supabase-reading-marks-rpc-v1.sql`，再部署当前网页；部署后用测试账号完成双浏览器新增生词与“清空全部生词”冒烟。

## 打开方式

先打开 `index.html`，再从统一导航进入文章、单词表或小游戏。

### 给浏览器与 AI 工具的纯文章入口

阅读页面本身是静态 HTML，英文正文已经在初始 HTML 中；不过页面同时包含中文折叠、词典标记和学习脚本，直接让 AI 解析时容易受到页面体积和交互结构影响。构建脚本会从同一份英文正文自动生成不含中文、释义、编号和脚本的纯文字文件，以及 JSON API：

- Reading 01 纯文字：`/reading-01.txt`
- Reading 01 API：`/api/articles/1`

API 通过 Netlify 重写到 `api/articles/1.json`，返回 `id`、`title`、`sentenceCount`、按原顺序排列的 `sentences` 和合并后的 `text`；`robots.txt` 明确允许公开读取，`_headers` 为文本/API 设置 UTF-8、CORS 和缓存标头。正文变更后重新运行 `_build-sync-v1.ps1`，这些入口会与文章页面一起重新生成。

页面只提供有道完整词典入口；外站可能要求网络连接或人机验证，搜索无结果不影响本地词表。

验证命令：`node --test tests/*.test.js`、`./_build-sync-v1.ps1 -ValidateOnly`、`node tests/browser-ui-regression.cjs`。浏览器回归拦截全部网络请求，不访问真实 Supabase；覆盖 320/390/650/651/1280px 导航、手机逐题及完成焦点、答题锁定和桌面双栏。脚本默认复用本机 Codex 的 Playwright 和 Chromium；其他机器可设置 `PLAYWRIGHT_MODULE_PATH` 与 `CHROMIUM_EXECUTABLE_PATH` 指向已安装运行时，无需改变产品代码。

目录页不再显示“从 Reading 01 开始”按钮；直接在文章目录中选择任意一篇即可。单词表和复习小游戏入口位于页面顶部和底部的统一操作区。

## 文章清单与生命周期

- `articles-manifest.json` 是目录、文章顺序和上一篇/下一篇的唯一清单；页面加载失败时才退回现有静态目录。
- 不想直接编辑 JSON 时，可打开 `article-manifest-manager.html`：载入现有清单后，用表单新增文章、隐藏或恢复文章，再下载新的 `articles-manifest.json`。Chrome/Edge 在允许文件访问时也可保存回刚打开的清单；其他浏览器使用下载文件替换。
- 管理器保存回已打开的清单前会重新读取磁盘版本；若文件在打开后被其他程序修改，保存会停止，需重新打开并手工合并，避免静默覆盖。
- 管理器与学习页面完全分离，不会修改文章 HTML、学习进度、Supabase 配置或同步规则。生成后仍需运行 `_build-sync-v1.ps1`，由构建脚本检查源 HTML 是否存在并重新生成目录与文章副本。
- `id` 是永久 `reading_id`。现有 `Reading 01`–`Reading 25` 已冻结，不得重编号或复用，否则会切断已有阅读进度和生词记录。
- `status` 支持 `draft`、`published`、`hidden`、`tombstone`。目录和导航只展示 `published`；设为 `hidden` 可随时恢复，Supabase 历史不会被删除。
- 调整展示顺序只修改 `order`，不要修改 `id`；新增文章需提供新的永久 ID、唯一 `file` 和源文件 `source`。
- `order` 必须唯一；管理器和页面运行时都会拒绝重复顺序，避免上一篇/下一篇导航跳过文章。
- 永久移除文章时先设为 `hidden` 并保留备份，再改成 `tombstone`；不要从清单中抹掉或把旧 ID 分配给新文章。
- 正式构建前先运行 `./_build-sync-v1.ps1 -ValidateOnly`。校验会完整读取并生成 25 篇文章到内存、检查清单、来源文件、注入点和存储键，但不会改写任何文件。
- `_build-sync-v1.ps1` 只有在所有页面都生成并校验成功后才开始写出，并通过同目录临时文件逐个原子替换，避免失败时留下截断文件。
- `_build-sync-v1.ps1` 默认保留当前增强版 `game.html`。从旧源覆盖小游戏必须同时显式使用 `-RefreshGameFromLegacySource -AllowEnhancedGameOverwrite`；该操作会丢失后续增强，日常文章更新不要使用这两个开关。
- `_build-sync-v1.ps1` 会从 `game.html` 的唯一主词库、25 篇生成页面中的实际词条关联和 `youdao-cache.js` 生成精简的 `wordlist-data.js`；不要手工维护这份派生数据。单词表不会在运行时下载或解析完整的 `game.html`。

## 自动保存与自动同步规则

- **未登录时**：所有变化立即写入当前浏览器的 localStorage，并显示“请先登录学习账号”。
- **Supabase 已配置且连接器可用时**：变更后约 1.5 秒进入自动同步队列；当前页面两轮自动同步启动间隔至少 10 秒，Realtime 断线时每 60 秒拉取并合并其他设备的更新，Realtime 正常时每 5 分钟校准一次。
- **网络或云端失败时**：显示“同步失败/待同步”，保留本地队列；网络恢复、页面重新进入前台或下一个周期会自动重试。
- “立即同步”继续保留，作为手动重试入口，不再是保存进度的必要步骤。

## 后续 Supabase 数据表

- `word_states`：每个用户、每个词条一行。
- `game_records`：小游戏状态变更记录；以 `(user_id, event_id)` 唯一约束实现幂等重试。
- `reading_marks`：每个用户、每篇文章的生词标记。
- `reading_progress`：每个用户、每篇文章的阅读位置和完成状态。

正式接入前必须先完成账户认证和 Row Level Security，确保用户只能读写自己的数据。
