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 @@
@@ -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 @@
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($(`
${icon("construction")} 挖掘机小队长,今天也要加油哦!
-
今日已打卡 ${checkedToday}/${CHECKIN_MODULES.length} 个学习模块 · 语文今日新字「${hz[0]}」· 古诗《${poem.t}》· 英语单词「${en[0]}」
+
${learningOn
+ ? `今日已打卡 ${checkedToday}/${enabled.length} 个学习模块 · 语文今日新字「${hz[0]}」· 古诗《${poem.t}》· 英语单词「${en[0]}」`
+ : `学习包还没有启用,去「学习」页为这个孩子开启学习模块吧`}
`));
- 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) => `
`).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")} 期初积分已确认
+
+
+
${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")} 奖励兑换
+
+
+
${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(){
macOS
系统设置 → 辅助功能 → 朗读内容 → 系统声音 → 管理声音,下载 English 语音。
查看 Apple 安装说明 ↗
iPhone / iPad
设置 → 辅助功能 → 朗读内容 → 声音 → English,点击下载需要的声音。
查看 Apple 语音说明 ↗
Android
设置 → 无障碍 → 文字转语音输出 → 选择引擎和语言 → 安装语音数据 → English。
查看 Google 安装说明 ↗
-
国产 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 = $(`
`);
+ 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 `