diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 7720e10..9139116 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -12,7 +12,11 @@ ## 检查清单 -- [ ] `npm run verify` 通过 +- [ ] 已按改动范围选择最小充分的测试层级 +- [ ] `npm run test:fast` 通过(如涉及逻辑或代码) +- [ ] 相关 UI/E2E 测试通过(如涉及页面、导航、离线或 PWA) +- [ ] 数据库/Functions/集成测试通过(如涉及对应边界) +- [ ] `npm run test:full` 通过(合并前、发布前或高风险改动) - [ ] 未提交 `.env`、`.vercel`、secret key 或个人数据 - [ ] 数据库变更附带迁移文件 - [ ] 已检查本次变更涉及的 README、公开 docs、CHANGELOG、发布说明及隐私/安全文档,并已同步实现差异 @@ -22,3 +26,9 @@ - [ ] 如包含外部贡献或第三方代码,已阅读 [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md),并在 PR 中说明来源与授权状态 - [ ] 已运行 `npm run public:check`,并复查暂存区没有内部、敏感或不必要文件 - [ ] 如果这是发布准备变更,已确认 `release-gate.config.json`、版本文件和发布说明同步 + +## 验证记录 + +- 选定层级: +- 命令与结果: +- 未运行或被环境阻塞的检查及原因: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12bed71..2e8b92d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,10 +62,13 @@ jobs: run: | status="$(npx supabase status --output env 2>/dev/null)" api_url="$(printf '%s\n' "$status" | sed -n 's/^API_URL=//p' | tr -d '"')" + db_url="$(printf '%s\n' "$status" | sed -n 's/^DB_URL=//p' | tr -d '"')" publishable_key="$(printf '%s\n' "$status" | sed -n 's/^ANON_KEY=//p' | tr -d '"')" test -n "$api_url" + test -n "$db_url" test -n "$publishable_key" echo "VITE_SUPABASE_URL=$api_url" >> "$GITHUB_ENV" + echo "SHADOW_MATE_TEST_DB_URL=$db_url" >> "$GITHUB_ENV" echo "VITE_SUPABASE_PUBLISHABLE_KEY=$publishable_key" >> "$GITHUB_ENV" echo "E2E_REAL_SUPABASE=1" >> "$GITHUB_ENV" - name: Run database tests diff --git a/.gitignore b/.gitignore index 7cc69e6..dfbc40e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,12 @@ dist/ .DS_Store Thumbs.db +# Agent runtime harness (local-only, must not enter public repo) +.agent_context/ +.claude/ +.multica/ +/attachments/ + test-results/ playwright-report/ coverage/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 76460ae..33919a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## [1.4.0-rc] - 2026-08-16 + +### Added +- 行为积分体系:自定义积分项(加分/扣分)、快捷撤销(10 秒内)、历史受控扣分与按日补记;积分日历独立统计,不计入成长模块数量。 +- 期初积分:切换到新账本时旧积分只读保留,家长可为每个孩子确认一次期初积分,重复确认会被拒绝。 +- 奖励与兑换:家长创建奖励并设置所需积分,可设为当前目标;孩子攒够后兑换,联网确认后生效,家长兑现后标记“已兑现”。 +- 学习包模块启停:家长可按孩子启用或隐藏整个学习包及其中每个模块;成长日历按已启用模块数显示 `已完成/n`。 +- 程序化界面音效:完成行动、获得积分、再试一次、扣除积分、奖励兑现五类音效,可逐事件开关、调音量、试听并恢复默认(仅存本机)。 +- 数据库迁移提案:积分、奖励、兑换、期初积分与成长漏斗聚合(待 Shadow Portal 控制面审批执行)。 + +### Changed +- 公开资料同步:README 标语与“现有能力”仅列已实现并验证的功能;页面 meta 与 PWA 安装描述统一文案;使用指南与应用内指南同步积分、奖励与兑换说明。 +- 修正应用内指南离线语音包体积描述(115MB → 63.5MB `en_US-ljspeech-medium`)。 + +### Fixed +- 移除引用未导入 `CHECKIN_GROUPS` 的孤儿 `CHECKIN_MODULES` 常量(W2 重构后已无使用),修复整页渲染 `ReferenceError`。 +- E2E 同步合并后的导航结构(学习页 → 模块入口)与指南语音包体积断言。 + +### Tests +- 发布候选全量验证通过:`npm run verify`(公开/安全/静态检查、构建、构建产物、覆盖率)、数据库 pgTAP 241/241、Edge Function 隔离守卫、E2E 66 通过 1 按环境跳过、`release:check` 通过(生产检查因无 `RELEASE_URL` 跳过)。 + ## [1.3.8] - 2026-08-15 ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0fd8978..983acd6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,17 +6,31 @@ npm.cmd ci git config core.hooksPath .githooks git config user.email "YOUR_GITHUB_NOREPLY_ADDRESS" -npm.cmd run verify +npm.cmd run test:fast ``` Get the noreply address from GitHub **Settings → Emails**. Do not use a personal or work mailbox in public commit metadata. Maintainers may create an ignored `.security-local-denylist` file with one private term per line. The security check scans tracked and untracked candidate files without publishing the denylist itself. +## 测试范围与分层 + +先写清本次改动的范围、明确不做什么、验收条件和受影响边界,再按风险选择最小充分的验证层级。开发循环不要求每次小改动都运行全量测试;合并、发布和高风险边界仍必须经过完整门禁。 + +| 层级 | 适用场景 | 命令 | +| --- | --- | --- | +| 静态 | 文档、文案、低风险 CSS 或静态检查 | `npm run check` | +| 快速 | 纯逻辑、数据模型、控制器 | `npm run test:fast` | +| 页面 | 导航、设置、PWA、离线和可见交互 | `npm run test:ui`,或运行指定 E2E 文件 | +| 集成 | Supabase schema/RLS、Functions、认证、同步、导出/删除 | `npm run test:db`、`npm run test:functions`,以及受影响的 E2E | +| 完整 | 合并前、发布前、依赖/公开资源或高风险边界 | `npm run test:full` | + +`test:fast` 当前包含全部 unit test 和 `check`,它是比浏览器/数据库测试更快的项目级入口,但不是 changed-only 测试。`verify` 负责公开范围、安全检查、静态检查、构建和覆盖率,不包含数据库、Functions 或 E2E;`test:full` 只在合并、发布或高风险边界运行。PR 必须记录实际选择的层级、命令、结果,以及未运行或被环境阻塞的检查。 + ## Required checks - Use a branch and pull request; do not push directly to `main`. -- Run `npm run verify` before pushing. +- Before pushing, run the smallest sufficient layer for the changed surface; source/build/public-resource changes require `npm run verify`, with database, Functions and E2E layers added when affected. Run `npm run test:full` before merge or release. - For a release tag, run `npm run build` followed by `npm run release:check`; the tag workflow repeats this against the final archive. - Commit `package-lock.json` and pin dependency versions. - Add explicit PostgreSQL grants and RLS policies in the same migration. @@ -37,7 +51,7 @@ Maintainers may create an ignored `.security-local-denylist` file with one priva - 检查本次 `git diff` 涉及的用户行为、数据模型、迁移、配置、测试命令、覆盖率、版本号和发布状态。 - 按影响范围同步 `README.md`、公开 `docs/`、`CHANGELOG.md`、`RELEASE_NOTES.md`、隐私/安全文档和 PR 说明;内部计划、法律记录和发布闸门不得放入公开目录。 - 代码、测试、迁移、配置和对应文档必须作为同一项工作提交并推送,禁止明知文档过期而先提交代码、之后再补文档。 -- 提交前运行 `npm run public:check`、`git diff --cached --check` 和 `npm run verify`,并逐项复查 `git diff --cached --name-status` 与 `git diff --cached`,确认没有把内部、敏感或不必要文件加入提交。 +- 提交前运行 `npm run public:check`、`git diff --cached --check` 和与改动范围匹配的测试层,并逐项复查 `git diff --cached --name-status` 与 `git diff --cached`,确认没有把内部、敏感或不必要文件加入提交;合并前或发布前补齐 `npm run test:full`。 - 推送前重新核对远端仓库可见性、目标分支、PR base/head 和 PR 描述;任何不确定的文件先移出暂存区,不要“先提交再解释”。 ## Release 闸门 @@ -59,7 +73,7 @@ Release 必须在 Tag 上执行,不把普通 PR 当作发布验收: ## GitHub 协作流程 -- 本地先运行 `npm.cmd run verify`;涉及端到端流程时,再运行 `npm.cmd run test:e2e`。 +- 本地先运行 `npm.cmd run test:fast`;涉及页面交互时,再运行 `npm.cmd run test:ui`;涉及数据库、认证或同步时,补充对应集成测试。合并前按风险矩阵运行 `npm.cmd run test:full`。 - 使用分支提交并推送,保持现有 SSH 远程仓库配置;不需要为每次 PR 重复配置 GitHub CLI。 - 分支推送后,优先使用已连接的 GitHub 插件创建、查看、Review 和合并 PR,避免通过浏览器重复填写表单。 - PR 作者不能批准自己的 PR;需要独立 Review 时邀请其他协作者,管理员按分支保护规则完成合并。 diff --git a/README.md b/README.md index f822f01..37da476 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@

- 把每天的学习,变成看得见的成长。
- 面向家庭的儿童学习打卡 PWA:学习、记录、同步,一处完成。 + 陪伴有方法,成长有动力。
+ 面向家庭的学习记录与成长反馈 PWA:多孩子、学习打卡、行为积分、离线使用与云端同步。

@@ -29,9 +29,11 @@ ## 它能做什么 -- **四个学习模块**:语文、数学、英语、绘本;每个模块内部的任务可以独立打卡和取消。 -- **成长记录**:近 30 天按学习模块统计完成情况,用 `已完成/4` 直接说明当天进度。 -- **积分日历**:行为积分单独记录,与学习模块分开,支持按日期查看和补记。 +- **学习打卡**:语文、数学、英语、绘本四个学习模块;可按孩子启用或隐藏模块,每个模块内部的任务可以独立打卡和取消。 +- **成长记录**:近 30 天按学习模块统计完成情况,用 `已完成/n` 直接说明当天进度(`n` 为孩子已启用的模块数)。 +- **行为积分**:自定义积分项,加分、扣分、快捷撤销和补记;旧积分只读保留,家长可为孩子确认一次期初积分。 +- **奖励与兑换**:设置奖励目标和分值,孩子攒够积分后可兑换;奖励在家长确认后标记为已兑现。 +- **界面音效**:完成行动、获得积分、再试一次、扣除积分、奖励兑现五类程序化音效,可逐事件开关、试听、调音量并恢复默认(仅保存在当前设备)。 - **家庭空间**:一个家长管理多个学习者,切换孩子后加载对应的学习记录。 - **共享账号登录**:支持邮箱验证码和邮箱密码;可设置、修改或找回适用于 Shadow 系列产品的共享密码。 - **防重复操作**:提交、同步、删除和打卡等操作会拦截快速连点,避免重复创建或重复变更。 @@ -82,20 +84,38 @@ npm.cmd run dev ```powershell npm.cmd run check npm.cmd run build -npm.cmd run test:unit -npm.cmd run test:e2e +npm.cmd run test:fast +npm.cmd run test:ui ``` 需要本地数据库测试时,先启动 Docker Desktop: ```powershell -supabase start -npm.cmd run test:db -supabase db lint --local --schema public --level warning --fail-on error +npm run supabase:local:start +npm run test:db +``` + +`supabase:local:start` 会转到同级 `shadow-size/merchant-admin`,启动共享本地 Supabase,并按 Shadow Portal 控制面的 SHA-256 校验结果加载 Shadow Mate 的 `learning_*` 业务 schema。控制面历史快照只会应用到 `127.0.0.1:54322`,不会复制到生产迁移目录,也不会连接生产数据库。 + +如果要验收登录、找回密码或其他 Edge Function,再开一个终端运行: + +```powershell +npm run supabase:local:functions:serve +``` + +该命令会准备共享函数覆盖层并以前台方式运行本地函数服务;关闭该终端就会停止函数服务。 + +如果要对共享本地数据库执行 lint,请在 merchant-admin 目录运行: + +```powershell +cd ../shadow-size/merchant-admin +npx supabase db lint --local --schema public --level warning --fail-on error ``` `test:coverage` 覆盖核心纯函数、学习状态机和防重复操作锁,语句、分支、函数和行覆盖率门槛均为 80%。`test:e2e` 覆盖离线导航、打卡、积分、日历、家庭空间、重复点击保护、邮箱验证码/密码登录、找回密码、数据生命周期和云端冲突限次重试;真实 Supabase E2E 需要额外配置环境变量。 +日常开发按改动范围选择最小充分的检查:页面改动运行目标 UI 测试,数据库/认证/同步改动补充对应集成测试;合并或发布前运行 `npm.cmd run test:full`。`test:fast` 是静态检查加全部 unit test,不是 changed-only 测试。 + ## 工作方式 ```text @@ -124,7 +144,7 @@ src/learning-state.js 学习状态机与四个模块的打卡分组 src/cloud.js 验证码/密码登录、家庭空间、同步、导出与删除 src/action-lock.js 全局快速连点拦截与异步操作单次执行锁 src/icons.js Lucide 图标渲染与图标 hydration -supabase/migrations/ 家庭数据、RLS、生命周期和删除权限 +supabase/migrations/ Shadow Mate 的 schema 提案与隔离 CI 测试副本 supabase/functions/ 账号级服务端删除 tests/unit/ 纯函数与学习状态机测试 tests/e2e/ 离线、云端和数据生命周期测试 @@ -134,7 +154,15 @@ tests/e2e/ 离线、云端和数据生命周期测试 当前部署配置位于 `src/config.js`,浏览器端只使用 publishable key。真正的数据隔离由 Supabase RLS、家庭成员关系和产品 ID 共同完成;绝不能把 secret key 或 `service_role` key 放进仓库。 -数据库迁移位于 `supabase/migrations/`,包括: +### 共享 Supabase 与迁移边界 + +影伴接入共享 Supabase 后,日常本地验收必须通过 `npm run supabase:local:start`,由同级 `shadow-size/merchant-admin` 启动共享本地实例,并加载经 Shadow Portal 控制面校验的 Shadow Mate `learning_*` schema。需要验收 Auth 或 Edge Functions 时,再在第二个终端运行 `npm run supabase:local:functions:serve`。 + +仓库中的 `supabase/migrations/` 仍用于保存与代码同步的迁移提案和隔离 CI 测试副本,不是共享生产库的唯一发布目录。共享生产迁移的 canonical 文件、审批、发布和台账由 `shadow-portal/supabase/control-plane` 管理;不要在本仓库直接执行生产 `db push`、`migration repair` 或 linked SQL。 + +`supabase/config.toml` 的独立端口和迁移配置仅供 CI/隔离测试使用。不要在影伴仓库根目录直接运行裸 `supabase start` 来代替共享本地启动。 + +数据库迁移提案包括: - 项目登记和共享多租户兼容性 - 家庭、成员、学习者和学习状态表 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index b98e0b6..584327d 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,34 @@ # Release Notes +## v1.4.0-rc - 2026-08-16(发布候选,未正式发布) + +Growth Loop MVP 发布候选。功能已实现,等待全量验证与迁移审批通过后发布;发布清单与回滚方案见本分支工作产物。 + +- **行为积分**:自定义积分项(加分/扣分)、快捷撤销(10 秒内)、历史受控扣分与按日补记;积分日历独立统计,不计入成长模块数量。 +- **期初积分**:切换到新账本时旧积分只读保留,家长可为每个孩子确认一次期初积分,生成独立期初流水;重复确认会被拒绝。 +- **奖励与兑换**:家长创建奖励并设置所需积分,可设为当前目标;孩子攒够后兑换,联网确认后生效,家长兑现后标记“已兑现”。 +- **学习包模块启停**:家长可在“学习”页按孩子启用或隐藏整个学习包及其中每个模块;成长日历按已启用模块数显示 `已完成/n`。 +- **程序化界面音效**:完成行动、获得积分、再试一次、扣除积分、奖励兑现五类音效,可在设置页逐事件开关、调音量、试听并恢复默认(仅存本机)。 +- **公开资料同步**:README 标语与“现有能力”仅列已实现并验证的功能;页面 meta 与 PWA 安装描述统一为“面向家庭的学习记录与成长反馈 PWA,支持多孩子、学习打卡、行为积分、离线使用与云端同步。”;使用指南与应用内指南同步积分、奖励与兑换说明。 +- **数据库提案**:新增积分、奖励、兑换、期初积分与成长漏斗聚合等迁移提案(经 Shadow Portal 控制面审批后执行,本仓库不直接执行生产迁移)。 + +### 验证结果 + +- 本分支为 `feat/growth-loop-release-candidate`,基于 Growth Loop 集成分支合并 W2/W4/W5 的 PR head 构建;正式发布以全量验证与生产迁移执行结果为准。 + +### 已知缺口 + +- 首页 Slogan 欢迎卡片(计划 4.5/10.3 的 E2E 要求)尚未实现,暂无对应 E2E 用例;不影响已上线功能,正式发布前需评估补上或明确延后。 + +### 部署清单(待执行,本候选未发布) + +1. 全量验证通过后,将本分支变更合并到 `main`,由 Vercel 部署前端。 +2. 生产迁移(积分/奖励/兑换/期初积分/漏斗聚合提案)经 Shadow Portal 控制面审批后串行执行;本仓库不直接执行生产迁移,执行顺序与回滚方案见迁移 runbook。 +3. 生产域名验证 `/`、`/manifest.json` 与隐私页响应头(CSP/HSTS/X-Frame-Options,无 `unsafe-inline`)。 +4. 发布后观察核心指标,异常按 runbook 回滚。 + +--- + ## v1.3.8 - 2026-08-15 - 将无 GMS Android 的离线英语语音模型改为从 `voice.shadow.wang` CDN 分发(`en_US-ljspeech-medium`,约 63.5MB):首次点击“听发音”时下载并缓存到浏览器,之后可离线合成,不上传录音。 diff --git a/docs/user-guide.md b/docs/user-guide.md index 32e1695..07a6326 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -10,12 +10,20 @@ ## 学习模块与日历 -影伴有四个学习打卡模块:语文、数学、英语和绘本。首页“今日打卡”显示当天已完成的模块数,例如 `2/4` 表示完成了其中两个模块。语文里的识字、古诗和写字虽然是三个独立任务,但同一天只计作 1 个语文模块。 +影伴有四个学习打卡模块:语文、数学、英语和绘本。家长可以在“学习”页按孩子启用或隐藏整个学习包,以及其中的每个模块;首页“今日打卡”显示当天已完成的模块数,例如 `2/4` 表示完成了其中两个模块。语文里的识字、古诗和写字虽然是三个独立任务,但同一天只计作 1 个语文模块。关闭某个模块只会隐藏入口并调整统计分母,不会删除孩子已有的历史记录。 -“成长”页面的近 30 天打卡日历按模块统计。每个日期格会显示 `已完成/4`,颜色和图例对应当天完成的模块数,黄色边框表示今天;因此格内的 `4/4` 表示四个学习模块全部完成,不是四条任务记录。 +“成长”页面的近 30 天打卡日历按模块统计。每个日期格会显示 `已完成/n`(`n` 为该孩子已启用的模块数),颜色和图例对应当天完成的模块数,黄色边框表示今天;因此格内的 `4/4` 表示四个学习模块全部完成,不是四条任务记录。 “积分”页面的日历独立记录行为积分,不计入成长模块数量。日期颜色分别表示无积分、有加分、有扣分,或同一天同时有加分和扣分;黄色边框表示当前选中的日期,可用于查看或补记该日积分。 +## 积分、奖励与兑换 + +- **积分项**:家长可以在“积分”页创建自定义积分项(例如“自己刷牙”),设定每次的积分数值;也可以从推荐模板中选择。每个孩子可以单独启用或停用积分项。 +- **加分与扣分**:从“今日行动”或“积分”页选择积分项记录一次正向行动;需要纠正时使用快捷撤销(10 秒内),历史纠错使用受控的扣分记录。 +- **期初积分**:切换到新账本时,旧积分保持只读可见,不会自动导入新余额。家长可以为每个孩子人工确认一次“期初积分”,生成一条独立的期初流水;重复确认会被拒绝,后续纠错使用受控调整。 +- **奖励与兑换**:家长可以创建奖励(例如“选一个故事”)并设置所需积分,也可设为当前目标。孩子攒够积分后可以兑换;兑换在联网确认后生效,奖励在家长实际兑现并标记后显示为“已兑现”。 +- **界面音效**:完成行动、获得积分、再试一次、扣除积分和奖励兑现会播放程序化音效。在“设置”页可以关闭总开关、调节总音量、逐事件开关、选择音效变体、试听,并恢复默认。音效偏好只保存在当前设备,不会同步到云端。 + ## 听发音前准备系统语音 影伴优先使用浏览器提供的设备英语语音服务。影伴不采集麦克风录音,但系统语音是否联网取决于设备、浏览器和语音引擎。如果系统没有英语语音、系统语音无响应或播放失败,当前版本会切换到浏览器本地 Piper 合成。 diff --git a/index.html b/index.html index a9d0372..6fc124e 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - + 影伴 @@ -36,13 +36,11 @@

影伴

diff --git a/package.json b/package.json index 77ca309..3fb879d 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,13 @@ "verify": "npm run public:check && npm run security:check && npm run check && npm run build && node scripts/check-build.mjs && npm run test:coverage", "test": "vitest run", "test:unit": "vitest run tests/unit", + "test:fast": "npm run check && npm run test:unit", + "test:ui": "node scripts/run-e2e.mjs tests/e2e/offline.spec.js", + "test:full": "npm run verify && npm run test:db && npm run test:functions && npm run test:e2e && npm run release:check", "test:coverage": "vitest run --coverage", - "test:db": "supabase test db --local", + "supabase:local:start": "node scripts/start-shared-supabase.mjs", + "supabase:local:functions:serve": "node scripts/serve-shared-functions.mjs", + "test:db": "node scripts/test-shared-db.mjs", "test:functions": "node scripts/test-delete-account-guard.mjs", "test:e2e": "node scripts/run-e2e.mjs" }, diff --git a/public/manifest.json b/public/manifest.json index 3df4b74..8f6f83b 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,7 +1,7 @@ { "name": "影伴", "short_name": "影伴", - "description": "面向家庭的儿童学习打卡与成长记录 PWA", + "description": "影伴是一款面向家庭的学习记录与成长反馈 PWA,支持多孩子、学习打卡、行为积分、离线使用与云端同步。", "lang": "zh-CN", "start_url": "/", "scope": "/", diff --git a/scripts/serve-shared-functions.mjs b/scripts/serve-shared-functions.mjs new file mode 100644 index 0000000..1f8971d --- /dev/null +++ b/scripts/serve-shared-functions.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const shadowMateRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const merchantAdminRoot = path.resolve(shadowMateRoot, '..', 'shadow-size', 'merchant-admin') + +if (!fs.existsSync(path.join(merchantAdminRoot, 'package.json'))) { + throw new Error( + `找不到共享本地 Supabase 控制仓库:${merchantAdminRoot}\n请确认 shadow-mate、shadow-size 位于同一个 VibeCoding 目录。`, + ) +} + +const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm' +execFileSync(npmCommand, ['run', 'supabase:local:functions:serve'], { + cwd: merchantAdminRoot, + stdio: 'inherit', +}) diff --git a/scripts/start-shared-supabase.mjs b/scripts/start-shared-supabase.mjs new file mode 100644 index 0000000..8a5c968 --- /dev/null +++ b/scripts/start-shared-supabase.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const shadowMateRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const merchantAdminRoot = path.resolve(shadowMateRoot, '..', 'shadow-size', 'merchant-admin') + +if (!fs.existsSync(path.join(merchantAdminRoot, 'package.json'))) { + throw new Error( + `找不到共享本地 Supabase 控制仓库:${merchantAdminRoot}\n请确认 shadow-mate、shadow-size 位于同一个 VibeCoding 目录。`, + ) +} + +const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm' +execFileSync(npmCommand, ['run', 'supabase:local:start'], { + cwd: merchantAdminRoot, + stdio: 'inherit', +}) diff --git a/scripts/test-delete-account-guard.mjs b/scripts/test-delete-account-guard.mjs index d9a9622..1c00fb6 100644 --- a/scripts/test-delete-account-guard.mjs +++ b/scripts/test-delete-account-guard.mjs @@ -1,8 +1,39 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function loadLocalEnv() { + const envPath = path.join(projectRoot, ".env.local"); + if (!fs.existsSync(envPath)) return; + + for (const line of fs.readFileSync(envPath, "utf8").split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + const separator = trimmed.indexOf("="); + if (separator < 1) continue; + + const key = trimmed.slice(0, separator).trim(); + const value = trimmed + .slice(separator + 1) + .trim() + .replace(/^(["'])(.*)\1$/, "$2"); + + if (!process.env[key]) process.env[key] = value; + } +} + +loadLocalEnv(); + const supabaseUrl = process.env.VITE_SUPABASE_URL; const publishableKey = process.env.VITE_SUPABASE_PUBLISHABLE_KEY; if (!supabaseUrl || !publishableKey) { - throw new Error("VITE_SUPABASE_URL and VITE_SUPABASE_PUBLISHABLE_KEY are required"); + throw new Error( + "VITE_SUPABASE_URL and VITE_SUPABASE_PUBLISHABLE_KEY are required; set them in the environment or .env.local", + ); } const email = `delete-guard-${Date.now()}@example.test`; diff --git a/scripts/test-shared-db.mjs b/scripts/test-shared-db.mjs new file mode 100644 index 0000000..c18e90b --- /dev/null +++ b/scripts/test-shared-db.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const shadowMateRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const merchantAdminRoot = path.resolve(shadowMateRoot, '..', 'shadow-size', 'merchant-admin') +const testsDir = path.join(shadowMateRoot, 'supabase', 'tests') + +function collectTestFiles() { + return fs + .readdirSync(testsDir) + .filter((entry) => entry.endsWith('.sql')) + .sort() + .map((entry) => path.join(testsDir, entry)) +} + +function validateLocalDatabaseUrl(databaseUrl, source) { + let parsedUrl + try { + parsedUrl = new URL(databaseUrl) + } catch { + parsedUrl = null + } + + if (!parsedUrl || parsedUrl.protocol !== 'postgresql:' || parsedUrl.hostname !== '127.0.0.1') { + throw new Error( + `${source} 不是 loopback 本地 PostgreSQL 地址;为避免测试误连生产库,已停止。`, + ) + } + + return databaseUrl +} + +function readDatabaseUrlFromSupabase(root, source) { + const status = execFileSync( + 'npx', + ['supabase', 'status', '-o', 'env'], + { + cwd: root, + env: { ...process.env, SUPABASE_TELEMETRY_DISABLED: '1' }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) + const line = status.split(/\r?\n/).find((entry) => entry.startsWith('DB_URL=')) + const databaseUrl = line + ?.slice('DB_URL='.length) + .trim() + .replace(/^['"]|['"]$/g, '') + + if (!databaseUrl) { + throw new Error(`${source} 没有返回 DB_URL;请先启动本地 Supabase。`) + } + + return validateLocalDatabaseUrl(databaseUrl, source) +} + +function getDatabaseUrl() { + if (process.env.SHADOW_MATE_TEST_DB_URL) { + return validateLocalDatabaseUrl(process.env.SHADOW_MATE_TEST_DB_URL, 'SHADOW_MATE_TEST_DB_URL') + } + + if (fs.existsSync(path.join(merchantAdminRoot, 'package.json'))) { + return readDatabaseUrlFromSupabase(merchantAdminRoot, 'merchant-admin 本地 Supabase') + } + + throw new Error( + `找不到 merchant-admin:${merchantAdminRoot}\n本地测试请运行 npm run supabase:local:start;CI 若使用隔离数据库,必须显式设置 SHADOW_MATE_TEST_DB_URL。`, + ) +} + +function buildTestDatabaseUrl(databaseUrl) { + const parsedUrl = new URL(databaseUrl) + parsedUrl.searchParams.set('sslmode', 'disable') + return parsedUrl.toString() +} + +export { buildTestDatabaseUrl } + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + const databaseUrl = getDatabaseUrl() + const testDatabaseUrl = buildTestDatabaseUrl(databaseUrl) + const testFiles = collectTestFiles() + + if (testFiles.length === 0) { + throw new Error(`在 ${testsDir} 中没有找到任何 SQL 测试文件。`) + } + + console.log(`🧪 运行 ${testFiles.length} 个 pgTAP 测试文件(目标:loopback 本地数据库)`) + execFileSync( + 'npx', + [ + 'supabase', + 'test', + 'db', + '--db-url', + testDatabaseUrl, + ...testFiles, + ], + { + cwd: shadowMateRoot, + env: { ...process.env, SUPABASE_TELEMETRY_DISABLED: '1' }, + stdio: 'inherit', + }, + ) +} diff --git a/src/app.css b/src/app.css index 94f831e..6318b97 100644 --- a/src/app.css +++ b/src/app.css @@ -106,6 +106,17 @@ padding:4px 10px;border-radius:999px;font-weight:700; } + /* 学习包启停开关 */ + .switch-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 2px;border-top:1px solid var(--line);} + .switch-row:first-of-type{border-top:none;} + .switch-label{display:flex;flex-direction:column;gap:2px;font-size:14px;font-weight:700;} + .switch-label .desc{font-size:11.5px;color:var(--ink-soft);font-weight:400;margin:0;} + .switch-row input[type="checkbox"]{width:44px;height:26px;appearance:none;flex:0 0 auto;border-radius:999px;background:var(--line);border:2px solid var(--line);position:relative;cursor:pointer;transition:background-color .18s ease,border-color .18s ease;margin:0;} + .switch-row input[type="checkbox"]::after{content:"";position:absolute;top:2px;left:2px;width:18px;height:18px;border-radius:50%;background:#fff;box-shadow:var(--shadow);transition:transform .18s ease;} + .switch-row input[type="checkbox"]:checked{background:var(--green);border-color:var(--green);} + .switch-row input[type="checkbox"]:checked::after{transform:translateX(18px);} + .switch-row input[type="checkbox"]:focus-visible{outline:3px solid var(--focus);outline-offset:2px;} + /* 识字卡片 */ .hanzi-card{display:flex;flex-direction:column;align-items:center;padding:14px 8px;} .hanzi-big{font-size:72px;font-weight:800;color:var(--ink);line-height:1;} @@ -271,6 +282,42 @@ .pts-card.sub .pts-toggle{background:#e08a8a;} .pts-card.sub.done .pts-toggle{background:#d9534f;} + .growth-form{display:grid;grid-template-columns:minmax(0,1fr) 120px auto;gap:8px;align-items:end;margin-top:10px;} + .growth-form label{display:flex;flex-direction:column;gap:5px;color:var(--green-deep);font-size:12px;font-weight:800;} + .growth-form input{width:100%;box-sizing:border-box;border:1px solid var(--line);border-radius:12px;background:var(--card);color:var(--ink);padding:10px 11px;font:inherit;font-size:14px;} + .growth-form .checkin{width:auto;white-space:nowrap;padding:10px 14px;font-size:13px;} + .growth-custom-card{background:linear-gradient(135deg,var(--card),var(--green-soft));} + .growth-reward-card{background:linear-gradient(135deg,var(--card),#fff7dc);} + .reward-list{margin-top:14px;display:grid;gap:9px;} + .reward-card{display:flex;align-items:center;gap:9px;border:1px solid var(--line);border-radius:16px;background:var(--card);padding:9px 10px;} + .reward-icon{width:34px;height:34px;border-radius:12px;background:#fff2bc;color:#a26c00;display:flex;align-items:center;justify-content:center;flex:0 0 auto;} + .reward-info{display:flex;flex:1;min-width:0;flex-direction:column;gap:2px;} + .reward-info strong{font-size:13px;color:var(--ink);} + .reward-info span{font-size:11px;color:var(--ink-soft);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} + .reward-card .pts-badge{margin-left:0;} + .reward-redeem{width:auto;padding:8px 10px;font-size:12px;} + .reward-redeem:disabled{opacity:.55;cursor:not-allowed;} + .reward-fulfill{width:auto;padding:8px 10px;font-size:12px;background:#ffd25e;color:#5b3e00;} + .reward-pill{flex:0 0 auto;} + .sound-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 0;border-bottom:1px solid var(--line);} + .sound-row:last-child{border-bottom:none;} + .sound-label{font-size:13.5px;font-weight:800;color:var(--ink);} + .sound-switch{min-width:52px;padding:7px 12px;border-radius:999px;border:1px solid var(--line);background:#e8ece8;color:var(--ink-soft);font-family:inherit;font-size:13px;font-weight:800;cursor:pointer;transition:background-color .15s ease,color .15s ease,transform .15s ease;} + .sound-switch:active{transform:scale(.96);} + .sound-switch.on{background:var(--green);color:#fff;border-color:var(--green);} + .sound-range{flex:1;max-width:220px;accent-color:var(--green);} + .sound-events{display:grid;gap:10px;} + .sound-event{border:1px solid var(--line);border-radius:14px;padding:10px 12px;display:grid;gap:8px;} + .sound-event-head{display:flex;align-items:center;justify-content:space-between;gap:10px;} + .sound-event-controls{display:flex;align-items:center;gap:8px;} + .sound-event-controls select{flex:1;min-width:0;border:1px solid var(--line);border-radius:10px;background:var(--card);color:var(--ink);padding:7px 8px;font:inherit;font-size:13px;} + .sound-event-controls select:disabled{opacity:.5;} + .sound-preview{width:auto;padding:7px 12px;font-size:12px;flex:0 0 auto;} + @media (max-width:360px){ + .growth-form{grid-template-columns:1fr;} + .growth-form .checkin{width:100%;} + } + .cal{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:6px;} .cal-chip{width:100%;height:auto;aspect-ratio:1;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:800;background:#f1f7f2;border:2px solid transparent;cursor:pointer;color:var(--ink-soft);transition:transform .15s ease,background-color .15s ease,border-color .15s ease,box-shadow .15s ease;font-family:inherit;appearance:none;} .cal-chip:active{transform:scale(.96);} diff --git a/src/app.js b/src/app.js index 831c2a3..3a0945a 100644 --- a/src/app.js +++ b/src/app.js @@ -13,14 +13,25 @@ import { } from "./piper-tts.js"; import { icon, hydrateIcons } from "./icons.js"; import { - CHECKIN_GROUPS, createLearningState, hasCheckin, - isPointMarked, transitionLearningState, } from "./learning-state.js"; - -const CHECKIN_MODULES = Object.keys(CHECKIN_GROUPS); +import { getLearningStateStorageKey } from "./learning-state-envelope.js"; +import { + FOUNDATION_PACKAGE, + getContentModuleDefinition, + getEnabledModuleIds, + normalizeContentConfig, + setContentModuleEnabled, + setContentPackageEnabled, +} from "./learning-content-package.js"; +import { loadLearningStateEnvelope, adoptPendingLearningState } from "./learning-state-storage.js"; +import { createIndexedDbLearningDb } from "./learning-local-db.js"; +import { createGrowthLoopController } from "./learning-growth-loop-controller.js"; +import { ACTIVITY_EVENT_TYPES, activityEventIdFor } from "./learning-analytics.js"; +import { getActivePointAction, getBalance, getOpeningBalance, getPointDayTotal, getPointPeriodTotal } from "./learning-growth-loop.js"; +import { createSoundEngine, SOUND_EVENTS, SOUND_EVENT_KEYS } from "./learning-sounds.js"; inject(); installRapidActionGuard(document); @@ -130,31 +141,97 @@ const PEANUT_BOOKS = [ ========================================================= */ const STORE_KEY = "shadow_mate_workbench_v1"; +const growthLoopDb = createIndexedDbLearningDb(); +const soundEffects = createSoundEngine(); +window.soundEffects = soundEffects; +const growthLoopController = createGrowthLoopController({ + db: growthLoopDb, + onRewardFulfilled: ({ redemption }) => { + if (redemption?.status === "fulfilled") soundEffects.play("reward_fulfilled"); + }, +}); +let growthLoopSnapshot = growthLoopController.getSnapshot(); +let CURRENT_MOD = "home"; +window.growthLoop = growthLoopController; + +function clientRequestId(prefix = "growth") { + if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID(); + return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +function queueGrowthActivity(event_type, payload = {}, bucket = "once") { + const scope = growthLoopController.getScope(); + if (!scope.household_id || !scope.profile_id) return Promise.resolve(null); + return growthLoopController.queueActivity({ + event_type, + event_id: activityEventIdFor({ ...scope, event_type, bucket }), + payload, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + client_version: document.documentElement.dataset.version || null, + }).catch((error) => { + console.warn("Growth Loop activity event queued locally but could not be written:", error); + return null; + }); +} + +growthLoopController.subscribe((nextSnapshot) => { + growthLoopSnapshot = nextSnapshot; + if (CURRENT_MOD === "points" || CURRENT_MOD === "grow") switchMod(CURRENT_MOD); +}); + +function learningStateFromEnvelope(envelope) { + return envelope?.schema_version === 2 && envelope.learning ? envelope.learning : envelope; +} + function readStoredState(){ - const raw = localStorage.getItem(STORE_KEY); - if(raw === null) return {}; - try{ - const parsed = JSON.parse(raw); - return parsed && typeof parsed === "object" ? parsed : {}; - }catch(error){ - console.warn("忽略无法解析的本地存档", error); - } - return {}; + return learningStateFromEnvelope(loadLearningStateEnvelope(localStorage, {})); } let store = createLearningState(readStoredState()); if(!store.checkins) store.checkins = {}; // {date: {module:true}} if(!store.extra) store.extra = {}; // 扩展记录(如数学题数) -if(!store.points) store.points = {}; // {ym: {itemIdx: {day:1}}} 积分打卡记录 +if(!store.points) store.points = {}; // 仅保留旧积分历史;新 Growth Loop 不再写入此字段 if(!store.bookShelf) store.bookShelf = {}; // {bookIdx:1} 绘本已读标记 if(!store.peanutLog) store.peanutLog = []; // [{title,date,rating}] 小花生阅读记录 if(!store.peanutRead) store.peanutRead = {}; // {bookIdx:1} 小花生书单已读标记 +let learningEnvelope = loadLearningStateEnvelope(localStorage, {}); + +function persistLearningState(){ + learningEnvelope = { + ...learningEnvelope, + schema_version: 2, + product_id: "shadow-mate", + learning: structuredClone(store), + }; + const scope = learningEnvelope.scope || {}; + localStorage.setItem(getLearningStateStorageKey(scope), JSON.stringify(learningEnvelope)); + // 兼容旧版本的低风险学习状态读取和既有冲突测试;新 Growth Loop + // 账本永远不写入旧 state.points。 + localStorage.setItem(STORE_KEY, JSON.stringify({ ...store, points: {} })); +} + function save(){ - localStorage.setItem(STORE_KEY, JSON.stringify(store)); + persistLearningState(); window.cloudSync?.schedule(); } +async function setLearningScope(scope, { adoptPending = false } = {}) { + learningEnvelope = adoptPending + ? adoptPendingLearningState(localStorage, scope) + : loadLearningStateEnvelope(localStorage, scope); + store = createLearningState(learningStateFromEnvelope(learningEnvelope)); + if(!store.checkins) store.checkins = {}; + if(!store.extra) store.extra = {}; + if(!store.points) store.points = {}; + if(!store.bookShelf) store.bookShelf = {}; + if(!store.peanutLog) store.peanutLog = []; + if(!store.peanutRead) store.peanutRead = {}; + persistLearningState(); + switchMod(CURRENT_MOD); + return structuredClone(learningEnvelope); +} + function todayKey(){ const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`; @@ -174,6 +251,27 @@ function isChecked(mod){ return hasCheckin(store.checkins[t], mod); } +function enabledModuleIds(){ + return getEnabledModuleIds(store.content_config); +} + +function updateContentPackage(enabled){ + store = { ...store, content_config: setContentPackageEnabled(store.content_config, enabled) }; + save(); + switchMod(CURRENT_MOD); +} + +function updateContentModule(moduleId, enabled){ + store = { ...store, content_config: setContentModuleEnabled(store.content_config, moduleId, enabled) }; + save(); + switchMod(CURRENT_MOD); +} + +function contentModuleLabel(moduleId){ + const labels = { chinese: "语文学习", math: "数学与数感", english: "英语学习", book: "绘本读物" }; + return labels[moduleId] || moduleId; +} + function toggleCheckin(mod){ store = transitionLearningState(store, { type: "CHECKIN_TOGGLED", @@ -181,6 +279,13 @@ function toggleCheckin(mod){ key: mod, }); save(); + if (isChecked(mod)) soundEffects.play("action_completed"); + void queueGrowthActivity( + ACTIVITY_EVENT_TYPES.GROWTH_ACTIVITY_RECORDED, + { source: "checkin", entry_type: "manual" }, + `${todayKey()}:${mod}`, + ); + window.cloudSync?.scheduleGrowthLoop?.(); } function streak(mod){ let s = 0; @@ -199,37 +304,78 @@ function totalChecked(mod){ /* 积分打卡辅助 */ function ymKey(){ const d=new Date(); return d.getFullYear()+"-"+(d.getMonth()+1); } -function pointOn(itemIdx, day){ - const ym = ymKey(); - return isPointMarked(store, ym, itemIdx, day); +function dateKeyForDay(day){ + const d = new Date(); + d.setHours(0, 0, 0, 0); + d.setDate(Number(day)); + return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`; } -function togglePoint(itemIdx, day){ - store = transitionLearningState(store, { - type: "POINT_TOGGLED", - month: ymKey(), - itemIndex: itemIdx, - day, +function visiblePointItems(){ + const items = window.growthLoop?.getPointItems?.() || []; + if(items.length) return items; + return POINT_ITEMS.map((item, index) => ({ + id: `recommended:${index}`, + name: item.name, + description: item.desc, + default_points: item.pts, + icon_key: PTS_ICON[item.name] || "star", + item_kind: "recommended", + })); +} +function pointItemAt(itemId){ + return visiblePointItems().find((item) => item.id === itemId) || null; +} +function pointOn(itemId, day){ + const item = pointItemAt(itemId); + if(!item?.id || item.id.startsWith("recommended:")) return false; + return getActivePointAction(growthLoopSnapshot, item.id, dateKeyForDay(day)); +} +function togglePoint(itemId, day){ + const item = pointItemAt(itemId); + if(!item) return; + const requestId = clientRequestId("point"); + const wasOn = pointOn(itemId, day); + const delta = Number(item.default_points ?? item.pts); + void window.growthLoop.recordPoint({ item, occurred_on: dateKeyForDay(day), request_id: requestId }).then(() => { + void queueGrowthActivity( + ACTIVITY_EVENT_TYPES.GROWTH_ACTIVITY_RECORDED, + { source: "point_item", entry_type: Number(item.default_points ?? item.pts) < 0 ? "adjustment" : "manual" }, + requestId, + ); + void queueGrowthActivity(ACTIVITY_EVENT_TYPES.CORE_ACTIVATION, { source: "point_item" }, "once"); + window.cloudSync?.scheduleGrowthLoop?.(); + renderPoints(); + if (wasOn) { + soundEffects.play("try_again"); + } else if (delta < 0) { + soundEffects.play("points_deducted"); + } else { + soundEffects.play("points_earned"); + } + }).catch((error) => { + console.error("Growth Loop local point write failed:", error); + alert("本机记录没有保存成功,请稍后重试。"); }); - save(); } -function itemMonthTotal(itemIdx){ - const ym = ymKey(); - const rec = store.points[ym] && store.points[ym][itemIdx]; - if(!rec) return 0; - return POINT_ITEMS[itemIdx].pts * Object.keys(rec).length; +function itemMonthTotal(itemId){ + const item = pointItemAt(itemId); + if(!item?.id || item.id.startsWith("recommended:")) return 0; + return getPointPeriodTotal(growthLoopSnapshot, item.id, currentPeriodKey()); } function monthTotal(){ - let s = 0; - for(let i=0;i total + itemMonthTotal(item.id), 0); +} +function currentPeriodKey(){ + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; } function pointDayState(day){ let total = 0; let positive = false; let negative = false; - for(let i=0;i 0) positive = true; if(points < 0) negative = true; @@ -308,6 +454,7 @@ function stopActivePlayback() { } async function speak(t, button){ + soundEffects.setTtsActive(true); const originalLabel = button?.dataset.label || "听发音"; const voiceHelp = "请在系统设置中安装英语语音包,然后重试"; const showSpeechGuide = () => { @@ -324,6 +471,7 @@ async function speak(t, button){ button.after(guideLink); }; const restore = () => { + soundEffects.setTtsActive(false); clearSystemTimer(); if (!button) return; button.innerHTML = buttonContent("volume", originalLabel); @@ -337,6 +485,12 @@ async function speak(t, button){ button.innerHTML = buttonContent("alert", message); button.title = message.startsWith("未检测到系统语音") ? voiceHelp : message; button.dataset.speechFailure = "true"; + const errorCode = message.includes("超时") ? "timeout" : message.includes("下载") ? "download_failed" : "synthesis_failed"; + void queueGrowthActivity(ACTIVITY_EVENT_TYPES.TTS_FAILED, { + source: "offline_tts", + error_code: errorCode, + retryable: true, + }, `${errorCode}:${Date.now()}`); showSpeechGuide(); window.setTimeout(() => { if (button.dataset.speechFailure === "true") restore(); @@ -553,49 +707,109 @@ function renderHome(){ const hz = HANZI[(di*2)%HANZI.length]; const poem = POEMS[di%POEMS.length]; const en = ENGLISH[di%ENGLISH.length]; - const checkedToday = CHECKIN_MODULES.filter(isChecked).length; - const m = new Date().getMonth()+1; + const enabled = enabledModuleIds(); + const learningOn = enabled.length > 0; + const checkedToday = enabled.filter(isChecked).length; const main = el("main"); main.innerHTML = ""; main.appendChild($(` `)); - const stat = $(` -
-

${icon("chart")} 今日成长数据

-
-
${checkedToday}/${CHECKIN_MODULES.length}
今日打卡
-
${streak("chinese")}
语文连续(天)
-
${totalChecked("math")}
数学累计打卡
-
${totalChecked("english")}
英语累计打卡
+ if (learningOn) { + const moduleStats = enabled.map((module) => ({ + value: module === "chinese" ? streak("chinese") : totalChecked(module), + caption: module === "chinese" ? "语文连续(天)" : `${contentModuleLabel(module)}累计打卡`, + })); + const stat = $(` +
+

${icon("chart")} 今日成长数据

+
+
${checkedToday}/${enabled.length}
今日打卡
+ ${moduleStats.map((s) => `
${s.value}
${s.caption}
`).join("")} +
+
-
-
- `); - main.appendChild(stat); - stat.querySelector(".progressbar i").style.width = (checkedToday/CHECKIN_MODULES.length*100)+"%"; + `); + main.appendChild(stat); + stat.querySelector(".progressbar i").style.width = (checkedToday/enabled.length*100)+"%"; + } - // 三大模块快捷入口 - const mk = (mod,iconName,t,d)=>$(` + // 统一学习入口 + 积分 + const mk = (mod,iconName,t,d,pillText)=>$(` `); - main.appendChild(mk("chinese","book","语文学习","识字·古诗词·写字 每日打卡")); - main.appendChild(mk("math","calculator","数学与数感","口算·数感游戏·数独 每日打卡")); - main.appendChild(mk("english","languages","英语学习","主题单词 每日推送与朗读打卡")); - main.appendChild(mk("book","library","绘本读物","多地优质绘本 · 跟读+思考题")); + main.appendChild(mk("learning","graduation","学习", learningOn ? "语文 · 数学 · 英语 · 绘本,按孩子启停" : "学习包未启用,点此开启", learningOn ? "学习" : "未启用")); main.appendChild(mk("points","star","积分打卡","加分减分 · 月度行为积分表")); main.querySelectorAll("[data-go]").forEach(c=>c.onclick=()=>switchMod(c.dataset.go)); main.appendChild($(``)); } +function renderLearning(){ + const main = el("main"); main.innerHTML=""; + main.appendChild(modTitle("graduation","学习")); + const config = normalizeContentConfig(store.content_config); + const enabled = enabledModuleIds(); + const settings = $(` +
+

${icon("grid")} 学习包设置

+
${escapeHtml(FOUNDATION_PACKAGE.name)} · 建议年龄 ${escapeHtml(FOUNDATION_PACKAGE.suggested_age)} 岁 · 按孩子独立启停。关闭模块只影响入口和统计,不会删除打卡历史。
+ + ${FOUNDATION_PACKAGE.modules.map((module) => ` + `).join("")} +
+ `); + main.appendChild(settings); + settings.querySelectorAll("[data-config-toggle='package']").forEach((input) => { + input.onchange = () => updateContentPackage(input.checked); + }); + settings.querySelectorAll("[data-config-toggle='module']").forEach((input) => { + input.onchange = () => updateContentModule(input.dataset.moduleId, input.checked); + }); + + if (enabled.length) { + const entries = enabled.map((moduleId) => { + const module = getContentModuleDefinition(moduleId); + const done = isChecked(moduleId); + return ``; + }).join(""); + main.appendChild($(` +
+

${icon("list")} 学习模块

+ ${entries} +
+ `)); + main.querySelectorAll("[data-go]").forEach((c) => (c.onclick = () => switchMod(c.dataset.go))); + } else { + main.appendChild($(` +
+

${icon("sprout")} 学习包未启用

+
开启上方「启用学习包」后,才能看到并进入学习模块。
+
+ `)); + } + main.appendChild($(``)); +} + /* ========================================================= 渲染:语文 ========================================================= */ @@ -697,8 +911,8 @@ function renderMath(){ const v = el("qa").value; const f = el("qf"); if(v===""){ f.textContent="请先写出答案哦"; f.className="feedback no"; return; } - if(+v===mathAns){ f.innerHTML=`${icon("party")} 答对啦,真棒!`; f.className="feedback ok"; } - else { f.textContent=`再想想~正确答案是 ${mathAns}`; f.className="feedback no"; } + if(+v===mathAns){ f.innerHTML=`${icon("party")} 答对啦,真棒!`; f.className="feedback ok"; soundEffects.play("action_completed"); } + else { f.textContent=`再想想~正确答案是 ${mathAns}`; f.className="feedback no"; soundEffects.play("try_again"); } }; // 数感:数字填写 1-100 找缺失 @@ -834,55 +1048,215 @@ function renderEnglish(){ /* ========================================================= 渲染:成长 ========================================================= */ +function openingStatusLabel(entry) { + if (!entry) return ""; + if (entry.status === "pending" || entry.status === "retryable") return "待联网确认"; + if (entry.status === "confirmed") return "已确认"; + if (entry.status === "conflict") return "待处理"; + return "确认失败"; +} + function renderGrow(){ const main = el("main"); main.innerHTML=""; main.appendChild(modTitle("sprout","成长记录")); - const total = CHECKIN_MODULES.reduce((sum, module) => sum + totalChecked(module), 0); - const card = $(` -
-

${icon("trophy")} 打卡总览

-
-
${totalChecked("chinese")}
语文累计(天)
-
${totalChecked("math")}
数学累计(天)
-
${totalChecked("english")}
英语累计(天)
-
${totalChecked("book")}
绘本累计(天)
+ const enabled = enabledModuleIds(); + const learningOn = enabled.length > 0; + + if (learningOn) { + const total = enabled.reduce((sum, module) => sum + totalChecked(module), 0); + const overview = $(` +
+

${icon("trophy")} 打卡总览

+
+ ${enabled.map((module) => `
${totalChecked(module)}
${contentModuleLabel(module)}累计(天)
`).join("")} +
+
${icon("chart")} 累计模块打卡:${total} 次
+
${icon("flame")} 连续打卡:${enabled.map((module) => `${contentModuleLabel(module)} ${streak(module)} 天`).join(" · ")}
+
+
目标:累计 30 次打卡解锁「挖掘机小队长」徽章
+
+ `); + main.appendChild(overview); + overview.querySelector(".progressbar i").style.width = Math.min(100,total/30*100)+"%"; + + // 日历式最近记录(只统计已启用模块) + let cells=""; + for(let i=29;i>=0;i--){ + const k=dateKeyOffset(i); + const c=store.checkins[k]; + const n = enabled.filter((module) => hasCheckin(c, module)).length; + const day = Number(k.slice(-2)); + const label = `${k},${n ? `已完成 ${n}/${enabled.length} 个学习模块` : "未打卡"}`; + cells += `
${day}${n}/${enabled.length}
`; + } + const legendLevels = Array.from({ length: enabled.length + 1 }, (_, level) => + `${level}/${enabled.length} ${level === 0 ? "未打卡" : level === enabled.length ? "全部完成" : "模块"}` + ).join(""); + const cal = $(` +
+

${icon("calendar")} 近 30 天打卡日历

+
${cells}
+
颜色表示当天完成的学习模块数,格内比例是已完成/共 ${enabled.length} 个模块,边框表示今天。
+
+ ${legendLevels} + 今天 +
+
+ `); + main.appendChild(cal); + } else { + const hint = $(` +
+

${icon("sprout")} 学习模块统计已隐藏

+
当前孩子的学习包未启用,首页和这里不会显示学习模块统计。启用后在「学习」页为这个孩子开启学习模块。
+
-
${icon("chart")} 累计模块打卡:${total} 次
-
${icon("flame")} 连续打卡:语文 ${streak("chinese")} 天 · 数学 ${streak("math")} 天 · 英语 ${streak("english")} 天 · 绘本 ${streak("book")} 天
-
-
目标:累计 30 次打卡解锁「挖掘机小队长」徽章
+ `); + main.appendChild(hint); + hint.querySelector("[data-go]").onclick = () => switchMod("learning"); + } + + const balance = getBalance(growthLoopSnapshot); + const opening = getOpeningBalance(growthLoopSnapshot); + const openingCard = $(` +
+ ${opening + ? `

${icon("checkCircle")} 期初积分已确认

+
+
${opening.delta}
期初积分
+
${openingStatusLabel(opening)}
状态
+
+
已确认的期初积分计入余额,不计入行为统计;如需纠错,请使用普通积分调整流水。
` + : `

${icon("star")} 期初积分

+
把旧记录里已经积累的积分带过来?期初积分由家长为当前孩子明确确认一次,不会自动导入旧流水;确认后如需调整,请用普通积分调整流水。
+
+ + +
` + }
`); - main.appendChild(card); - card.querySelector(".progressbar i").style.width = Math.min(100,total/30*100)+"%"; - - // 日历式最近记录 - let cells=""; - for(let i=29;i>=0;i--){ - const k=dateKeyOffset(i); - const c=store.checkins[k]; - const n = CHECKIN_MODULES.filter((module) => hasCheckin(c, module)).length; - const lvl = n; - const day = Number(k.slice(-2)); - const label = `${k},${n ? `已完成 ${n}/${CHECKIN_MODULES.length} 个学习模块` : "未打卡"}`; - cells += `
${day}${n}/${CHECKIN_MODULES.length}
`; + main.appendChild(openingCard); + const openingForm = openingCard.querySelector("#openingBalanceForm"); + if (openingForm) { + openingForm.onsubmit = async (event) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); + const value = Number(form.get("balance")); + if (!Number.isInteger(value) || value < 1 || value > 1000000) { + alert("请填写 1 到 1000000 的整数积分。"); + return; + } + if (!window.confirm(`确定为当前孩子确认 ${value} 分期初积分?每个孩子只能确认一次。`)) return; + try { + const result = await window.growthLoop.confirmOpeningBalance({ + balance: value, + note: "期初积分", + request_id: clientRequestId("opening"), + }); + if (result.error === "opening_balance_already_confirmed") { + alert("这个孩子的期初积分已经确认过了。"); + } else if (result.error) { + alert("期初积分确认失败,请稍后重试。"); + } else { + window.cloudSync?.scheduleGrowthLoop?.(); + renderGrow(); + } + } catch (error) { + console.error("Growth Loop opening balance confirm failed:", error); + alert("期初积分没有保存成功,请稍后重试。"); + } + }; } - const cal = $(` -
-

${icon("calendar")} 近 30 天打卡日历

-
${cells}
-
颜色表示当天完成的学习模块数,格内比例是已完成/共 ${CHECKIN_MODULES.length} 个模块,边框表示今天。
-
- 0/${CHECKIN_MODULES.length} 未打卡 - 1/${CHECKIN_MODULES.length} 模块 - 2/${CHECKIN_MODULES.length} 模块 - 3/${CHECKIN_MODULES.length} 模块 - ${CHECKIN_MODULES.length}/${CHECKIN_MODULES.length} 全部完成 - 今天 + + const rewards = window.growthLoop?.getRewards?.() || []; + const pendingRedemptions = growthLoopSnapshot.redemptions.filter((item) => item.status === "pending").length; + const rewardCards = rewards.map((reward) => { + const cost = Number(reward.cost_points || 0); + const latest = growthLoopSnapshot.redemptions + .filter((item) => item.reward_id === reward.id) + .sort((left, right) => String(right.created_at || "").localeCompare(String(left.created_at || "")))[0]; + const status = latest?.status === "pending" ? "待联网确认" : latest?.status === "fulfilled" ? "已兑现" : ""; + const canFulfill = latest?.status === "pending" && latest?.confirmed && !latest?.fulfill_requested; + const fulfillRequested = latest?.status === "pending" && latest?.confirmed && latest?.fulfill_requested; + const fulfillAction = canFulfill + ? `` + : fulfillRequested + ? `兑现已请求` + : ""; + return `
+
${icon(reward.icon_key || "gift")}
+
${escapeHtml(reward.name)}${escapeHtml(reward.description || "家长和孩子一起约定")}
+ ${cost}分 + + ${fulfillAction} +
`; + }).join(""); + const rewardCard = $(`
+

${icon("gift")} 奖励兑换

+
+
${balance}
当前可用积分
+
${pendingRedemptions}
待联网确认
-
- `); - main.appendChild(cal); +
离线兑换会先记为“待联网确认”,联网并完成服务端确认前不代表最终成功。
+
+ + + +
+
${rewardCards || '
还没有奖励,先添加一个约定吧。
'}
+
`); + main.appendChild(rewardCard); + rewardCard.querySelector("#rewardForm").onsubmit = async (event) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); + const name = String(form.get("name") || "").trim(); + const cost = Number(form.get("cost")); + if (!name || !Number.isInteger(cost) || cost < 1 || cost > 100000) { + alert("请填写奖励名称,并输入 1 到 100000 的整数积分。"); + return; + } + try { + await window.growthLoop.createReward({ + request_id: clientRequestId("reward"), + reward: { name, description: "家庭约定奖励", cost_points: cost, category: "family", icon_key: "gift" }, + }); + window.cloudSync?.scheduleGrowthLoop?.(); + renderGrow(); + } catch (error) { + console.error("Growth Loop reward creation failed:", error); + alert("奖励没有保存成功,请稍后重试。"); + } + }; + rewardCard.querySelectorAll("[data-reward-id]").forEach((button) => { + button.onclick = async () => { + const requestId = clientRequestId("redemption"); + button.disabled = true; + const result = await window.growthLoop.redeemReward({ reward_id: button.dataset.rewardId, request_id: requestId }); + if (result.error) { + button.disabled = false; + alert(result.error === "insufficient_points" ? "积分还不够,继续积累后再兑换吧。" : "这个奖励暂时不能兑换,请刷新后重试。"); + return; + } + void queueGrowthActivity(ACTIVITY_EVENT_TYPES.REWARD_REDEEMED, { source: "reward" }, requestId); + window.cloudSync?.scheduleGrowthLoop?.(); + renderGrow(); + }; + }); + rewardCard.querySelectorAll("[data-fulfill-id]").forEach((button) => { + button.onclick = async () => { + button.disabled = true; + const result = await window.growthLoop.fulfillRedemption({ redemption_id: button.dataset.fulfillId }); + if (result.error) { + button.disabled = false; + alert("这个奖励暂时不能兑现,请稍后重试。"); + return; + } + window.cloudSync?.scheduleGrowthLoop?.(); + renderGrow(); + }; + }); + main.appendChild($(``)); } @@ -892,18 +1266,20 @@ function renderGrow(){ /* 积分卡片辅助 */ const PTS_ICON = {"一起做家务":"house","认真完成学习":"bookCheck","帮带带弟弟":"learner","古诗词跟读":"bookMarked","撒谎":"circleX","白天摸当众摸鸡鸡":"alert","不收玩具":"eraser"}; let PT_DAY = null; -function ptsCardHTML(it, i, day){ - const done = pointOn(i, day); - const sub = it.pts < 0; - const ptsIcon = PTS_ICON[it.name] || (sub ? "alert" : "star"); +function ptsCardHTML(it, day){ + const done = pointOn(it.id, day); + const points = Number(it.default_points ?? it.pts ?? 0); + const description = it.description ?? it.desc ?? ""; + const sub = points < 0; + const ptsIcon = it.icon_key || PTS_ICON[it.name] || (sub ? "alert" : "star"); return `
${icon(ptsIcon)}
-
${it.name}
- ${it.desc?`
${it.desc}
`:""} +
${escapeHtml(it.name)}
+ ${description?`
${escapeHtml(description)}
`:""}
-
${it.pts>0?'+':'-'}${Math.abs(it.pts)}分
- +
${points>0?'+':'-'}${Math.abs(points)}分
+
`; } @@ -919,7 +1295,8 @@ function renderPoints(){ if(PT_DAY===null || PT_DAY > daysInMonth) PT_DAY = Math.min(today, daysInMonth); const activeDay = PT_DAY; const mt = monthTotal(); - const dt = dayTotal(activeDay); + const dt = getPointDayTotal(growthLoopSnapshot, dateKeyForDay(activeDay)); + const pointItems = visiblePointItems(); // 挖掘机主题横幅 main.appendChild($(`
@@ -967,20 +1344,70 @@ function renderPoints(){
${icon("checkCircle")} 为 ${new Date().getMonth()+1}月${activeDay}日打卡 ${isToday?'今天':''}
${isToday?"":``}
${icon("plus")} 加分项
- ${POINT_ITEMS.filter(it=>it.group==="加分项").map((it)=>ptsCardHTML(it,POINT_ITEMS.indexOf(it),activeDay)).join("")} + ${pointItems.filter((it) => Number(it.default_points ?? it.pts) > 0).map((it)=>ptsCardHTML(it,activeDay)).join("")}
${icon("minus")} 减分项目
- ${POINT_ITEMS.filter(it=>it.group==="减分项目").map((it)=>ptsCardHTML(it,POINT_ITEMS.indexOf(it),activeDay)).join("")} + ${pointItems.filter((it) => Number(it.default_points ?? it.pts) < 0).map((it)=>ptsCardHTML(it,activeDay)).join("")}
`); main.appendChild(head); - head.querySelectorAll(".pts-toggle").forEach(b=>b.onclick=()=>{ togglePoint(+b.dataset.i, activeDay); renderPoints(); }); + head.querySelectorAll(".pts-toggle").forEach((button)=>{ + button.onclick=()=>{ + togglePoint(button.dataset.itemId, activeDay); + button.disabled = true; + }; + }); const bt = el("backtoday"); if(bt) bt.onclick=()=>{PT_DAY=today; renderPoints();}; - // 清空 - main.appendChild($(`
`)); - el("ptclear").onclick=()=>{ - if(confirm("确定清空本月所有积分打卡记录?")){ - store = transitionLearningState(store, { type: "POINTS_CLEARED", month: ymKey() }); - save(); PT_DAY=today; renderPoints(); + const customCard = $(`
+

${icon("pencil")} 自定义积分项

+
把成长任务纳入积分管理:正数是加分,负数是扣分;每个孩子可以有自己的分值。
+
+ + + +
+
`); + main.appendChild(customCard); + customCard.querySelector("#pointItemForm").onsubmit = async (event) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); + const name = String(form.get("name") || "").trim(); + const points = Number(form.get("points")); + if (!name || !Number.isInteger(points) || points === 0 || Math.abs(points) > 1000) { + alert("请填写名称,并输入 1 到 1000 的整数分值(可填负数)。"); + return; + } + try { + await window.growthLoop.createPointItem({ + request_id: clientRequestId("point-item"), + item: { + name, + description: "自定义成长任务", + default_points: points, + category: "growth", + icon_key: points > 0 ? "star" : "alert", + item_kind: "custom", + }, + }); + window.cloudSync?.scheduleGrowthLoop?.(); + renderPoints(); + } catch (error) { + console.error("Growth Loop custom point item creation failed:", error); + alert("积分项没有保存成功,请稍后重试。"); + } + }; + + // 结束当前积分周期:保留历史,通过不可变的反向调整归零当前月。 + main.appendChild($(`
不会删除历史记录,会追加反向调整,让本月重新开始。
`)); + el("ptclear").onclick=async()=>{ + if(confirm("确定结束本月积分周期?历史记录会保留,但本月积分会归零。")){ + try { + await window.growthLoop.closePeriod({ period_key: currentPeriodKey(), request_id: clientRequestId("period-close") }); + window.cloudSync?.scheduleGrowthLoop?.(); + PT_DAY=today; renderPoints(); + } catch (error) { + console.error("Growth Loop point period close failed:", error); + alert("积分周期没有结束成功,请稍后重试。"); + } } }; main.appendChild($(``)); @@ -1158,18 +1585,23 @@ function renderGuide(){ -

国产 Android(无 Google 服务)没有英语系统语音时,首次点“听发音”会提示下载影伴内置的高质量离线语音包(约 115MB,一次性,可离线使用,不上传录音),下载后即可正常发音。

+

国产 Android(无 Google 服务)没有英语系统语音时,首次点“听发音”会提示下载影伴内置的高质量离线语音包(约 63.5MB,一次性,可离线使用,不上传录音),下载后即可正常发音。

下载完成后重新打开影伴,再点击“听发音”。同时检查设备音量、静音开关和浏览器是否允许播放声音。
+
+
03

积分、奖励与兑换

把好习惯变成看得见的成长:积分按孩子独立记录,奖励由家长确认。

+
自定义积分项在“积分”页为每个孩子创建加分或扣分项,也可以选择推荐模板。
加分与纠正记录一次行动加分;10 秒内可快捷撤销,历史纠错使用受控扣分。
期初积分切换到新账本时为孩子确认一次期初积分;旧积分只读保留,不自动导入。
奖励与兑换家长设置奖励和所需积分;孩子攒够后兑换,家长兑现后标记“已兑现”。
界面音效完成行动、获得积分、再试一次、扣分和兑现会播放音效,可在“设置”页逐项开关。
+
+
-
03

家庭空间和同步

家庭空间是统一入口,学习记录按孩子分别同步和保存。

+
04

家庭空间和同步

家庭空间是统一入口,学习记录按孩子分别同步和保存。

家庭维度管理家庭名称、孩子档案和当前选择。
孩子维度每个孩子的打卡、积分和绘本记录分别同步。
看同步状态进入家庭空间可查看家庭内最近同步时间。
-
04

安装到主屏幕,打开更方便

影伴是网页应用,不需要从陌生渠道下载 APK 或安装包。

+
05

安装到主屏幕,打开更方便

影伴是网页应用,不需要从陌生渠道下载 APK 或安装包。

iPhone / iPadSafari 打开影伴 → 分享 → 添加到主屏幕。
AndroidChrome 打开影伴 → 菜单 ⋮ → 添加到主屏幕。
电脑Chrome 或 Edge 地址栏右侧点击安装图标,或使用浏览器菜单“安装影伴”。
@@ -1192,10 +1624,85 @@ function checkinBtn(mod,label){ return ``; } +/* ========================================================= + 渲染:音效设置(设备级,仅本机) + ========================================================= */ +function renderSettings(){ + const main = el("main"); main.innerHTML=""; + main.appendChild(modTitle("settings","音效设置")); + const settings = soundEffects.getSettings(); + main.appendChild($(` +
+

${icon("volume")} 音效总开关与音量

+
+ 启用界面音效 + +
+
+ 总音量 ${Math.round(settings.volume*100)}% + +
+
+ `)); + const eventsCard = $(`

${icon("list")} 事件音效

`); + main.appendChild(eventsCard); + const list = eventsCard.querySelector(".sound-events"); + for (const key of SOUND_EVENT_KEYS) { + const def = SOUND_EVENTS[key]; + const eventSettings = settings.events[key]; + const variantOptions = Object.entries(def.variants).map(([variantKey, variant]) => + `` + ).join(""); + list.appendChild($(` +
+
+ ${def.label} + +
+
+ + +
+
+ `)); + } + main.appendChild($(` +
+ +
恢复为默认的总开关、音量与每个事件的变体选择。
+
+ `)); + main.appendChild($(``)); + + el("snd-master").onclick = () => { + soundEffects.setEnabled(!soundEffects.getSettings().enabled); + renderSettings(); + }; + el("snd-volume").oninput = (event) => { + soundEffects.setVolume(Number(event.target.value) / 100); + const label = el("snd-volume-label"); + if (label) label.textContent = `总音量 ${Math.round(soundEffects.getSettings().volume*100)}%`; + }; + el("snd-reset").onclick = () => { + soundEffects.resetDefaults(); + renderSettings(); + }; + main.querySelectorAll("[data-event-enable]").forEach((button) => button.onclick = () => { + soundEffects.setEventEnabled(button.dataset.eventEnable, !soundEffects.getSettings().events[button.dataset.eventEnable].enabled); + renderSettings(); + }); + main.querySelectorAll("[data-event-variant]").forEach((select) => select.onchange = () => { + soundEffects.setEventVariant(select.dataset.eventVariant, select.value); + renderSettings(); + }); + main.querySelectorAll("[data-event-preview]").forEach((button) => button.onclick = () => { + soundEffects.preview(button.dataset.eventPreview); + }); +} + /* ========================================================= 导航切换 ========================================================= */ -let CURRENT_MOD = "home"; function switchMod(mod){ CURRENT_MOD = mod; document.querySelectorAll(".navbtn").forEach(b=>{ @@ -1205,6 +1712,7 @@ function switchMod(mod){ else b.removeAttribute("aria-current"); }); if(mod==="home") renderHome(); + else if(mod==="learning") renderLearning(); else if(mod==="chinese") renderChinese(); else if(mod==="math") renderMath(); else if(mod==="english") renderEnglish(); @@ -1212,6 +1720,7 @@ function switchMod(mod){ else if(mod==="points") renderPoints(); else if(mod==="grow") renderGrow(); else if(mod==="guide") renderGuide(); + else if(mod==="settings") renderSettings(); // 绑定打卡按钮 el("main").querySelectorAll("[data-cmod]").forEach(btn=>{ btn.onclick=()=>{ @@ -1261,19 +1770,41 @@ window.learningDesk = { getState(){ return JSON.parse(JSON.stringify(store)); }, + getEnvelope(){ + return structuredClone({ + ...learningEnvelope, + schema_version: 2, + product_id: "shadow-mate", + learning: store, + }); + }, + async setScope(scope, options = {}){ + return setLearningScope(scope, options); + }, + getPendingState(){ + const pending = loadLearningStateEnvelope(localStorage, {}); + return structuredClone(pending); + }, replaceState(next, options = {}){ - store = transitionLearningState(store, { type: "STATE_REPLACED", state: next }); - if(options.persist) localStorage.setItem(STORE_KEY, JSON.stringify(store)); + store = transitionLearningState(store, { type: "STATE_REPLACED", state: learningStateFromEnvelope(next) }); + if(options.persist) persistLearningState(); switchMod(CURRENT_MOD); }, clearLocalData(){ const { reload = true } = arguments[0] || {}; localStorage.removeItem(STORE_KEY); + localStorage.removeItem(getLearningStateStorageKey({})); + localStorage.removeItem(getLearningStateStorageKey(learningEnvelope.scope || {})); if (reload) { window.location.reload(); return; } + learningEnvelope = loadLearningStateEnvelope(localStorage, {}); store = createLearningState(); switchMod(CURRENT_MOD); } }; + +void growthLoopController.hydrate().catch((error) => { + console.warn("Growth Loop 本地数据库初始化失败,已保持只读推荐项:", error); +}); diff --git a/src/cloud.js b/src/cloud.js index 3c28bbe..9048cf9 100644 --- a/src/cloud.js +++ b/src/cloud.js @@ -3,6 +3,11 @@ import { CLOUD_CONFIG } from "./config.js"; import { escapeHtml, formatAuthError, formatCloudError, passwordStrength, stateHasData, mergeObjects, mergeState, latestUpdatedAt, GRADE_OPTIONS, gradeLabel, gradeOptionsSelected } from "./lib.js"; import { runLockedAction } from "./action-lock.js"; import { icon } from "./icons.js"; +import { buildCloudSavePayload, normalizeCloudLearningState } from "./learning-cloud-state.js"; +import { createGrowthLoopTransport, fetchGrowthLoopSnapshot } from "./learning-growth-cloud.js"; +import { buildHouseholdExport } from "./learning-export.js"; +import { ACTIVITY_EVENT_TYPES, activityEventIdFor } from "./learning-analytics.js"; +import { mergeGrowthLoopSnapshot } from "./learning-growth-loop.js"; const PRODUCT_ID = CLOUD_CONFIG.productId; const AUTH_PRODUCT_NAME = "影伴 Shadow Mate"; @@ -29,6 +34,7 @@ const supabase = cloudEnabled }, }) : null; +const growthLoopTransport = cloudEnabled ? createGrowthLoopTransport({ client: supabase }) : null; let session = null; let memberships = []; @@ -51,6 +57,7 @@ let localResetInProgress = false; let lastAuthSessionKey = null; let passwordRecoveryActive = false; let passwordStatusCheckedForSession = null; +let growthLoopSyncTimer = null; const accountButton = document.querySelector("#accountButton"); const dialog = document.querySelector("#cloudDialog"); @@ -88,6 +95,67 @@ function showToast(message, duration = 2800) { toastTimer = setTimeout(hideToast, duration); } +async function loadGrowthLoopProfile(profile, { adoptPending = false } = {}) { + if (!profile || !window.growthLoop || !window.learningDesk) return; + const scope = { household_id: profile.household_id, profile_id: profile.id }; + await window.growthLoop.loadScope(scope, { adoptPending }); + await queueGrowthCloudActivity(profile, ACTIVITY_EVENT_TYPES.HOUSEHOLD_ACTIVATED, {}, "once"); + if (!growthLoopTransport) return; + const remote = await fetchGrowthLoopSnapshot(supabase, { + householdId: profile.household_id, + profileId: profile.id, + }); + if (window.growthLoop.getScope().profile_id !== profile.id) return; + // A not-yet-migrated environment must not overwrite local records with an + // empty fallback snapshot. Release-time migrations enable this path. + if (remote.errors.length) return; + await window.growthLoop.mergeRemote(remote.snapshot); + if (window.growthLoop.getScope().profile_id !== profile.id) return; + const report = await window.growthLoop.sync({ transport: growthLoopTransport }); + if (report.retryable || report.conflict || report.rejected) { + await queueGrowthCloudActivity(profile, ACTIVITY_EVENT_TYPES.SYNC_FAILED, { + source: "growth_loop_sync", + error_code: report.conflict ? "conflict" : report.rejected ? "rejected" : "retryable", + retryable: Boolean(report.retryable), + }, `sync:${new Date().toISOString().slice(0, 13)}`); + scheduleGrowthLoopSync(); + } +} + +async function queueGrowthCloudActivity(profile, event_type, payload = {}, bucket = "once", { ensureScope = false } = {}) { + if (!profile || !window.growthLoop) return null; + const scope = { household_id: profile.household_id, profile_id: profile.id }; + if (!scope.household_id || !scope.profile_id) return null; + try { + const currentScope = window.growthLoop.getScope(); + if (currentScope.household_id !== scope.household_id || currentScope.profile_id !== scope.profile_id) { + if (!ensureScope) return null; + await window.growthLoop.loadScope(scope, { adoptPending: false }); + } + if (window.growthLoop.getScope().profile_id !== scope.profile_id) return null; + const event = await window.growthLoop.queueActivity({ + event_type, + event_id: activityEventIdFor({ ...scope, event_type, bucket }), + payload, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + client_version: document.documentElement.dataset.version || null, + }); + return event; + } catch (error) { + console.warn("Growth Loop cloud activity event deferred:", error); + return null; + } +} + +function scheduleGrowthLoopSync() { + clearTimeout(growthLoopSyncTimer); + growthLoopSyncTimer = setTimeout(() => { + if (activeProfile) void loadGrowthLoopProfile(activeProfile).catch((error) => { + console.warn("Growth Loop cloud sync deferred:", error); + }); + }, 500); +} + function guardianConsentField() { return `