Compare commits
5 Commits
08d9a2792a
...
feat-3.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 98dc338848 | |||
| fe1074e3f0 | |||
| 997042789d | |||
| 8de06ce66b | |||
| eddd7d75e2 |
@@ -2,3 +2,70 @@
|
||||
|
||||
直接使用view等原生代替canvas进行渲染,方便用户操作,只在最后导出图片时才渲染canvas。或者每次修改渲染隐藏canvas。
|
||||
以此减少交互和适配成本。
|
||||
|
||||
### 公开预览图配置
|
||||
|
||||
预览图和固定字体的公开地址统一配置在 `miniprogram/config/assets.ts`:
|
||||
|
||||
```typescript
|
||||
publicDomain: 'https://cdn.joeyone.cn',
|
||||
keyPrefix: 'doodle-assets/',
|
||||
```
|
||||
|
||||
首页预拉取、旧 pageConfig 缓存、新后端动态兜底,以及年龄推荐、收藏、下载记录和内容管理页的旧预览图,展示前统一通过 `resolvePreviewUrl` 转成七牛地址。旧云存储临时链接也会转换并去掉旧签名;其他环境、外部图片、本地图片和头像不参与此次预览图替换。分类页继续使用新后端返回的七牛地址。重新编译小程序即可使用,无需手工清除 pageConfig 缓存。
|
||||
|
||||
`cache/page-config.json` 中的 34 处预览引用已替换。兼容逻辑不修改线上云数据库、云函数或已上传的配置 JSON;旧数据由客户端读取时转换。新的预览上传由管理员上传接口写入七牛,再以 ready 资产 ID 发布元数据。
|
||||
|
||||
运行预览图回归测试:`npm run test:assets`;分类页回归:`npm run test:category`。
|
||||
|
||||
### 新后端静默登录与鉴权
|
||||
|
||||
启动及回到前台时通过 `utils/auth.ts` 非阻塞预热:`wx.login` 取一次性 code,调用 `POST /api/v1/auth/wechat/login`。首次使用 `start`,已有会话续期和 401 恢复使用 `resume`;明确无效 code 最多重新取一次。JWT 与用户摘要按 AppID、包类型、环境名称和接口地址分开保存,凭证到期前 60 秒按需续登。不缓存角色或微信 OpenID,不记录凭证、code 或完整登录响应。
|
||||
|
||||
统一请求层 `services/api-request.ts` 默认要求登录并携带 Bearer;公开分类与题型接口显式指定 `auth: 'none'`,不携带凭证、不触发登录。受保护请求遇到可恢复 401 时最多重登并重发一次,并发请求合并登录,迟到的旧 401 不会清掉新凭证。恢复后身份变化时不补发原业务写入。403、限流、服务错误或网络失败不触发自动重登链。
|
||||
|
||||
```typescript
|
||||
import { apiRequest } from './services/api-request';
|
||||
import { getUser, refreshUser } from './utils/auth';
|
||||
|
||||
// 获取新后端用户摘要;必要时静默登录。
|
||||
const user = await getUser();
|
||||
// 受保护的 GET /me,读取最新资料;不会先清掉有效凭证。
|
||||
await refreshUser();
|
||||
// 新增本人业务接口默认使用统一鉴权;公开接口显式 auth=none。
|
||||
await apiRequest('/me');
|
||||
```
|
||||
|
||||
账户不存在或禁用时停止自动恢复,不自动切回 start 重建账户;账户历史标记保留在同一个版本化缓存中。个人中心仅在需要用户主动重新开始时显示“开始使用账户”。登录失败不阻塞公开浏览、本地生成和导出。
|
||||
|
||||
用户资料、头像上传、收藏、下载记录和账户设置现已全部使用新后端及 `globalData.user`,旧登录兼容层已移除。界面保留少量 `_id` 展示字段映射,来源始终是新后端 ID,不再调用旧云业务。正式环境切换前仍需单独迁移历史用户及关联数据,线上 API 地址当前仍待配置。
|
||||
|
||||
验证:`npm run test:auth`;联合回归可执行 `node --import tsx --test tests/auth-api.test.mjs tests/category-api.test.mjs tests/preview-assets.test.mjs`。微信兑换在测试中使用模拟响应,真实一次性 code 需要在微信开发者工具或真机联调;没有加入绕过微信验证的测试登录接口。
|
||||
|
||||
### 业务接口切换
|
||||
|
||||
本地开发中的小程序运行代码已不再调用 `wx.cloud`;体验版与正式版首页继续使用 `wx.getBackgroundFetchData` 获取预拉取首屏,在后台使用新 `/page-config` 刷新。开发版跳过旧线上预拉取,使用所选接口环境自己的内容缓存及新后端;管理改动也会刷新首页。旧云端的预拉取地址、生成与发布链路、定时任务保持原样,新管理开关仅控制新后端的快照刷新任务。
|
||||
|
||||
当前已发布的线上版仍使用旧云开发接口。`cloudfunctions/` 中全部旧云函数源码、云开发配置及线上服务均须保留;本地开发版切换接口不代表线上旧版本已停止调用。未经另行明确安排,不删除、停用旧云函数,不改变其接口契约,不清理旧云数据库或云存储,也不迁移现有预拉取模式。
|
||||
|
||||
用户接口由 `services/member-api.ts` 与 `utils/auth.ts` 管理,管理页由 `services/admin-api.ts` 适配具名 REST 路由。图片通过本站 multipart 上传接口进入七牛,不暴露 AK/SK;同一次上传恢复沿用 UUID uploadId。管理页只能由数据库中当前角色为 admin 的账户操作,开发版不会绕过权限。
|
||||
|
||||
服务端路由、权限与数据迁移说明见 doodle-store 的 `docs/v3.5/小程序云接口迁移清单.md`。旧云端用户、收藏与下载历史本次未自动导入,正式切换前需要另行核对迁移。
|
||||
|
||||
### 开发版接口环境切换
|
||||
|
||||
入口:个人中心 → Debug 工具 → 接口环境。`develop` 可切换 `local / staging / production`,选择保存在本机,切换后自动重建页面返回首页,无需重新编译。开发版导航栏显示当前环境。`trial / release` 固定使用 `production`,忽略本机调试选择,配置层与切换入口均有校验。
|
||||
|
||||
地址在 `miniprogram/config/api.ts` 集中配置:
|
||||
|
||||
- `localBaseUrl`:本地开发接口。
|
||||
- `stagingBaseUrl`:未来测试接口。
|
||||
- `productionBaseUrl`:未来正式接口。
|
||||
|
||||
目前按要求,三个地址都暂填 `http://127.0.0.1:8800/api/v1`,客户端缓存和会话按环境隔离,但实际请求仍访问同一个本地后端和数据库。真机的 `127.0.0.1` 指向手机;调试时 local 需改为电脑局域网地址。部署时把 staging/production 占位地址改为对应 HTTPS 地址,不会因地址缺失自动回退 local。
|
||||
|
||||
切换会清空共享用户和内容状态,读取目标环境凭证,并强制从目标接口加载首页;原环境的缓存与会话保留。所有 API 响应按环境版本检查,即使快速 A→B→A,旧请求也不能覆盖当前数据。写请求(含登录等待及上传重试)未完成时禁止切换。目标接口不可用时提示连接失败并保留所选环境,不展示上一环境的内容。打印偏好继续共用。
|
||||
|
||||
本轮仅调整开发版数据来源;体验/正式包原预拉取模式、地址及全部旧云端服务保持原样。
|
||||
|
||||
环境切换回归:`node --import tsx --test tests/environment-switch.test.mjs`。
|
||||
|
||||
Vendored
+34
-34
@@ -52,7 +52,7 @@
|
||||
"ageBand": "3-5岁",
|
||||
"difficulty": "入门",
|
||||
"icon": "",
|
||||
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/math/number-coloring.jpg",
|
||||
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/math/number-coloring.jpg",
|
||||
"badge": "",
|
||||
"path": "/mathPages/mathDraw/mathDraw?id=number-coloring",
|
||||
"available": true
|
||||
@@ -65,7 +65,7 @@
|
||||
"ageBand": "4-6岁",
|
||||
"difficulty": "基础",
|
||||
"icon": "",
|
||||
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/math/number-sort.jpg",
|
||||
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/math/number-sort.jpg",
|
||||
"badge": "",
|
||||
"path": "/mathPages/mathDraw/mathDraw?id=number-sort",
|
||||
"available": true
|
||||
@@ -78,7 +78,7 @@
|
||||
"ageBand": "5-7岁",
|
||||
"difficulty": "进阶",
|
||||
"icon": "",
|
||||
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/puzzle/code-connect.jpg",
|
||||
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/puzzle/code-connect.jpg",
|
||||
"badge": "",
|
||||
"path": "/focusPages/focusDraw/focusDraw?id=code-connect",
|
||||
"available": true
|
||||
@@ -91,7 +91,7 @@
|
||||
"ageBand": "4-8岁",
|
||||
"difficulty": "基础",
|
||||
"icon": "",
|
||||
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/chinese/hanzi-sheet.jpg",
|
||||
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/chinese/hanzi-sheet.jpg",
|
||||
"badge": "",
|
||||
"path": "/chinesePages/handwritingSheet/handwritingSheet?id=hanzi-sheet",
|
||||
"available": true
|
||||
@@ -104,7 +104,7 @@
|
||||
"ageBand": "6-8岁",
|
||||
"difficulty": "基础",
|
||||
"icon": "",
|
||||
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/english/letter-tracing-daily-checkin.jpg",
|
||||
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/english/letter-tracing-daily-checkin.jpg",
|
||||
"badge": "",
|
||||
"path": "/englishPages/letterTracing/letterTracing?id=letter-tracing-daily-checkin",
|
||||
"available": true
|
||||
@@ -117,7 +117,7 @@
|
||||
"ageBand": "4-6岁",
|
||||
"difficulty": "入门",
|
||||
"icon": "",
|
||||
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/puzzle/dot-connect.jpg",
|
||||
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/puzzle/dot-connect.jpg",
|
||||
"badge": "",
|
||||
"path": "/focusPages/focusDraw/focusDraw?id=dot-connect",
|
||||
"available": true
|
||||
@@ -132,7 +132,7 @@
|
||||
"ageBand": "4-6岁",
|
||||
"difficulty": "基础",
|
||||
"icon": "",
|
||||
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/math/number-sort.jpg",
|
||||
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/math/number-sort.jpg",
|
||||
"badge": "",
|
||||
"path": "/mathPages/mathDraw/mathDraw?id=number-sort",
|
||||
"available": true
|
||||
@@ -145,7 +145,7 @@
|
||||
"ageBand": "3-5岁",
|
||||
"difficulty": "入门",
|
||||
"icon": "",
|
||||
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/math/number-coloring.jpg",
|
||||
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/math/number-coloring.jpg",
|
||||
"badge": "",
|
||||
"path": "/mathPages/mathDraw/mathDraw?id=number-coloring",
|
||||
"available": true
|
||||
@@ -158,7 +158,7 @@
|
||||
"ageBand": "5-7岁",
|
||||
"difficulty": "进阶",
|
||||
"icon": "",
|
||||
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/math/make-ten.jpg",
|
||||
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/math/make-ten.jpg",
|
||||
"badge": "",
|
||||
"path": "/mathPages/mathDraw/mathDraw?id=make-ten",
|
||||
"available": true
|
||||
@@ -171,7 +171,7 @@
|
||||
"ageBand": "5-7岁",
|
||||
"difficulty": "进阶",
|
||||
"icon": "",
|
||||
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/puzzle/code-connect.jpg",
|
||||
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/puzzle/code-connect.jpg",
|
||||
"badge": "",
|
||||
"path": "/focusPages/focusDraw/focusDraw?id=code-connect",
|
||||
"available": true
|
||||
@@ -184,7 +184,7 @@
|
||||
"ageBand": "3-5岁",
|
||||
"difficulty": "入门",
|
||||
"icon": "",
|
||||
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/math/counting-matching.jpg",
|
||||
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/math/counting-matching.jpg",
|
||||
"badge": "",
|
||||
"path": "/mathPages/mathDraw/mathDraw?id=counting-matching",
|
||||
"available": true
|
||||
@@ -205,7 +205,7 @@
|
||||
"ageBand": "4-6岁",
|
||||
"difficulty": "基础",
|
||||
"icon": "",
|
||||
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/math/number-sort.jpg",
|
||||
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/math/number-sort.jpg",
|
||||
"badge": "",
|
||||
"path": "/mathPages/mathDraw/mathDraw?id=number-sort",
|
||||
"available": true
|
||||
@@ -218,7 +218,7 @@
|
||||
"ageBand": "3-5岁",
|
||||
"difficulty": "入门",
|
||||
"icon": "",
|
||||
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/math/number-coloring.jpg",
|
||||
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/math/number-coloring.jpg",
|
||||
"badge": "",
|
||||
"path": "/mathPages/mathDraw/mathDraw?id=number-coloring",
|
||||
"available": true
|
||||
@@ -231,7 +231,7 @@
|
||||
"ageBand": "5-7岁",
|
||||
"difficulty": "进阶",
|
||||
"icon": "",
|
||||
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/math/make-ten.jpg",
|
||||
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/math/make-ten.jpg",
|
||||
"badge": "",
|
||||
"path": "/mathPages/mathDraw/mathDraw?id=make-ten",
|
||||
"available": true
|
||||
@@ -244,7 +244,7 @@
|
||||
"ageBand": "5-7岁",
|
||||
"difficulty": "入门",
|
||||
"icon": "",
|
||||
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/math/number-decompose.jpg",
|
||||
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/math/number-decompose.jpg",
|
||||
"badge": "",
|
||||
"path": "/mathPages/mathDraw/mathDraw?id=number-decompose",
|
||||
"available": true
|
||||
@@ -257,7 +257,7 @@
|
||||
"ageBand": "3-5岁",
|
||||
"difficulty": "入门",
|
||||
"icon": "",
|
||||
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/math/number-find.jpg",
|
||||
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/math/number-find.jpg",
|
||||
"badge": "",
|
||||
"path": "/mathPages/mathDraw/mathDraw?id=number-find",
|
||||
"available": true
|
||||
@@ -278,7 +278,7 @@
|
||||
"ageBand": "4-6岁",
|
||||
"difficulty": "基础",
|
||||
"icon": "",
|
||||
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/puzzle/position-coloring.jpg",
|
||||
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/puzzle/position-coloring.jpg",
|
||||
"badge": "",
|
||||
"path": "/focusPages/focusDraw/focusDraw?id=position-coloring",
|
||||
"available": true
|
||||
@@ -291,7 +291,7 @@
|
||||
"ageBand": "3-6岁",
|
||||
"difficulty": "入门",
|
||||
"icon": "",
|
||||
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/puzzle/line-recognition.jpg",
|
||||
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/puzzle/line-recognition.jpg",
|
||||
"badge": "",
|
||||
"path": "/focusPages/focusDraw/focusDraw?id=line-recognition",
|
||||
"available": true
|
||||
@@ -304,7 +304,7 @@
|
||||
"ageBand": "5-7岁",
|
||||
"difficulty": "进阶",
|
||||
"icon": "",
|
||||
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/puzzle/grid-reasoning.jpg",
|
||||
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/puzzle/grid-reasoning.jpg",
|
||||
"badge": "",
|
||||
"path": "/focusPages/focusDraw/focusDraw?id=grid-reasoning",
|
||||
"available": true
|
||||
@@ -317,7 +317,7 @@
|
||||
"ageBand": "5-7岁",
|
||||
"difficulty": "进阶",
|
||||
"icon": "",
|
||||
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/puzzle/code-connect.jpg",
|
||||
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/puzzle/code-connect.jpg",
|
||||
"badge": "",
|
||||
"path": "/focusPages/focusDraw/focusDraw?id=code-connect",
|
||||
"available": true
|
||||
@@ -330,7 +330,7 @@
|
||||
"ageBand": "4-6岁",
|
||||
"difficulty": "基础",
|
||||
"icon": "",
|
||||
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/puzzle/shape-symbol.jpg",
|
||||
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/puzzle/shape-symbol.jpg",
|
||||
"badge": "",
|
||||
"path": "/focusPages/focusDraw/focusDraw?id=shape-symbol",
|
||||
"available": true
|
||||
@@ -351,7 +351,7 @@
|
||||
"ageBand": "5-7岁",
|
||||
"difficulty": "入门",
|
||||
"icon": "",
|
||||
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/pinyin/pinyin-tracing.jpg",
|
||||
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/pinyin/pinyin-tracing.jpg",
|
||||
"badge": "",
|
||||
"path": "/pinyinPages/pinyinDictation/pinyinDictation?id=pinyin-tracing",
|
||||
"available": true
|
||||
@@ -364,7 +364,7 @@
|
||||
"ageBand": "6-8岁",
|
||||
"difficulty": "基础",
|
||||
"icon": "",
|
||||
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/pinyin/pinyin-dictation.jpg",
|
||||
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/pinyin/pinyin-dictation.jpg",
|
||||
"badge": "",
|
||||
"path": "/pinyinPages/pinyinDictation/pinyinDictation?id=pinyin-dictation",
|
||||
"available": true
|
||||
@@ -377,7 +377,7 @@
|
||||
"ageBand": "5-7岁",
|
||||
"difficulty": "入门",
|
||||
"icon": "",
|
||||
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/pinyin/pinyin-tracing-v2.jpg",
|
||||
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/pinyin/pinyin-tracing-v2.jpg",
|
||||
"badge": "",
|
||||
"path": "/pinyinPages/pinyinDictation/pinyinDictation?id=pinyin-tracing-v2",
|
||||
"available": true
|
||||
@@ -390,7 +390,7 @@
|
||||
"ageBand": "6-8岁",
|
||||
"difficulty": "基础",
|
||||
"icon": "",
|
||||
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/pinyin/pinyin-dictation-v2.jpg",
|
||||
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/pinyin/pinyin-dictation-v2.jpg",
|
||||
"badge": "",
|
||||
"path": "/pinyinPages/pinyinDictation/pinyinDictation?id=pinyin-dictation-v2",
|
||||
"available": true
|
||||
@@ -403,7 +403,7 @@
|
||||
"ageBand": "6-8岁",
|
||||
"difficulty": "基础",
|
||||
"icon": "",
|
||||
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/pinyin/pinyin-daily.jpg",
|
||||
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/pinyin/pinyin-daily.jpg",
|
||||
"badge": "",
|
||||
"path": "/pinyinPages/pinyinDictation/pinyinDictation?id=pinyin-daily",
|
||||
"available": true
|
||||
@@ -424,7 +424,7 @@
|
||||
"ageBand": "3-6岁",
|
||||
"difficulty": "入门",
|
||||
"icon": "",
|
||||
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/chinese/word-coloring-grid.jpg",
|
||||
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/chinese/word-coloring-grid.jpg",
|
||||
"badge": "",
|
||||
"path": "/chinesePages/wordColoring/wordColoring?id=word-coloring-grid",
|
||||
"available": true
|
||||
@@ -437,7 +437,7 @@
|
||||
"ageBand": "3-6岁",
|
||||
"difficulty": "入门",
|
||||
"icon": "",
|
||||
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/chinese/word-coloring-find.jpg",
|
||||
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/chinese/word-coloring-find.jpg",
|
||||
"badge": "",
|
||||
"path": "/chinesePages/wordColoring/wordColoring?id=word-coloring-find",
|
||||
"available": true
|
||||
@@ -450,7 +450,7 @@
|
||||
"ageBand": "4-8岁",
|
||||
"difficulty": "基础",
|
||||
"icon": "",
|
||||
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/chinese/hanzi-sheet.jpg",
|
||||
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/chinese/hanzi-sheet.jpg",
|
||||
"badge": "",
|
||||
"path": "/chinesePages/handwritingSheet/handwritingSheet?id=hanzi-sheet",
|
||||
"available": true
|
||||
@@ -471,7 +471,7 @@
|
||||
"ageBand": "6-8岁",
|
||||
"difficulty": "基础",
|
||||
"icon": "",
|
||||
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/english/letter-tracing-daily-checkin.jpg",
|
||||
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/english/letter-tracing-daily-checkin.jpg",
|
||||
"badge": "",
|
||||
"path": "/englishPages/letterTracing/letterTracing?id=letter-tracing-daily-checkin",
|
||||
"available": true
|
||||
@@ -484,7 +484,7 @@
|
||||
"ageBand": "4-7岁",
|
||||
"difficulty": "入门",
|
||||
"icon": "",
|
||||
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/english/letter-tracing-single.jpg",
|
||||
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/english/letter-tracing-single.jpg",
|
||||
"badge": "",
|
||||
"path": "/englishPages/letterTracing/letterTracing?id=letter-tracing-single",
|
||||
"available": true
|
||||
@@ -497,7 +497,7 @@
|
||||
"ageBand": "5-7岁",
|
||||
"difficulty": "入门",
|
||||
"icon": "",
|
||||
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/english/letter-tracing-case-pairing.jpg",
|
||||
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/english/letter-tracing-case-pairing.jpg",
|
||||
"badge": "",
|
||||
"path": "/englishPages/letterTracing/letterTracing?id=letter-tracing-case-pairing",
|
||||
"available": true
|
||||
@@ -510,7 +510,7 @@
|
||||
"ageBand": "5-8岁",
|
||||
"difficulty": "基础",
|
||||
"icon": "",
|
||||
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/english/letter-tracing-half.jpg",
|
||||
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/english/letter-tracing-half.jpg",
|
||||
"badge": "",
|
||||
"path": "/englishPages/letterTracing/letterTracing?id=letter-tracing-half",
|
||||
"available": true
|
||||
@@ -523,7 +523,7 @@
|
||||
"ageBand": "5-7岁",
|
||||
"difficulty": "入门",
|
||||
"icon": "",
|
||||
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/english/letter-tracing-two-column.jpg",
|
||||
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/english/letter-tracing-two-column.jpg",
|
||||
"badge": "",
|
||||
"path": "/englishPages/letterTracing/letterTracing?id=letter-tracing-two-column",
|
||||
"available": true
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Doodle / 涂鸦丫 - PC Web 端技术预案
|
||||
|
||||
> **2026-10-12 决策更新**:本文保留为历史预案,DAU 启动门槛和浏览器承载完整生成器不作为当前决策。现有后端已决定迁移到自有服务器,见 [迁移方案(doodle-store)](../../doodle-store/docs/云服务端迁移方案.md);Web 范围和私有服务端生成见 [v3.5 策略](./v3.5/Web与服务端生成策略.md)。
|
||||
|
||||
> 状态:**远期规划**(当前聚焦小程序,暂不启动 Web 开发)
|
||||
> 最后更新:2026-03-24
|
||||
> 配套文档:[技术架构设计文档](./技术架构设计文档.md) | [产品设计文档](./产品设计文档.md)
|
||||
|
||||
+7
-4
@@ -1,16 +1,17 @@
|
||||
# Doodle Mini v3.5 规划索引
|
||||
|
||||
> 状态:待评审
|
||||
> 状态:产品规划待评审;云服务端迁移方向已确认,待实施
|
||||
>
|
||||
> 更新日期:2026-09-24
|
||||
> 更新日期:2026-10-12
|
||||
|
||||
v3.5 的目标不是继续堆叠题型,而是把「生成一张练习纸」升级为家长可持续使用的家庭打印学习服务,并为未来 Web 内容平台建立服务端生成能力。
|
||||
|
||||
## 本阶段的三项决策
|
||||
## 本阶段的四项决策
|
||||
|
||||
1. **小程序仍是当前主产品。** 优先改善家长首次找到内容、完成打印、再次回来使用的体验。
|
||||
2. **创意涂色纸是下一项内容型 MVP。** 它覆盖低龄儿童、视觉传播力强,也最适合作为服务端生成的首个试点。
|
||||
3. **现在开始建设 Web 底座,不启动完整 Web 平台。** 新增高价值题型优先接入服务端生成;既有小程序题型不做一次性迁移。
|
||||
4. **现有业务后端迁移到自有云服务器。** 采用统一 API、MySQL 和七牛文件存储;CloudBase 只在切换期承担旧版兼容。业务后端迁移与存量 Canvas 服务端重写分别实施。
|
||||
|
||||
## 文档
|
||||
|
||||
@@ -19,11 +20,13 @@ v3.5 的目标不是继续堆叠题型,而是把「生成一张练习纸」升
|
||||
| [产品路线图](./产品路线图.md) | 用户问题、功能优先级、阶段目标与验收指标 |
|
||||
| [待办与里程碑](./待办与里程碑.md) | 可执行的产品、设计、研发待办与依赖关系 |
|
||||
| [Web 与服务端生成策略](./Web与服务端生成策略.md) | Web 试点范围、服务端渲染边界及技术演进决策 |
|
||||
| [云服务端迁移方案(doodle-store)](../../../doodle-store/docs/云服务端迁移方案.md) | 在新项目统一维护:现有功能/API 映射、数据与文件迁移、实施待办、切换回滚、验收及 AI 辅助工时预算 |
|
||||
| [埋点事件字典](./埋点事件字典.md) | 现有事件和成功保存口径;后端迁移继续沿用 |
|
||||
|
||||
## 不在 v3.5 范围内
|
||||
|
||||
- 完整的 Kiddoleap 风格 Web 资源平台、全量 SEO 内容站。
|
||||
- 一次性迁移所有云数据库、云函数和既有 Canvas 题型到独立服务器。
|
||||
- 在业务后端迁移中同时重写全部既有 Canvas 题型。
|
||||
- 社区晒图、公开儿童信息、复杂学习报告。
|
||||
- 会员支付、PDF 多页合并等商业化闭环;仅预留数据与服务端能力。
|
||||
|
||||
|
||||
+18
-19
@@ -1,8 +1,10 @@
|
||||
# Doodle Mini v3.5 Web 与服务端生成策略
|
||||
|
||||
> 状态:架构决策草案
|
||||
> 状态:迁移到自有云服务器的方向已确认;Web 与绘图试点待实施
|
||||
>
|
||||
> 更新日期:2026-09-24
|
||||
> 更新日期:2026-10-12
|
||||
|
||||
> 现有业务后端的迁移范围、切换顺序和验收以[云服务端迁移方案(doodle-store)](../../../doodle-store/docs/云服务端迁移方案.md)为准。本文说明迁移后 Web 与服务端生成的演进,不再以继续保留 CloudBase 业务后端为目标。
|
||||
|
||||
## 1. 决策结论
|
||||
|
||||
@@ -10,9 +12,7 @@
|
||||
|
||||
Web 是长期的搜索获客、内容沉淀、电脑打印和教师/机构扩展渠道;小程序仍是当前的微信内打开、分享、每日推荐和家庭留存主产品。
|
||||
|
||||
因此,v3.5 采用:
|
||||
|
||||
> **70% 投入小程序核心体验,30% 投入 Web 与服务端生成试点。**
|
||||
v3.5 将现有业务后端迁移作为独立实施任务,小程序体验和小规模 Web/服务端生成按里程碑推进,不预设固定投入比例。
|
||||
|
||||
不等待小程序“达到很大用户量”才准备 Web,因为搜索内容、服务端渲染和内容生产机制都需要时间积累;但也不在内容库、内容生产和核心留存未验证时建设完整 Web 平台。
|
||||
|
||||
@@ -102,28 +102,27 @@ favoriteService.toggle(worksheetId)
|
||||
historyService.list()
|
||||
```
|
||||
|
||||
小程序适配器可以暂时使用云函数,Web 使用 HTTP;上层页面不感知具体实现。
|
||||
新版小程序与后续 Web 统一使用 HTTP 业务 API;旧版云函数只在过渡期转发同一业务实现。上层页面不感知数据库与文件存储实现。
|
||||
|
||||
## 5. 后端演进路径
|
||||
|
||||
### 阶段 A:现在至创意涂色 MVP
|
||||
### 阶段 A:现有业务后端迁移
|
||||
|
||||
- 保留 CloudBase 数据库、存储和既有云函数。
|
||||
- 新建可通过 HTTP 调用的 Render API;可先使用 CloudBase 云托管容器,无需立即维护独立云服务器。
|
||||
- 私有素材存放在不可公开枚举的对象存储路径;最终文件通过短期 URL 交付。
|
||||
- 只迁移创意涂色纸,验证延迟、成本和保护边界。
|
||||
- 按专项方案迁移现有云函数、数据库、文件和定时任务到自有腾讯云服务器 + MySQL + 七牛。
|
||||
- 新版小程序经业务服务层调用统一 HTTP API;短期兼容桥照顾旧正式版本,只有一个业务写入源。
|
||||
- 先保持现有生成与保存行为,再验收数据关联、文件、计数和切换;不同时重写所有绘图代码。
|
||||
|
||||
### 阶段 B:Web 小规模试点
|
||||
### 阶段 B:服务端生成样板
|
||||
|
||||
- Web 调用统一 HTTP API;小程序继续调用业务服务层。
|
||||
- 新题型优先服务端生成;旧题型按商业价值与迁移成本逐步处理。
|
||||
- 增加异步渲染任务、缓存、批量导出和基础监控。
|
||||
- 在同一后端增加受控 Render API,以创意涂色或一个简单现有题型验证生成、字体、延迟和文件交付。
|
||||
- 原始素材和模板采用私有存储;最终文件通过受控地址交付,不依赖隐藏对象路径保护素材。
|
||||
- 根据实际任务量增加缓存/异步处理,不预先搭建独立队列平台。
|
||||
|
||||
### 阶段 C:扩张决策
|
||||
### 阶段 C:Web 小规模试点与扩张
|
||||
|
||||
当 Web 需要 SEO、会员支付、复杂搜索、教师版、批量 PDF、AI/异步任务,或 CloudBase 成本与能力成为瓶颈时,再迁核心 API 到 NestJS + MySQL/对象存储。
|
||||
|
||||
迁移的是业务 API 和数据,不是让前端重新实现一套生成逻辑。
|
||||
- Web 复用统一 API 和内容源,内容页面使用适当的预生成或服务端渲染,生成规则留在服务器。
|
||||
- 只建设下节规定的 MVP;旧题型按价值与实际适配成本逐步接入,批量 PDF/会员另行评估。
|
||||
- 私有生成进程可按资源需求拆分,但不另建用户、收藏、内容数据库或第二套业务规则。
|
||||
|
||||
## 6. 安全、成本与体验基线
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# 微信云存储资源目录清单
|
||||
|
||||
更新:2026-10-16。依据 doodle-mini 的 miniprogram 与 cloudfunctions 源码、路径构造函数及仓库内缓存分析,不是云端存储桶的实时文件列表。本次未下载、上传、迁移或删除云文件。
|
||||
|
||||
## 目录树
|
||||
|
||||
以下为代码涉及的云存储相对路径,仅列到最后一级目录,共 9 个末级目录:
|
||||
|
||||
```text
|
||||
/
|
||||
├── assets/
|
||||
│ ├── fonts/
|
||||
│ └── previews/
|
||||
│ ├── chinese/
|
||||
│ ├── english/
|
||||
│ ├── math/
|
||||
│ ├── papers/
|
||||
│ ├── pinyin/
|
||||
│ └── puzzle/
|
||||
├── content/
|
||||
└── user-avatars/
|
||||
```
|
||||
|
||||
对象存储中的“目录”是文件 key 的前缀。此处表示代码引用或能够写入的路径,不保证每个目录已经存在文件,也不能据此排除云端的历史/未引用目录。
|
||||
|
||||
## 用途与代码依据
|
||||
|
||||
| 最后一级目录 | 用途与确认范围 | 代码依据 |
|
||||
| ------------------------ | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| assets/previews/chinese/ | 语文、控笔、识字等题型预览;首页静态项目也直接引用此路径 | [首页数据](../../miniprogram/pages/home/home.data.ts)、[语文发布配置](../../miniprogram/chinesePages/wordColoring/wordColoring.config.ts) |
|
||||
| assets/previews/english/ | 英文字母题型预览;首页静态项目直接引用此路径 | [首页数据](../../miniprogram/pages/home/home.data.ts)、[英语发布配置](../../miniprogram/englishPages/letterTracing/letterTracing.config.ts) |
|
||||
| assets/previews/math/ | 数学题型预览;由发布路径构造,旧页面配置缓存也有文件引用 | [数学发布配置](../../miniprogram/mathPages/mathDraw/mathDraw.config.ts)、[路径构造](../../miniprogram/utils/debugPublish.ts) |
|
||||
| assets/previews/papers/ | 作业纸预览的动态上传路径;有发布配置,不能仅凭代码断言云端已有文件 | [作业纸发布配置](../../miniprogram/papersPages/paperSheet/paperSheet.config.ts)、[路径构造](../../miniprogram/utils/debugPublish.ts) |
|
||||
| assets/previews/pinyin/ | 拼音题型预览;首页静态项目直接引用此路径 | [首页数据](../../miniprogram/pages/home/home.data.ts)、[拼音发布配置](../../miniprogram/pinyinPages/pinyinDictation/pinyinDictation.config.ts) |
|
||||
| assets/previews/puzzle/ | 益智题型预览;由发布路径构造,旧页面配置缓存也有文件引用 | [益智发布配置](../../miniprogram/focusPages/focusDraw/focusDraw.config.ts)、[路径构造](../../miniprogram/utils/debugPublish.ts) |
|
||||
| assets/fonts/ | ToneOZ 拼音字体;通过 cloud fileID 兑换临时链接,再加载字体 | [字体配置](../../miniprogram/core/font/fontProfiles.ts)、[字体加载](../../miniprogram/core/font/fontLoader.ts) |
|
||||
| content/ | 首页展示配置 JSON;内容构建和定时刷新云函数上传到 content/page-config.json | [内容构建](../../cloudfunctions/pageContentBuild/index.js)、[定时刷新](../../cloudfunctions/homeAutoRefresh/index.js) |
|
||||
| user-avatars/ | 用户头像;按用户 ID 和时间生成文件名上传,文件数量须另查用户数据或云端清单 | [头像上传](../../miniprogram/pages/profile/profile.ts) |
|
||||
|
||||
预览图统一写入规则为 `assets/previews/${category}/${id}.jpg`,由页面发布元数据传入 category,再通过 `base/pageMixin.ts` 调用 wx.cloud.uploadFile。扫描发布配置得到 chinese、english、math、papers、pinyin、puzzle 六个分类。类型或分类导航里存在 craft,不等于存在 craft 云预览目录,本清单不据此添加。
|
||||
|
||||
cloudfunctions 中另有两处实际上传调用,均写 content/page-config.json。头像上传是一个调用点,题型预览上传是一个公共调用点;未发现其他业务云文件上传路径。旧云数据库 previewImg/avatarUrl 字段可以保存代码未硬编码的 fileID,因此运行时数据仍可能增加清单以外的引用。
|
||||
|
||||
## 已走 CDN 的远程资源
|
||||
|
||||
以下引用使用 `https://cdn.joeyone.cn`,不是当前源码中的微信 cloud fileID,不混入上述云存储目录。仅凭域名不能确认底层存储服务商或 bucket,也不假定需要重复迁移。
|
||||
|
||||
| CDN 路径 | 内容 | 代码依据 |
|
||||
| ----------------- | -------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| doodle/fonts/ | 六个字体文件,包含手写、描红和数字字体 | [字体配置](../../miniprogram/core/font/fontProfiles.ts) |
|
||||
| doodle/hanzi/ | 汉字笔画及读音 JSON | [汉字数据加载](../../miniprogram/chinesePages/shared/getWordsSvgJson.ts) |
|
||||
| doodle/share-img/ | 小程序分享图片 | [分享配置](../../miniprogram/config/config.ts) |
|
||||
|
||||
小程序包内的 assets/imgs、assets/tabBar、iconfont 等本地文件不属于云存储迁移范围;不能因为本地路径也以 assets 开头,就当作微信云文件。
|
||||
|
||||
## 后续快速同步建议
|
||||
|
||||
先做一次批量迁移,稳定后再考虑持续同步;当前仅为方案,未执行:
|
||||
|
||||
1. 生成文件级清单:题型预览从旧 worksheets.previewImg/新后端 assets.source_ref 获取,头像从用户当前引用获取,补上已确认的字体和配置路径。代码目录用于限定范围,不能替代实际文件枚举。旧缓存只作线索,不作为当前有效引用的唯一依据。
|
||||
2. 按清单从原云环境授权下载到本地备份;记录相对路径、MIME、大小和 SHA-256,可失败重试。不将临时签名 URL 保存为长期资源地址。
|
||||
3. 上传七牛并保留可追踪路径映射。例如公共资源可采用统一前缀加原相对路径,或按资产 ID/内容摘要生成不可变 key;目标空间、前缀和同名文件处理在执行前明确。头像按现有后端设计使用私有空间,不能与公共预览一并公开。
|
||||
4. 回读校验后再更新引用:51 张已导入预览保留现有 assetId/preview_asset_id,回填七牛对象信息并标记 ready;字体更新配置 URL,头像按用户绑定并经服务端签名访问。
|
||||
5. 首页配置 JSON 含嵌套预览地址,需在迁移图片后重建或替换这些引用,并处理小程序缓存。仅复制 JSON 文件不会自动转换其中的 cloud fileID。
|
||||
6. 同时切换后续上传、发布和定时任务的目标,否则旧代码继续往微信云存储写入,七牛副本会再次落后。切换核对完成前保留源文件。
|
||||
|
||||
已有实施调研见 [云存储图片迁移七牛调研](../../../doodle-store/docs/v3.5/云存储图片迁移七牛调研.md)。本地未发现可直接使用的 tcb、cloudbase、qshell 命令;已有小程序 AppSecret 不等于源环境存储管理权限或七牛凭据。后续实施需确认可读源文件的授权方式,以及目标七牛 bucket、区域、HTTPS 域名和服务端凭据,不把凭据写入文档或前端。
|
||||
+14
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
> 状态:待排期
|
||||
>
|
||||
> 更新日期:2026-09-24
|
||||
> 更新日期:2026-10-12
|
||||
|
||||
## 0. 排期原则
|
||||
|
||||
@@ -10,6 +10,19 @@
|
||||
|
||||
以下周期为相对迭代,不绑定具体人天;每个里程碑完成后应先检查数据,再决定是否扩大范围。
|
||||
|
||||
现有业务后端已决定迁移到自有云服务器。该专项按下列 S0–S5 执行,业务后端迁移与存量 Canvas 重写分别排期。详细清单、工时假设及切换步骤见[云服务端迁移方案(doodle-store)](../../../doodle-store/docs/云服务端迁移方案.md)。
|
||||
|
||||
## 服务端迁移专项(S0–S5)
|
||||
|
||||
- [ ] S0:盘点实际线上集合、云文件、服务器、域名和权限,保留原始备份及功能样本。
|
||||
- [ ] S1:完成统一 API/数据库骨架、微信登录、题型读取、收藏/下载幂等事务。
|
||||
- [ ] S2:补齐现有管理接口、首页配置/推荐、七牛文件上传与定时任务。
|
||||
- [ ] S3:完成小程序 HTTP 接入、试迁审计、旧版兼容桥、恢复演练和体验版真机验收。
|
||||
- [ ] S4:冻结旧写入,最终导入后切换唯一写入源,再发布新版并核对线上行为。
|
||||
- [ ] S5:旧版及旧文件依赖达到退场条件后,归档并完全退出 CloudBase。
|
||||
|
||||
服务端生成样板在 S3 验收基础上单独实施;完整 Web 与新产品功能不计入这次现有功能迁移的完成标准。
|
||||
|
||||
## M0:体验与数据基线(第 1 个迭代)
|
||||
|
||||
### 产品与研发
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# 小程序分类页接入新后端
|
||||
|
||||
更新:2026-10-16。分类页已由旧 CloudBase 查询切换为 doodle-store 的公开 HTTP 接口;首页、微信登录、收藏、下载计数及其他页面仍使用原有云服务,本次不做双写或隐式数据同步。
|
||||
|
||||
## 全局配置
|
||||
|
||||
入口:`miniprogram/config/api.ts`。
|
||||
|
||||
| 配置 | 当前值 | 用途 |
|
||||
| ---------------------- | ---------------------------- | --------------------------------------------- |
|
||||
| developmentEnvironment | local | 开发版选择 local 或 online |
|
||||
| localBaseUrl | http://127.0.0.1:8800/api/v1 | 开发者工具连接本机后端 |
|
||||
| onlineBaseUrl | 空串 | 线上地址待确定;填写完整 HTTPS 地址及 /api/v1 |
|
||||
| timeout | 10000 | 请求超时,毫秒 |
|
||||
|
||||
环境通过 `wx.getAccountInfoSync().miniProgram.envVersion` 获取。develop 默认走 local,可以修改 developmentEnvironment 联调线上;trial、release 和未知版本只走 online。线上地址为空时明确显示“请先配置线上接口地址”,不会回退本地或云函数。该配置不保存 AppSecret、JWT 密钥或数据库凭据。
|
||||
|
||||
新后端请求统一使用 `services/api-request.ts`,分别校验 HTTP 状态、业务 code 和响应结构;`services/content-api.ts` 封装分类、完整题型分页与详情。旧 `utils/http.ts` 和旧云请求保留,以免影响其他页面。
|
||||
|
||||
## 如何切换接口环境
|
||||
|
||||
修改 [miniprogram/config/api.ts](../../miniprogram/config/api.ts):
|
||||
|
||||
```ts
|
||||
export const apiConfig = {
|
||||
developmentEnvironment: 'local' as ApiEnvironment,
|
||||
localBaseUrl: 'http://127.0.0.1:8800/api/v1',
|
||||
onlineBaseUrl: '',
|
||||
timeout: 10000,
|
||||
};
|
||||
```
|
||||
|
||||
- **开发版连接本地**:将 `developmentEnvironment` 设置为 `'local'`,在 `localBaseUrl` 中填写本地接口地址。
|
||||
- **开发版连接线上**:先填写 `onlineBaseUrl`,再将 `developmentEnvironment` 设置为 `'online'`。
|
||||
- **体验版、正式版**:自动使用 `onlineBaseUrl`,不受 `developmentEnvironment` 影响。地址需要使用 HTTPS,并包含 `/api/v1`;目前线上地址尚未确定,保持空串,确定后再填写。
|
||||
- **手机测试本地服务**:保持 `'local'`,将 `localBaseUrl` 中的 `127.0.0.1` 改为电脑局域网 IP;同时将后端 `doodle-store/apps/server/.env` 中的 `HOST` 设置为 `0.0.0.0` 并重启后端。手机和电脑需在同一局域网。
|
||||
|
||||
修改后重新编译小程序即可。当前此配置用于已迁移的分类页,其他页面仍使用原云服务。
|
||||
|
||||
## 本地运行
|
||||
|
||||
1. 在 doodle-store 执行 `pnpm dev`。实际 `apps/server/.env` 和缺省端口已改为 8800,默认 HOST 仍为 127.0.0.1。
|
||||
2. 浏览器访问 `http://127.0.0.1:8800/api/v1/health` 或 `http://127.0.0.1:8800/api-docs`。
|
||||
3. 微信开发者工具打开 doodle-mini,重新编译后进入分类页。本机 `project.private.config.json` 的 urlCheck 已设为 false,公共 project.config.json 保持域名校验;私有配置覆盖公共配置。
|
||||
4. 真机测试时手机和电脑在同一局域网;后端 HOST 改为 0.0.0.0 并重启,将 localBaseUrl 的 IP 改为电脑局域网 IP,保留 8800 和 /api/v1。手机上的 127.0.0.1 指向手机自身。
|
||||
|
||||
开发者工具打开服务端口后才可使用微信官方 CLI 自动化。本机当前未开启该端口,本次没有修改 IDE 全局安全设置、上传代码或发布小程序。
|
||||
|
||||
## 分类页行为
|
||||
|
||||
- 分类导航读取 GET /categories,不再用写死目录决定上线状态;全部、热门推荐、下载最多是前端内置入口。现有后端目录包含 craft、不含 papers,页面按后端实际目录展示。
|
||||
- GET /worksheets?page=1&pageSize=100,按 hasNext 继续读取,完整成功后才生成页面数据。分页变化、重复 ID 或缺页时展示重试,不返回局部榜单。
|
||||
- 保留旧交互:全部及普通分类以 updatedAt 最近两条置顶,其余按下载数降序;热门推荐仍为收藏数前 12,下载最多为下载数前 12。前端“热门推荐”与服务端 sort=hot(下载热度)含义不同,因此不直接用后者替代。
|
||||
- likes/downloads 已含展示 seed,保留十进制字符串;按字符串长度及字典序比较,不转换为 Number,不要求微信运行时支持 BigInt。
|
||||
- previewImg 为空时显示所属分类图标;当前 51 张预览尚未迁移,不阻止题型数据展示。
|
||||
- 每次 onShow 重新读取。加载失败清空旧数据,展示原因和“重新加载”;不恢复 category.data.ts、写死目录或旧云查询。
|
||||
- 请求采用页面版本标记,旧请求或离开页面后的响应不会覆盖新数据。已下架的默认分类回到“全部”。
|
||||
- 点击题型先查询 GET /worksheets/:id,再使用当前服务端 path 跳转;404 时刷新列表,网络失败不直接进入内置生成页。通过分享或其他页面直接进入生成页的访问检查另行迁移,不能只凭分类页迁移认定全站均受新后端发布控制。
|
||||
|
||||
## 验证
|
||||
|
||||
`npm run test:category` 运行环境切换、请求失败、121 条完整分页、分页变化、64 位计数、服务端目录、空图、页面重试、过期响应和点击下架检查。`TEST_LOCAL_API=1 npm run test:category` 额外连接真实本地 8800 服务。
|
||||
|
||||
本次 9 项含真实接口的测试全部通过,读取 6 个分类及 51 个题型,卡片数据及点击详情正常。小程序全量 TypeScript 检查原有 117 条报错仍存在,修改前后结果一致,本次相关文件无新增错误。后端 pnpm check 通过。微信开发者工具模拟器的实际编译/显示尚未通过 CLI 验证(服务端口关闭),不能把脚本联调等同于真机验收。
|
||||
@@ -1,5 +1,7 @@
|
||||
# Doodle Mini — 后端部署方案(NestJS + 自有云服务器)
|
||||
|
||||
> **2026-10-12 决策更新**:已确认迁移到自有腾讯云服务器。当前实施请使用 [云服务端迁移方案(doodle-store)](../../doodle-store/docs/云服务端迁移方案.md)。本文保留为历史技术预案,其中版本、数据模型与本地文件方案不可直接当作当前实施规格;“零增量成本/零成本 CDN”不作为成本事实。
|
||||
|
||||
> 版本:v1.0
|
||||
> 最后更新:2026-03-27
|
||||
> **定位**:**远期 / 阶段到期后的迁移方案**(自有服务器 + NestJS)。
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Doodle Mini — 小程序云开发方案
|
||||
|
||||
> **2026-10-12 决策更新**:本文用于了解现网 CloudBase 和历史数据设计。已确认迁移到自有云服务器,实施以 [云服务端迁移方案(doodle-store)](../../doodle-store/docs/云服务端迁移方案.md) 为准。迁移集合和字段以实际代码及线上盘点为准,不直接按本文的设计集合导入。
|
||||
|
||||
> 版本:v1.1
|
||||
> 最后更新:2026-03-27
|
||||
> **适用范围**:与 [技术架构设计文档](./技术架构设计文档.md) 配套;**当前阶段**采用本文档所述微信云开发环境实现后端能力。
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
> 最后更新:2026-03-27
|
||||
> 配套文档:[现有功能清单](./现有功能清单.md) | [产品设计文档](./产品设计文档.md) | [小程序云开发方案](./小程序云开发方案.md) | [后端部署方案](./后端部署方案.md) | [PC-Web端技术预案](./PC-Web端技术预案.md)
|
||||
|
||||
**后端阶段说明(重要)**:**现阶段**后端能力采用**微信小程序云开发**(云数据库、云存储、云函数),与 [小程序云开发方案](./小程序云开发方案.md) 一致。**当前阶段有效期至 2026 年 9 月 15 日**(到期前需结合用量、成本与产品路线,决定续用云开发、升级套餐或迁移至 [后端部署方案](./后端部署方案.md) 所述 NestJS + 自有服务器方案)。前端仍通过 `platform/cloud-adapter` 对接 `wx.cloud`,迁移时再抽象为 HTTP 适配层。
|
||||
**后端阶段说明(2026-10-12 更新)**:现网仍使用微信云开发,已确认迁移到自有腾讯云服务器 + 统一 API + MySQL + 七牛。实施、兼容与退场以 [云服务端迁移方案(doodle-store)](../../doodle-store/docs/云服务端迁移方案.md) 为准;下文旧阶段、免费额度及未来方案描述作为历史设计参考。当前云调用散落在多个工具和页面,不能仅修改 `platform/cloud-adapter` 完成迁移。存量小程序 Canvas 绘制与本次业务后端迁移分别实施。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+125
-44
@@ -1,8 +1,14 @@
|
||||
import { fetchPageConfig } from './services/site-api';
|
||||
import { normalizePreviewImages } from './config/assets';
|
||||
import { defaultPrintConfig } from './config/config';
|
||||
import { getUser } from './utils/auth';
|
||||
import { apiContextKey, getApiConfig, isCurrentApiConfig } from './config/api';
|
||||
import { generateUUID, getAppUUID, setAppUUID } from './utils/uuid';
|
||||
|
||||
const STORAGE_KEY_PAGE_CONFIG = 'pageConfig';
|
||||
let _refreshGeneration = 0;
|
||||
let _loadGeneration = 0;
|
||||
|
||||
const pageConfigStorageKey = () => `doodle_page_config_v2:${apiContextKey()}`;
|
||||
|
||||
// Pending resolvers for getPageConfig() callers waiting on config
|
||||
let _configResolvers: Array<(config: PageConfigData | null) => void> = [];
|
||||
@@ -18,11 +24,12 @@ App<IAppOption>({
|
||||
pageConfigReady: false,
|
||||
},
|
||||
onLaunch() {
|
||||
wx.cloud.init({
|
||||
env: 'cloud1-9gifs7a2756e2c87',
|
||||
traceUser: true,
|
||||
});
|
||||
|
||||
try {
|
||||
wx.removeStorageSync('user_info');
|
||||
wx.removeStorageSync('user_info_expire');
|
||||
} catch {
|
||||
/* 旧云登录缓存清理失败不影响新凭证命名空间。 */
|
||||
}
|
||||
const accountInfo = wx.getAccountInfoSync();
|
||||
const env = accountInfo.miniProgram.envVersion || 'release';
|
||||
this.globalData.env = env;
|
||||
@@ -41,22 +48,42 @@ App<IAppOption>({
|
||||
this.globalData.printConfig = printConfig;
|
||||
}
|
||||
|
||||
// 静默登录(不阻塞页面渲染)
|
||||
getUser()
|
||||
.then((user) => {
|
||||
this.globalData.user = user;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('静默登录失败', err);
|
||||
});
|
||||
// 启动与回到前台共用登录 Promise,不阻塞公开页面渲染。
|
||||
this.warmAuthentication();
|
||||
|
||||
void this.loadPageConfig();
|
||||
},
|
||||
|
||||
onShow() {},
|
||||
onShow() {
|
||||
this.warmAuthentication();
|
||||
if (this.globalData.pageConfigReady) void this.refreshPageConfig();
|
||||
},
|
||||
|
||||
warmAuthentication() {
|
||||
void getUser().catch(() => {
|
||||
console.warn('静默登录暂未完成,公开浏览仍可使用');
|
||||
});
|
||||
},
|
||||
|
||||
onHide() {},
|
||||
|
||||
/** 切环境不重启 App:作废旧加载、释放等待者并重置共享业务状态。 */
|
||||
resetApiState() {
|
||||
_refreshGeneration++;
|
||||
_loadGeneration++;
|
||||
this.globalData.user = null;
|
||||
this.globalData.pageConfig = undefined;
|
||||
this.globalData.pageConfigReady = false;
|
||||
this.globalData.pendingCategoryTabId = undefined;
|
||||
for (const resolve of _configResolvers) resolve(null);
|
||||
_configResolvers = [];
|
||||
try {
|
||||
wx.removeStorageSync('favorites_active_tab');
|
||||
} catch {
|
||||
/* 临时页签偏好清理失败不影响环境与用户隔离。 */
|
||||
}
|
||||
},
|
||||
|
||||
getPrintConfig(): PrintConfig {
|
||||
return this.globalData.printConfig || defaultPrintConfig;
|
||||
},
|
||||
@@ -66,32 +93,54 @@ App<IAppOption>({
|
||||
wx.setStorageSync('printConfig', config);
|
||||
},
|
||||
|
||||
async loadPageConfig() {
|
||||
async loadPageConfig(forceRefresh = false) {
|
||||
let context: ReturnType<typeof getApiConfig>;
|
||||
try {
|
||||
context = getApiConfig();
|
||||
} catch {
|
||||
// 地址被移除时结束首页等待,开发者仍可进入 Debug 改选可用环境。
|
||||
this.globalData.pageConfig = undefined;
|
||||
this.globalData.pageConfigReady = true;
|
||||
for (const resolve of _configResolvers) resolve(null);
|
||||
_configResolvers = [];
|
||||
return false;
|
||||
}
|
||||
const generation = ++_loadGeneration;
|
||||
const current = () =>
|
||||
generation === _loadGeneration && isCurrentApiConfig(context);
|
||||
const storageKey = pageConfigStorageKey();
|
||||
let config: PageConfigData | null = null;
|
||||
|
||||
// L1: 数据预拉取
|
||||
try {
|
||||
const res = await new Promise<{ fetchedData: string }>(
|
||||
(resolve, reject) => {
|
||||
wx.getBackgroundFetchData({
|
||||
fetchType: 'pre',
|
||||
success: resolve as any,
|
||||
fail: reject,
|
||||
});
|
||||
},
|
||||
);
|
||||
if (res.fetchedData) {
|
||||
const parsed = JSON.parse(res.fetchedData);
|
||||
config = parsed?.data || parsed;
|
||||
// 仅开发版跳过旧线上预拉取,保证环境切换后的内容来自目标服务。
|
||||
// 体验/正式版继续沿用原预拉取模式、地址与旧云端任务。
|
||||
if (context.version !== 'develop')
|
||||
try {
|
||||
const res = await new Promise<{ fetchedData: string }>(
|
||||
(resolve, reject) => {
|
||||
wx.getBackgroundFetchData({
|
||||
fetchType: 'pre',
|
||||
success: resolve as any,
|
||||
fail: reject,
|
||||
});
|
||||
},
|
||||
);
|
||||
if (res.fetchedData) {
|
||||
const parsed = JSON.parse(res.fetchedData);
|
||||
config = parsed?.data || parsed;
|
||||
}
|
||||
} catch {
|
||||
// 预拉取失败,继续降级
|
||||
}
|
||||
} catch {
|
||||
// 预拉取失败,继续降级
|
||||
}
|
||||
if (!current()) return false;
|
||||
|
||||
// L2: storage 缓存
|
||||
if (!config) {
|
||||
if (!config && !forceRefresh) {
|
||||
try {
|
||||
const cached = wx.getStorageSync(STORAGE_KEY_PAGE_CONFIG);
|
||||
const cached =
|
||||
wx.getStorageSync(storageKey) ||
|
||||
(context.version !== 'develop'
|
||||
? wx.getStorageSync('pageConfig')
|
||||
: null);
|
||||
if (cached && typeof cached === 'object') {
|
||||
config = cached;
|
||||
}
|
||||
@@ -100,26 +149,26 @@ App<IAppOption>({
|
||||
}
|
||||
}
|
||||
|
||||
// L3: 云函数兜底
|
||||
// L3: 新后端动态兜底;微信预拉取机制和地址保持不变。
|
||||
const hadCachedConfig = !!config;
|
||||
if (!config) {
|
||||
try {
|
||||
const res = (await wx.cloud.callFunction({
|
||||
name: 'pageConfigFetch',
|
||||
})) as { result?: { success?: boolean; data?: any } };
|
||||
if (res.result?.success && res.result.data) {
|
||||
config = res.result.data;
|
||||
}
|
||||
config = await fetchPageConfig();
|
||||
} catch {
|
||||
// 云函数也失败,config 保持 null
|
||||
/* 新后端不可用时继续展示空状态,本地生成仍可使用。 */
|
||||
}
|
||||
}
|
||||
if (!current()) return false;
|
||||
|
||||
// 三种数据入口都先转换预览图,旧缓存会随成功加载写回新地址。
|
||||
config = normalizePreviewImages(config);
|
||||
|
||||
// 写入 globalData
|
||||
if (config) {
|
||||
this.globalData.pageConfig = config;
|
||||
// 成功获取后写入 storage 缓存
|
||||
try {
|
||||
wx.setStorageSync(STORAGE_KEY_PAGE_CONFIG, config);
|
||||
wx.setStorageSync(storageKey, config);
|
||||
} catch {
|
||||
// storage 写入失败不影响主流程
|
||||
}
|
||||
@@ -131,6 +180,38 @@ App<IAppOption>({
|
||||
resolve(config);
|
||||
}
|
||||
_configResolvers = [];
|
||||
// 预拉取/缓存先渲染,再用新后端刷新,避免旧静态配置长期覆盖管理修改。
|
||||
if (hadCachedConfig) void this.refreshPageConfig();
|
||||
return !!config;
|
||||
},
|
||||
|
||||
async refreshPageConfig() {
|
||||
const context = getApiConfig();
|
||||
const storageKey = pageConfigStorageKey();
|
||||
const generation = ++_refreshGeneration;
|
||||
try {
|
||||
const config = normalizePreviewImages(await fetchPageConfig());
|
||||
if (
|
||||
generation !== _refreshGeneration ||
|
||||
!isCurrentApiConfig(context)
|
||||
)
|
||||
return;
|
||||
this.globalData.pageConfig = config;
|
||||
try {
|
||||
wx.setStorageSync(storageKey, config);
|
||||
} catch {
|
||||
/* 缓存失败不影响当前数据。 */
|
||||
}
|
||||
for (const page of getCurrentPages()) {
|
||||
if (
|
||||
page.route === 'pages/home/home' &&
|
||||
typeof page.initData === 'function'
|
||||
)
|
||||
void page.initData(config);
|
||||
}
|
||||
} catch {
|
||||
/* 保留已显示的数据,网络错误不清空预拉取首屏。 */
|
||||
}
|
||||
},
|
||||
|
||||
getPageConfig(): Promise<PageConfigData | null> {
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* 桥接文件 - 保持向后兼容
|
||||
* 实际实现已迁移到 platform/cloud-adapter.ts
|
||||
*/
|
||||
import { callCloud } from '../platform/cloud-adapter';
|
||||
export default callCloud;
|
||||
@@ -1,3 +1,5 @@
|
||||
import { uploadAsset } from '../services/member-api';
|
||||
import { apiRequest } from '../services/api-request';
|
||||
import { PAPER_SIZE } from '../constants/colors';
|
||||
import { downloadPrint, tryUnlockWithRewardedAd } from '../utils/downloadPrint';
|
||||
import { shouldShowShareGuide, shouldUnlockWithAd } from '../utils/shareGuide';
|
||||
@@ -5,7 +7,6 @@ import { BaseDrawService } from '../core/draw/baseDraw';
|
||||
import tracker from '../utils/tracker';
|
||||
import { defaultShareConfig } from '../config/config';
|
||||
import {
|
||||
buildWorksheetPreviewCloudPath,
|
||||
isDebugPublishEnabled,
|
||||
type DebugPublishConfirmDetail,
|
||||
type DebugPublishMeta,
|
||||
@@ -117,8 +118,7 @@ export function buildPageSharePath(page?: SharePathPage): string {
|
||||
const normalized = route.startsWith('/') ? route : `/${route}`;
|
||||
const data = page?.data || {};
|
||||
const functionId = (data.functionId || data.worksheetId) as
|
||||
| string
|
||||
| undefined;
|
||||
string | undefined;
|
||||
const currentMode = data.currentMode as string | undefined;
|
||||
|
||||
if (!functionId) {
|
||||
@@ -126,9 +126,7 @@ export function buildPageSharePath(page?: SharePathPage): string {
|
||||
}
|
||||
|
||||
const modeQuery =
|
||||
currentMode && currentMode !== functionId
|
||||
? `&mode=${currentMode}`
|
||||
: '';
|
||||
currentMode && currentMode !== functionId ? `&mode=${currentMode}` : '';
|
||||
return `${normalized}?id=${functionId}${modeQuery}`;
|
||||
}
|
||||
|
||||
@@ -509,34 +507,28 @@ export function getPageCommonMethods(config: PageCommonMethodsConfig = {}) {
|
||||
settings: detail.settings,
|
||||
});
|
||||
|
||||
const cloudPath = buildWorksheetPreviewCloudPath(
|
||||
publishMeta.category,
|
||||
publishMeta.id,
|
||||
const asset = await uploadAsset(
|
||||
processed.tempFilePath,
|
||||
'preview',
|
||||
);
|
||||
const uploadRes = await wx.cloud.uploadFile({
|
||||
cloudPath,
|
||||
filePath: processed.tempFilePath,
|
||||
});
|
||||
|
||||
const cloudCall = (await wx.cloud.callFunction({
|
||||
name: 'worksheetsPublish',
|
||||
const {
|
||||
category,
|
||||
previewImg: _oldPreview,
|
||||
...metadata
|
||||
} = publishMeta;
|
||||
await apiRequest('/admin/worksheets/publish', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
...publishMeta,
|
||||
previewImg: uploadRes.fileID,
|
||||
...metadata,
|
||||
categoryId: category,
|
||||
previewAssetId: asset.id,
|
||||
},
|
||||
})) as {
|
||||
result?: { success?: boolean; message?: string };
|
||||
};
|
||||
if (!cloudCall.result?.success) {
|
||||
throw new Error(
|
||||
cloudCall.result?.message || '云端写入失败',
|
||||
);
|
||||
}
|
||||
});
|
||||
this.setData({
|
||||
debugPublishVisible: false,
|
||||
debugPublishMeta: {
|
||||
...publishMeta,
|
||||
previewImg: uploadRes.fileID,
|
||||
previewImg: asset.url,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -566,8 +558,7 @@ export function getPageCommonMethods(config: PageCommonMethodsConfig = {}) {
|
||||
initPageInfo(
|
||||
this: PageCanvasInstance,
|
||||
functionIdOrOptions:
|
||||
| string
|
||||
| { title: string; desc?: string; functionId: string },
|
||||
string | { title: string; desc?: string; functionId: string },
|
||||
defaultTitle?: string,
|
||||
) {
|
||||
let functionId: string;
|
||||
@@ -605,7 +596,9 @@ export function getPageCommonMethods(config: PageCommonMethodsConfig = {}) {
|
||||
tracker.reportPreviewView(this.route || '', {
|
||||
worksheet_id: functionId,
|
||||
worksheet_title: title,
|
||||
...(this.data.currentMode ? { mode: this.data.currentMode } : {}),
|
||||
...(this.data.currentMode
|
||||
? { mode: this.data.currentMode }
|
||||
: {}),
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -670,8 +663,7 @@ export function applyPageMixin(
|
||||
try {
|
||||
const result = await originalDrawCanvas.apply(this, args);
|
||||
const startedAt = (this as any)[generationStartKey] as
|
||||
| number
|
||||
| undefined;
|
||||
number | undefined;
|
||||
if (startedAt) {
|
||||
delete (this as any)[generationStartKey];
|
||||
const worksheetId =
|
||||
@@ -693,8 +685,7 @@ export function applyPageMixin(
|
||||
return result;
|
||||
} catch (error) {
|
||||
const startedAt = (this as any)[generationStartKey] as
|
||||
| number
|
||||
| undefined;
|
||||
number | undefined;
|
||||
if (startedAt) {
|
||||
delete (this as any)[generationStartKey];
|
||||
tracker.reportGenerateResult(this.route || '', false, {
|
||||
|
||||
@@ -41,6 +41,11 @@ Component({
|
||||
type: Object,
|
||||
value: {},
|
||||
},
|
||||
/** 页面可指定发布弹窗的默认裁剪模式,未指定时沿用仅裁头部。 */
|
||||
defaultCropMode: {
|
||||
type: String,
|
||||
value: 'header-only',
|
||||
},
|
||||
},
|
||||
|
||||
data: {
|
||||
@@ -83,7 +88,7 @@ Component({
|
||||
formSubtitle: meta?.subtitle ?? '',
|
||||
formTags: meta?.tags?.join(', ') ?? '',
|
||||
formStatus: meta?.status ?? 'draft',
|
||||
formCropMode: 'header-only', // 默认裁剪头部
|
||||
formCropMode: this.properties.defaultCropMode,
|
||||
formQuality: DEBUG_PUBLISH_DEFAULT_QUALITY,
|
||||
formWidth: DEBUG_PUBLISH_DEFAULT_WIDTH,
|
||||
});
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
bindchange="onWidthChange" />
|
||||
</view>
|
||||
<text class="debug-publish-tools__hint">
|
||||
默认按文档规则裁掉页眉和页脚,并尽量压缩到 {{maxSizeKB}}KB 内。
|
||||
按所选裁剪模式处理图片,并尽量压缩到 {{maxSizeKB}}KB 内。
|
||||
</text>
|
||||
</view>
|
||||
|
||||
|
||||
@@ -66,13 +66,21 @@
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.nav-bar__title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nav-bar__title {
|
||||
font-size: @fs-nav-title;
|
||||
font-weight: @fw-nav-title;
|
||||
color: @color-nav-title;
|
||||
letter-spacing: @ls-nav-title;
|
||||
line-height: 1;
|
||||
flex: 1;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -86,4 +94,14 @@
|
||||
.nav-bar__placeholder {
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
.nav-bar__environment {
|
||||
margin-right: 16rpx;
|
||||
padding: 6rpx 12rpx;
|
||||
border-radius: 12rpx;
|
||||
background: @brand;
|
||||
color: @text-title;
|
||||
font-size: 20rpx;
|
||||
line-height: 1.2;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getNavInnerPx } from '../../utils/navMetrics';
|
||||
import { getApiEnvironment } from '../../config/api';
|
||||
|
||||
const HOME_TAB_URL = '/pages/home/home';
|
||||
const HOME_ROUTE = 'pages/home/home';
|
||||
@@ -24,6 +25,7 @@ Component({
|
||||
leftActionType: 'none' as 'none' | 'back' | 'home' | 'brand',
|
||||
/** 仅首页(Tab 根页)在 logo 旁展示「涂鸦丫」;其余主 Tab 只展示 logo */
|
||||
showBrandName: false,
|
||||
apiEnvironment: '',
|
||||
},
|
||||
lifetimes: {
|
||||
attached() {
|
||||
@@ -44,6 +46,12 @@ Component({
|
||||
},
|
||||
methods: {
|
||||
syncLeftAction() {
|
||||
this.setData({
|
||||
apiEnvironment:
|
||||
wx.getAccountInfoSync().miniProgram.envVersion === 'develop'
|
||||
? getApiEnvironment()
|
||||
: '',
|
||||
});
|
||||
const pages = getCurrentPages();
|
||||
const currentRoute = pages[pages.length - 1]?.route || '';
|
||||
const isTabBarPage = TAB_BAR_ROUTES.has(currentRoute);
|
||||
|
||||
@@ -22,7 +22,12 @@
|
||||
wx:elif="{{leftActionType === 'home'}}"
|
||||
class="toy-icon toy-icon-home-o nav-bar__back-icon"></text>
|
||||
</view>
|
||||
<text class="nav-bar__title">{{title}}</text>
|
||||
<view class="nav-bar__title-group">
|
||||
<text class="nav-bar__title">{{title}}</text>
|
||||
<text wx:if="{{apiEnvironment}}" class="nav-bar__environment"
|
||||
>{{apiEnvironment}}</text
|
||||
>
|
||||
</view>
|
||||
<view class="nav-bar__right">
|
||||
<slot name="right"></slot>
|
||||
</view>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
export type ApiEnvironment = 'local' | 'staging' | 'production';
|
||||
type MiniProgramVersion = 'develop' | 'trial' | 'release';
|
||||
|
||||
const LOCAL_BASE_URL = 'http://127.0.0.1:8800/api/v1';
|
||||
const STORAGE_KEY = 'doodle_develop_api_environment_v1';
|
||||
let selectedEnvironment: ApiEnvironment | undefined;
|
||||
let revision = 0;
|
||||
|
||||
/** 暂时共用本地地址;部署后分别替换 staging/production,真机 local 使用电脑局域网 IP。 */
|
||||
export const apiConfig = {
|
||||
developmentEnvironment: 'local' as ApiEnvironment,
|
||||
localBaseUrl: LOCAL_BASE_URL,
|
||||
stagingBaseUrl: LOCAL_BASE_URL,
|
||||
productionBaseUrl: LOCAL_BASE_URL,
|
||||
timeout: 10000,
|
||||
};
|
||||
|
||||
export const API_ENVIRONMENTS: ApiEnvironment[] = [
|
||||
'local',
|
||||
'staging',
|
||||
'production',
|
||||
];
|
||||
|
||||
export function isApiEnvironment(value: unknown): value is ApiEnvironment {
|
||||
return API_ENVIRONMENTS.includes(value as ApiEnvironment);
|
||||
}
|
||||
|
||||
/** 仅开发包读取本机选择;体验、正式及未知包始终连接 production。 */
|
||||
export function getApiEnvironment(
|
||||
version: string = wx.getAccountInfoSync().miniProgram.envVersion,
|
||||
): ApiEnvironment {
|
||||
if (version === 'develop' && selectedEnvironment === undefined) {
|
||||
try {
|
||||
const stored: unknown = wx.getStorageSync(STORAGE_KEY);
|
||||
if (isApiEnvironment(stored)) selectedEnvironment = stored;
|
||||
} catch {
|
||||
/* 无缓存时使用开发默认值。 */
|
||||
}
|
||||
}
|
||||
return version === 'develop'
|
||||
? (selectedEnvironment ?? apiConfig.developmentEnvironment)
|
||||
: 'production';
|
||||
}
|
||||
|
||||
export function resolveApiConfig(version: MiniProgramVersion | string) {
|
||||
return resolveEnvironment(getApiEnvironment(version), version);
|
||||
}
|
||||
|
||||
function resolveEnvironment(environment: ApiEnvironment, version: string) {
|
||||
const baseUrl = apiConfig[`${environment}BaseUrl`]
|
||||
.trim()
|
||||
.replace(/\/+$/, '');
|
||||
if (!baseUrl) throw new Error(`请先配置 ${environment} 接口地址`);
|
||||
// 用户指定的本机占位地址暂允许 HTTP;实际云端入口仍须使用 HTTPS。
|
||||
if (
|
||||
!/^https?:\/\/[^/?#\s]+\/api\/v1$/.test(baseUrl) ||
|
||||
(environment !== 'local' &&
|
||||
!baseUrl.startsWith('https://') &&
|
||||
baseUrl !== LOCAL_BASE_URL)
|
||||
)
|
||||
throw new Error('接口地址配置不正确');
|
||||
return {
|
||||
environment,
|
||||
baseUrl,
|
||||
timeout: apiConfig.timeout,
|
||||
revision,
|
||||
version,
|
||||
};
|
||||
}
|
||||
|
||||
export function getApiConfig() {
|
||||
const { appId = '', envVersion } = wx.getAccountInfoSync().miniProgram;
|
||||
return { ...resolveApiConfig(envVersion), appId };
|
||||
}
|
||||
|
||||
/** 即使三个地址相同也按环境隔离;develop 不与体验/正式包共享业务缓存。 */
|
||||
export function apiContextKey(config = getApiConfig()): string {
|
||||
const scope = config.version === 'develop' ? 'develop' : 'public';
|
||||
return [config.appId, scope, config.environment, config.baseUrl]
|
||||
.map(encodeURIComponent)
|
||||
.join(':');
|
||||
}
|
||||
|
||||
/** 版本号阻止 A→B→A 后旧 A 请求覆盖当前页面。 */
|
||||
export function isCurrentApiConfig(config: ReturnType<typeof getApiConfig>) {
|
||||
const current = getApiConfig();
|
||||
return (
|
||||
config.revision === current.revision &&
|
||||
apiContextKey(config) === apiContextKey(current)
|
||||
);
|
||||
}
|
||||
|
||||
/** 写入成功后才提交内存选择;配置失败保留原环境,不自动回退。 */
|
||||
export function selectApiEnvironment(environment: ApiEnvironment): boolean {
|
||||
if (wx.getAccountInfoSync().miniProgram.envVersion !== 'develop')
|
||||
throw new Error('仅开发版可切换环境');
|
||||
if (!isApiEnvironment(environment)) throw new Error('未知接口环境');
|
||||
resolveEnvironment(environment, 'develop');
|
||||
if (getApiEnvironment() === environment) return false;
|
||||
wx.setStorageSync(STORAGE_KEY, environment);
|
||||
selectedEnvironment = environment;
|
||||
revision++;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getApiEnvironmentOptions() {
|
||||
return API_ENVIRONMENTS.map((environment) => {
|
||||
try {
|
||||
return {
|
||||
environment,
|
||||
baseUrl: resolveEnvironment(environment, 'develop').baseUrl,
|
||||
available: true,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
environment,
|
||||
baseUrl: '尚未配置有效地址',
|
||||
available: false,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/** 与后端及 asset-sync 的公开资源配置保持一致;此处不包含上传凭据。 */
|
||||
export const assetConfig = {
|
||||
publicDomain: 'https://cdn.joeyone.cn',
|
||||
keyPrefix: 'doodle-assets/',
|
||||
};
|
||||
|
||||
/** 固定字体和页面预览使用相对路径;接口返回的资源 URL 直接使用。 */
|
||||
export function publicAssetUrl(relativePath: string): string {
|
||||
const parts = relativePath.split('/');
|
||||
if (
|
||||
relativePath.includes('\\') ||
|
||||
[...relativePath].some(
|
||||
(char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127,
|
||||
) ||
|
||||
parts.some((part) => !part || part === '.' || part === '..')
|
||||
)
|
||||
throw new Error('资源相对路径不正确');
|
||||
const domain = assetConfig.publicDomain.replace(/\/+$/, '');
|
||||
const prefix = assetConfig.keyPrefix.replace(/\/+$/, '');
|
||||
return `${domain}/${[...(prefix ? prefix.split('/') : []), ...parts].map(encodeURIComponent).join('/')}`;
|
||||
}
|
||||
|
||||
const LEGACY_PREVIEW_PREFIX =
|
||||
'cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/';
|
||||
const LEGACY_PREVIEW_HTTPS =
|
||||
/^https?:\/\/636c-cloud1-9gifs7a2756e2c87-1351593184\.tcb\.qcloud\.la\/([^?#]+)(?:[?#].*)?$/;
|
||||
|
||||
/** 旧预拉取、缓存和云函数中的预览图指向已迁移的公开目录;其他资源不作替换。 */
|
||||
export function resolvePreviewUrl(value: unknown): string {
|
||||
if (typeof value !== 'string') return '';
|
||||
const raw = value.trim();
|
||||
let path: string;
|
||||
if (raw.startsWith(LEGACY_PREVIEW_PREFIX)) {
|
||||
path = raw.slice(LEGACY_PREVIEW_PREFIX.length);
|
||||
if (/[?#]/.test(path)) return '';
|
||||
} else {
|
||||
const match = raw.match(LEGACY_PREVIEW_HTTPS);
|
||||
if (!match) return raw;
|
||||
path = match[1];
|
||||
}
|
||||
try {
|
||||
path = path.split('/').map(decodeURIComponent).join('/');
|
||||
if (!path.startsWith('assets/previews/')) return raw;
|
||||
return publicAssetUrl(path);
|
||||
} catch {
|
||||
// 畸形路径不继续请求旧云存储,也不生成越界的 CDN 地址。
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** 统一处理预拉取/缓存中的嵌套 previewImg;不修改原数据、头像和其他云文件引用。 */
|
||||
export function normalizePreviewImages<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => normalizePreviewImages(item)) as T;
|
||||
}
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
const result = { ...value } as Record<string, unknown>;
|
||||
for (const key of Object.keys(result)) {
|
||||
result[key] =
|
||||
key === 'previewImg'
|
||||
? resolvePreviewUrl(result[key])
|
||||
: normalizePreviewImages(result[key]);
|
||||
}
|
||||
return result as T;
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
// };
|
||||
|
||||
/** 当前应用版本号;profile 页和首页公告共用 */
|
||||
export const APP_VERSION = '3.4.0';
|
||||
export const APP_VERSION = '3.6.0';
|
||||
|
||||
export const defaultPrintConfig: PrintConfig = {
|
||||
// header: 'LogoImage',
|
||||
|
||||
@@ -1,26 +1,72 @@
|
||||
import type { WorksheetDefinition } from './types';
|
||||
|
||||
export const PAPER_SHEET_WORKSHEET_DEFINITIONS = [
|
||||
/** 业务条目与绘制方式分离;颜色等用户参数不另建 worksheet。 */
|
||||
export const PAPER_TYPES = [
|
||||
{ id: 'tian', name: '田字格', desc: '汉字练习' },
|
||||
{ id: 'mi', name: '米字格', desc: '书法练习' },
|
||||
{ id: 'square', name: '方格', desc: '书法练字纸' },
|
||||
{ id: 'four-line', name: '四线三格', desc: '拼音 / 字母' },
|
||||
{ id: 'letter', name: '信纸', desc: '横线信笺' },
|
||||
{ id: 'horizontal', name: '横线', desc: '横线本' },
|
||||
{ id: 'vertical', name: '竖线', desc: '竖排书写' },
|
||||
{ id: 'dictation-hanzi', name: '汉字听写', desc: '田字格 + 订正栏' },
|
||||
{
|
||||
id: 'paper-sheet',
|
||||
icon: 'draw-o',
|
||||
title: '作业纸',
|
||||
subtitle: '田字格 / 方格 / 信纸等多种作业纸一键打印',
|
||||
ageMin: 3,
|
||||
ageMax: 12,
|
||||
difficulty: 1,
|
||||
tags: [
|
||||
'作业纸',
|
||||
'田字格',
|
||||
'米字格',
|
||||
'方格',
|
||||
'四线三格',
|
||||
'信纸',
|
||||
'横线',
|
||||
'竖线',
|
||||
'听写',
|
||||
'作业登记表',
|
||||
],
|
||||
sortOrder: 50,
|
||||
id: 'dictation-pinyin',
|
||||
name: '拼音/英语听写',
|
||||
desc: '四线三格 + 订正栏',
|
||||
},
|
||||
] as const satisfies ReadonlyArray<WorksheetDefinition>;
|
||||
{ id: 'homework-log', name: '作业登记表', desc: '分科登记' },
|
||||
] as const;
|
||||
|
||||
export type PaperType = (typeof PAPER_TYPES)[number]['id'];
|
||||
|
||||
/** 原综合入口始终保留,不重分配历史收藏和统计。 */
|
||||
export const PAPER_SHEET_WORKSHEET_ID = 'paper-sheet';
|
||||
|
||||
export interface PaperSheetDefinition extends WorksheetDefinition {
|
||||
rendererKey: 'paper-sheet';
|
||||
renderConfig: { paperType?: PaperType };
|
||||
}
|
||||
|
||||
export const PAPER_SHEET_WORKSHEET_DEFINITIONS: ReadonlyArray<PaperSheetDefinition> =
|
||||
[
|
||||
{
|
||||
id: PAPER_SHEET_WORKSHEET_ID,
|
||||
rendererKey: 'paper-sheet',
|
||||
renderConfig: {},
|
||||
icon: 'draw-o',
|
||||
title: '作业纸',
|
||||
subtitle: '田字格 / 方格 / 信纸等多种作业纸一键打印',
|
||||
ageMin: 3,
|
||||
ageMax: 12,
|
||||
difficulty: 1,
|
||||
tags: [
|
||||
'作业纸',
|
||||
'田字格',
|
||||
'米字格',
|
||||
'方格',
|
||||
'四线三格',
|
||||
'信纸',
|
||||
'横线',
|
||||
'竖线',
|
||||
'听写',
|
||||
'作业登记表',
|
||||
],
|
||||
sortOrder: 50,
|
||||
},
|
||||
...PAPER_TYPES.map(
|
||||
(paper, index): PaperSheetDefinition => ({
|
||||
id: `paper-${paper.id}`,
|
||||
rendererKey: 'paper-sheet',
|
||||
renderConfig: { paperType: paper.id },
|
||||
icon: 'draw-o',
|
||||
title: paper.name,
|
||||
subtitle: `${paper.desc},一键生成打印`,
|
||||
ageMin: 3,
|
||||
ageMax: 12,
|
||||
difficulty: 1,
|
||||
tags: ['作业纸', paper.name, paper.desc],
|
||||
sortOrder: 51 + index,
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
@@ -3,36 +3,26 @@
|
||||
*
|
||||
* 使用 wx.loadFontFace 将字体注册到 Canvas native 渲染管线,
|
||||
* 按 URL 去重,同一字体只加载一次。
|
||||
* 支持 HTTP/HTTPS 直链和 cloud:// 云存储文件 ID。
|
||||
* 仅使用已配置的 HTTPS 字体地址。
|
||||
*/
|
||||
|
||||
/** 字体加载所需的最小描述 */
|
||||
export interface FontFace {
|
||||
/** wx.loadFontFace 的 family 参数 */
|
||||
name: string;
|
||||
/** 字体文件 URL(HTTP/HTTPS)或 cloud:// 文件 ID */
|
||||
/** 字体文件 HTTPS URL */
|
||||
url: string;
|
||||
}
|
||||
|
||||
const _loadedIds = new Set<string>();
|
||||
|
||||
/**
|
||||
* 将 cloud:// 文件 ID 解析为临时 HTTPS URL。
|
||||
* 非 cloud:// 的 URL 原样返回。
|
||||
*/
|
||||
async function resolveUrl(raw: string): Promise<string> {
|
||||
if (!raw.startsWith('cloud://')) return raw;
|
||||
const res = await wx.cloud.getTempFileURL({ fileList: [raw] });
|
||||
const file = res.fileList?.[0];
|
||||
if (file?.tempFileURL) return file.tempFileURL;
|
||||
throw new Error(`cloud file resolve failed: ${raw}`);
|
||||
}
|
||||
|
||||
/** 加载字体,同一 url 只加载一次 */
|
||||
export async function loadFontFace(font: FontFace): Promise<void> {
|
||||
if (_loadedIds.has(font.url)) return;
|
||||
|
||||
const resolved = await resolveUrl(font.url);
|
||||
if (!font.url.startsWith('https://') || /["\r\n]/.test(font.url))
|
||||
throw new Error('字体地址须为 HTTPS');
|
||||
const resolved = font.url;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.loadFontFace({
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { publicAssetUrl } from '../../config/assets';
|
||||
|
||||
/**
|
||||
* 字体渲染配置(Font Profile)
|
||||
*
|
||||
@@ -12,10 +14,7 @@
|
||||
|
||||
/** 字母分类 */
|
||||
export type LetterCategory =
|
||||
| 'uppercase'
|
||||
| 'tallLower'
|
||||
| 'descender'
|
||||
| 'lowercase';
|
||||
'uppercase' | 'tallLower' | 'descender' | 'lowercase';
|
||||
|
||||
/** 单个分类的渲染参数 */
|
||||
export interface CategoryMetrics {
|
||||
@@ -48,9 +47,6 @@ export function fontFamilyOf(profile: FontProfile): string {
|
||||
return `${profile.name}, Roboto, sans-serif`;
|
||||
}
|
||||
|
||||
const CLOUD_PREFIX =
|
||||
'cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/';
|
||||
|
||||
// ── 字母分类集合 ──
|
||||
const TALL_LOWERCASE = new Set(['b', 'd', 'h', 'k', 'l']);
|
||||
const DESCENDERS = new Set(['g', 'p', 'q', 'y']);
|
||||
@@ -213,7 +209,7 @@ export const PRINT_CLEARLY_DASHED: FontProfile = {
|
||||
/** ToneOZ Pinyin 拼音字体(含声调字母、ü 变体等专用字形) */
|
||||
export const TONEOZ_PINYIN: FontProfile = {
|
||||
name: 'ToneOZPinyin',
|
||||
url: `${CLOUD_PREFIX}assets/fonts/ToneOZ-Pinyin-Regular.ttf`,
|
||||
url: publicAssetUrl('assets/fonts/ToneOZ-Pinyin-Regular.ttf'),
|
||||
categories: {
|
||||
uppercase: { scale: 1, baselineOffset: 0 },
|
||||
tallLower: { scale: 1, baselineOffset: 0 },
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { fetchAgeRecommendations } from '../../services/site-api';
|
||||
import { resolvePreviewUrl } from '../../config/assets';
|
||||
import { appSharePageMethods } from '../../base/pageMixin';
|
||||
import { AGE_BANDS, AgeBandKey } from '../../core/data/difficulty';
|
||||
import { parseMiniProgramUrl } from '../../utils/index';
|
||||
@@ -91,24 +93,17 @@ async function fetchAgeRecommended(
|
||||
ageKey: AgeBandKey,
|
||||
): Promise<RecommendedItemView[]> {
|
||||
try {
|
||||
const res = await wx.cloud.callFunction({
|
||||
name: 'ageRecommendedQuery',
|
||||
data: { ageKey },
|
||||
});
|
||||
const payload = (res && (res.result as any)) || null;
|
||||
if (!payload?.success) return [];
|
||||
const items = (payload.data?.items || []) as any[];
|
||||
const response = await fetchAgeRecommendations(ageKey);
|
||||
const items = response.items;
|
||||
return items
|
||||
.map((it) => ({
|
||||
title: String(it.title || ''),
|
||||
image: String(it.previewImg || ''),
|
||||
image: resolvePreviewUrl(it.previewImg),
|
||||
subtitle: String(it.subtitle || ''),
|
||||
ageBand: String(it.ageBand || it.ageRange || ''),
|
||||
difficulty: toDifficultyLabel(
|
||||
it.difficultyLabel || it.difficulty || '',
|
||||
),
|
||||
ageBand: `${it.ageMin}-${it.ageMax}岁`,
|
||||
difficulty: toDifficultyLabel(it.difficulty || ''),
|
||||
path: String(it.path || ''),
|
||||
worksheetId: String(it.worksheetId || ''),
|
||||
worksheetId: String(it.id || ''),
|
||||
}))
|
||||
.filter((it) => it.title);
|
||||
} catch (e) {
|
||||
@@ -174,8 +169,7 @@ Page({
|
||||
onTapCard(e: WechatMiniprogram.CustomEvent) {
|
||||
const detail = (e.detail || {}) as { title?: string; path?: string };
|
||||
const path =
|
||||
detail.path ||
|
||||
(e.currentTarget.dataset.path as string | undefined);
|
||||
detail.path || (e.currentTarget.dataset.path as string | undefined);
|
||||
const title =
|
||||
detail.title ||
|
||||
(e.currentTarget.dataset.title as string | undefined);
|
||||
|
||||
@@ -288,10 +288,12 @@
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg,
|
||||
transparent 25%,
|
||||
rgba(255, 255, 255, 0.7) 50%,
|
||||
transparent 75%);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
transparent 25%,
|
||||
rgba(255, 255, 255, 0.7) 50%,
|
||||
transparent 75%
|
||||
);
|
||||
animation: skeleton-shimmer 1.2s linear infinite;
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -316,4 +318,18 @@
|
||||
width: 80%;
|
||||
height: 24rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 请求失败时允许显式重试,不用本地静态题型补回下架内容。
|
||||
.cat-empty__retry {
|
||||
margin: 8rpx 0 0;
|
||||
padding: 0 32rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #f3dba2;
|
||||
color: @text-title;
|
||||
font-size: 26rpx;
|
||||
line-height: 72rpx;
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { resolvePreviewUrl } from '../../config/assets';
|
||||
import type { ApiCategory, ApiWorksheet } from '../../services/content-api';
|
||||
|
||||
export interface CategoryTab {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface CategoryItem {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
icon: string;
|
||||
previewImg: string;
|
||||
ageBand: string;
|
||||
difficultyLabel: string;
|
||||
path: string;
|
||||
available: boolean;
|
||||
likes: string;
|
||||
downloads: string;
|
||||
date: string;
|
||||
updatedAt: string;
|
||||
skeleton?: boolean;
|
||||
}
|
||||
|
||||
export interface CategoryDataset {
|
||||
sidebarTabs: CategoryTab[];
|
||||
groups: Map<string, CategoryItem[]>;
|
||||
}
|
||||
|
||||
export const BASE_TABS: CategoryTab[] = [
|
||||
{ id: 'all', name: '全部', icon: '📋' },
|
||||
{ id: 'hot', name: '热门推荐', icon: '🔥' },
|
||||
{ id: 'top-downloads', name: '下载最多', icon: '📥' },
|
||||
];
|
||||
const DIFFICULTY_LABELS = ['入门', '基础', '进阶', '挑战'];
|
||||
|
||||
/** 比较任意长度的非负十进制计数;微信运行时无需 BigInt,也不丢失 64 位精度。 */
|
||||
export function compareCountDescending(left: string, right: string): number {
|
||||
const a = left.replace(/^0+(?=\d)/, '');
|
||||
const b = right.replace(/^0+(?=\d)/, '');
|
||||
return a.length !== b.length
|
||||
? b.length - a.length
|
||||
: a === b
|
||||
? 0
|
||||
: a < b
|
||||
? 1
|
||||
: -1;
|
||||
}
|
||||
|
||||
function time(value: string): number {
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
/** 保留原页面交互:最近更新的两项置顶,其余下载量降序,并列按稳定 ID。 */
|
||||
function sortWorksheets(items: CategoryItem[]): CategoryItem[] {
|
||||
const byId = (a: CategoryItem, b: CategoryItem) =>
|
||||
a.id < b.id ? -1 : a.id === b.id ? 0 : 1;
|
||||
const sorted = [...items].sort(
|
||||
(a, b) => time(b.updatedAt) - time(a.updatedAt) || byId(a, b),
|
||||
);
|
||||
return [
|
||||
...sorted.slice(0, 2),
|
||||
...sorted
|
||||
.slice(2)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
compareCountDescending(a.downloads, b.downloads) ||
|
||||
byId(a, b),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/** 只展示本次接口返回的分类;并发下架导致目录缺失的题型直接丢弃。 */
|
||||
export function buildCategoryDataset(
|
||||
categories: ApiCategory[],
|
||||
worksheets: ApiWorksheet[],
|
||||
): CategoryDataset {
|
||||
const reservedIds = new Set(BASE_TABS.map((tab) => tab.id));
|
||||
const groups = new Map<string, CategoryItem[]>();
|
||||
const icons = new Map<string, string>();
|
||||
for (const category of categories) {
|
||||
if (reservedIds.has(category.id) || groups.has(category.id))
|
||||
throw new Error('分类数据异常,请稍后重试');
|
||||
groups.set(category.id, []);
|
||||
icons.set(category.id, category.icon || '📄');
|
||||
}
|
||||
for (const row of worksheets) {
|
||||
const group = groups.get(row.category);
|
||||
if (!group) continue;
|
||||
const date = row.contentUpdatedAt || row.createdAt;
|
||||
group.push({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
subtitle: row.subtitle,
|
||||
icon: icons.get(row.category) || '📄',
|
||||
previewImg: resolvePreviewUrl(row.previewImg),
|
||||
ageBand: `${row.ageMin}-${row.ageMax}岁`,
|
||||
difficultyLabel: DIFFICULTY_LABELS[row.difficulty - 1] || '基础',
|
||||
path: row.path,
|
||||
available: Boolean(row.path),
|
||||
likes: row.likes,
|
||||
downloads: row.downloads,
|
||||
date: Number.isFinite(Date.parse(date))
|
||||
? new Date(date).toISOString().slice(0, 10)
|
||||
: '',
|
||||
updatedAt: row.updatedAt,
|
||||
});
|
||||
}
|
||||
return {
|
||||
sidebarTabs: [
|
||||
...BASE_TABS,
|
||||
...categories.map(({ id, name, icon }) => ({ id, name, icon })),
|
||||
],
|
||||
groups,
|
||||
};
|
||||
}
|
||||
|
||||
/** 两个榜单沿用旧页面含义:热门推荐按收藏数,下载最多按下载数,各取前 12。 */
|
||||
export function getCategoryItems(
|
||||
dataset: CategoryDataset | null,
|
||||
categoryId: string,
|
||||
keyword = '',
|
||||
): CategoryItem[] {
|
||||
if (!dataset) return [];
|
||||
let items: CategoryItem[];
|
||||
if (
|
||||
categoryId === 'all' ||
|
||||
categoryId === 'hot' ||
|
||||
categoryId === 'top-downloads'
|
||||
) {
|
||||
const all = sortWorksheets(Array.from(dataset.groups.values()).flat());
|
||||
if (categoryId === 'all') items = all;
|
||||
else {
|
||||
const key = categoryId === 'hot' ? 'likes' : 'downloads';
|
||||
items = all
|
||||
.sort((a, b) => compareCountDescending(a[key], b[key]))
|
||||
.slice(0, 12);
|
||||
}
|
||||
} else items = sortWorksheets(dataset.groups.get(categoryId) || []);
|
||||
const kw = keyword.trim().toLowerCase();
|
||||
return kw
|
||||
? items.filter(
|
||||
(item) =>
|
||||
item.title.toLowerCase().includes(kw) ||
|
||||
item.subtitle.toLowerCase().includes(kw),
|
||||
)
|
||||
: items;
|
||||
}
|
||||
@@ -1,44 +1,19 @@
|
||||
import { appSharePageMethods } from '../../base/pageMixin';
|
||||
import { CATEGORY_LIST_WITH_ALL } from '../../core/data/categories';
|
||||
import { parseMiniProgramUrl, takePendingCategoryTabId } from '../../utils/index';
|
||||
import { takePendingCategoryTabId } from '../../utils/index';
|
||||
import tracker from '../../utils/tracker';
|
||||
import { CATEGORY_DATA, type CategoryItem } from './category.data';
|
||||
|
||||
type CategoryTab = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
};
|
||||
|
||||
type CategoryDataset = {
|
||||
searchPlaceholder: string;
|
||||
categories: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
items: CategoryItem[];
|
||||
}>;
|
||||
};
|
||||
|
||||
const DIFFICULTY_LABELS: Record<number, string> = {
|
||||
1: '入门',
|
||||
2: '基础',
|
||||
3: '进阶',
|
||||
4: '挑战',
|
||||
};
|
||||
|
||||
const DYNAMIC_TABS: CategoryTab[] = [
|
||||
{ id: 'hot', name: '热门推荐', icon: '🔥' },
|
||||
{ id: 'top-downloads', name: '下载最多', icon: '📥' },
|
||||
];
|
||||
|
||||
const SIDEBAR_TABS: CategoryTab[] = [
|
||||
{ id: 'all', name: '全部', icon: '📋' },
|
||||
...DYNAMIC_TABS,
|
||||
...CATEGORY_LIST_WITH_ALL.filter((c) => c.id !== 'all').map(
|
||||
({ id, name, icon }) => ({ id, name, icon }),
|
||||
),
|
||||
];
|
||||
import { ApiRequestError } from '../../services/api-request';
|
||||
import {
|
||||
getWorksheet,
|
||||
listAllWorksheets,
|
||||
listCategories,
|
||||
} from '../../services/content-api';
|
||||
import {
|
||||
BASE_TABS,
|
||||
buildCategoryDataset,
|
||||
getCategoryItems,
|
||||
type CategoryDataset,
|
||||
type CategoryItem,
|
||||
} from './category.model';
|
||||
|
||||
const TAB_BAR_PATHS = new Set([
|
||||
'/pages/home/home',
|
||||
@@ -47,182 +22,42 @@ const TAB_BAR_PATHS = new Set([
|
||||
'/pages/favorites/favorites',
|
||||
'/pages/profile/profile',
|
||||
]);
|
||||
|
||||
// Page-level mutable state (shared across methods)
|
||||
let _categoryData: CategoryDataset | null = null;
|
||||
|
||||
// --- 以下仅为本地调试:拉取分类数据后查看各 worksheet 的 downloads / likes,非业务逻辑 ---
|
||||
|
||||
function debugLogWorksheetStatsAfterCategoryLoad(
|
||||
source:
|
||||
| { kind: 'cloud'; rows: Record<string, any>[] }
|
||||
| { kind: 'static'; data: CategoryDataset },
|
||||
) {
|
||||
let map: Record<string, { downloads: number; likes: number }> = {};
|
||||
if (source.kind === 'cloud') {
|
||||
for (const raw of source.rows) {
|
||||
const id = String(raw._id || '').trim();
|
||||
if (!id) continue;
|
||||
map[id] = {
|
||||
downloads: Number(raw.downloads) || 0,
|
||||
likes: Number(raw.likes) || 0,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
for (const group of source.data.categories) {
|
||||
for (const item of group.items) {
|
||||
map[item.id] = {
|
||||
downloads: Number(item.downloads) || 0,
|
||||
likes: Number(item.likes) || 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('worksheetStatsById', JSON.stringify(map, null, 2));
|
||||
}
|
||||
|
||||
// --- 调试块结束 ---
|
||||
|
||||
function toContentDate(raw: Record<string, any>): string {
|
||||
const ts = raw.contentUpdatedAt || raw.createdAt;
|
||||
return ts
|
||||
? new Date(ts).toISOString().slice(0, 10)
|
||||
: new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** 将云端 worksheet 原始数据转换为 CategoryItem */
|
||||
function toDisplayItem(raw: Record<string, any>): CategoryItem {
|
||||
const difficulty = (Number(raw.difficulty) || 2) as 1 | 2 | 3 | 4;
|
||||
const ageMin = Number(raw.ageMin) || 0;
|
||||
const ageMax = Number(raw.ageMax) || 0;
|
||||
return {
|
||||
id: String(raw._id || ''),
|
||||
title: String(raw.title || ''),
|
||||
subtitle: String(raw.subtitle || ''),
|
||||
previewImg: String(raw.previewImg || ''),
|
||||
ageBand: `${ageMin}-${ageMax}岁`,
|
||||
ageMin,
|
||||
ageMax,
|
||||
difficulty,
|
||||
difficultyLabel: DIFFICULTY_LABELS[difficulty] || '基础',
|
||||
path: String(raw.path || ''),
|
||||
available: true,
|
||||
likes: Number(raw.likes) || 0,
|
||||
downloads: Number(raw.downloads) || 0,
|
||||
date: toContentDate(raw),
|
||||
updatedAt: raw.updatedAt ? String(raw.updatedAt) : '',
|
||||
};
|
||||
}
|
||||
|
||||
/** 排序:updatedAt 最新的 2 个置顶,其余按 downloads 降序 */
|
||||
function sortWorksheets(items: CategoryItem[]): CategoryItem[] {
|
||||
const byTime = [...items].sort(
|
||||
(a, b) =>
|
||||
new Date(b.updatedAt || '').getTime() -
|
||||
new Date(a.updatedAt || '').getTime(),
|
||||
);
|
||||
const recent = byTime.slice(0, 2);
|
||||
const recentIds = new Set(recent.map((w) => w.id));
|
||||
const rest = byTime
|
||||
.filter((w) => !recentIds.has(w.id))
|
||||
.sort((a, b) => (b.downloads || 0) - (a.downloads || 0));
|
||||
return [...recent, ...rest];
|
||||
}
|
||||
|
||||
/** 将云端 worksheet 数组按分类分组,构建 CategoryDataset */
|
||||
function buildCategoryDataFromCloud(
|
||||
rawList: Record<string, any>[],
|
||||
): CategoryDataset {
|
||||
const itemsByCategory: Record<string, CategoryItem[]> = {};
|
||||
for (const raw of rawList) {
|
||||
const item = toDisplayItem(raw);
|
||||
const cat = String(raw.category || '');
|
||||
if (!itemsByCategory[cat]) itemsByCategory[cat] = [];
|
||||
itemsByCategory[cat].push(item);
|
||||
}
|
||||
|
||||
const categories = CATEGORY_LIST_WITH_ALL.filter((c) => c.id !== 'all').map(
|
||||
(cat) => ({
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
icon: cat.icon,
|
||||
items: sortWorksheets(itemsByCategory[cat.id] || []),
|
||||
}),
|
||||
);
|
||||
|
||||
return { searchPlaceholder: '搜索练习纸...', categories };
|
||||
}
|
||||
|
||||
function buildAllItems(): CategoryItem[] {
|
||||
if (!_categoryData) return [];
|
||||
const items: CategoryItem[] = [];
|
||||
for (const group of _categoryData.categories) {
|
||||
for (const item of group.items) {
|
||||
items.push({ ...item });
|
||||
}
|
||||
}
|
||||
return sortWorksheets(items);
|
||||
}
|
||||
|
||||
const DYNAMIC_LIMIT = 12;
|
||||
|
||||
function buildHotItems(): CategoryItem[] {
|
||||
const all = buildAllItems();
|
||||
return [...all]
|
||||
.sort((a, b) => (b.likes || 0) - (a.likes || 0))
|
||||
.slice(0, DYNAMIC_LIMIT);
|
||||
}
|
||||
|
||||
function buildTopDownloadsItems(): CategoryItem[] {
|
||||
const all = buildAllItems();
|
||||
return [...all]
|
||||
.sort((a, b) => (b.downloads || 0) - (a.downloads || 0))
|
||||
.slice(0, DYNAMIC_LIMIT);
|
||||
}
|
||||
|
||||
function getItemsByCategory(categoryId: string): CategoryItem[] {
|
||||
if (!_categoryData) return [];
|
||||
if (categoryId === 'all') return buildAllItems();
|
||||
if (categoryId === 'hot') return buildHotItems();
|
||||
if (categoryId === 'top-downloads') return buildTopDownloadsItems();
|
||||
const group = _categoryData.categories.find((c) => c.id === categoryId);
|
||||
if (!group) return [];
|
||||
return group.items.map((item) => ({ ...item }));
|
||||
}
|
||||
|
||||
function filterByKeyword(
|
||||
items: CategoryItem[],
|
||||
keyword: string,
|
||||
): CategoryItem[] {
|
||||
if (!keyword) return items;
|
||||
const kw = keyword.toLowerCase();
|
||||
return items.filter(
|
||||
(item) =>
|
||||
item.title.toLowerCase().includes(kw) ||
|
||||
item.subtitle.toLowerCase().includes(kw),
|
||||
);
|
||||
}
|
||||
|
||||
// Skeleton placeholder items for loading state
|
||||
const SKELETON_ITEMS = Array.from({ length: 6 }, (_, i) => ({
|
||||
id: `skeleton-${i}`,
|
||||
title: '\u00A0',
|
||||
subtitle: '\u00A0',
|
||||
skeleton: true,
|
||||
}));
|
||||
const SKELETON_ITEMS: CategoryItem[] = Array.from(
|
||||
{ length: 6 },
|
||||
(_, index) => ({
|
||||
id: `skeleton-${index}`,
|
||||
title: '\u00a0',
|
||||
subtitle: '\u00a0',
|
||||
icon: '',
|
||||
previewImg: '',
|
||||
ageBand: '',
|
||||
difficultyLabel: '',
|
||||
path: '',
|
||||
available: false,
|
||||
likes: '',
|
||||
downloads: '',
|
||||
date: '',
|
||||
updatedAt: '',
|
||||
skeleton: true,
|
||||
}),
|
||||
);
|
||||
|
||||
Page({
|
||||
data: {
|
||||
searchPlaceholder: '搜索练习纸...',
|
||||
searchKeyword: '',
|
||||
sidebarTabs: SIDEBAR_TABS,
|
||||
sidebarTabs: BASE_TABS,
|
||||
activeCategoryId: 'all',
|
||||
displayItems: SKELETON_ITEMS as any[],
|
||||
displayItems: SKELETON_ITEMS,
|
||||
scrollIntoViewId: '',
|
||||
loading: true,
|
||||
errorMessage: '',
|
||||
},
|
||||
|
||||
_defaultCategoryId: 'all',
|
||||
_categoryData: null as CategoryDataset | null,
|
||||
_loadVersion: 0,
|
||||
_opening: false,
|
||||
|
||||
onLoad(options: Record<string, string>) {
|
||||
tracker.reportPageView('pages/category/category');
|
||||
@@ -233,58 +68,92 @@ Page({
|
||||
},
|
||||
|
||||
onShow() {
|
||||
_categoryData = null;
|
||||
this.setData({ displayItems: SKELETON_ITEMS, loading: true });
|
||||
void this.loadWorksheets(takePendingCategoryTabId() ?? undefined);
|
||||
},
|
||||
|
||||
async loadWorksheets(overrideCategoryId?: string | null) {
|
||||
try {
|
||||
const res = await wx.cloud.callFunction({
|
||||
name: 'worksheetsQuery',
|
||||
data: { status: 'active' },
|
||||
});
|
||||
const result = (res?.result as any) || {};
|
||||
if (!result.success) throw new Error(result.message || '查询失败');
|
||||
onHide() {
|
||||
this._loadVersion++;
|
||||
},
|
||||
|
||||
const rows = result.data || [];
|
||||
console.log('rows', rows);
|
||||
_categoryData = buildCategoryDataFromCloud(rows);
|
||||
debugLogWorksheetStatsAfterCategoryLoad({ kind: 'cloud', rows });
|
||||
} catch {
|
||||
_categoryData = CATEGORY_DATA;
|
||||
debugLogWorksheetStatsAfterCategoryLoad({
|
||||
kind: 'static',
|
||||
data: CATEGORY_DATA,
|
||||
onUnload() {
|
||||
this._loadVersion++;
|
||||
},
|
||||
|
||||
/** 每次进入重新读取公开目录与完整分页;失败清空内容,不回退静态数据或旧云服务。 */
|
||||
async loadWorksheets(overrideCategoryId?: string) {
|
||||
const version = ++this._loadVersion;
|
||||
this._categoryData = null;
|
||||
this.setData({
|
||||
displayItems: SKELETON_ITEMS,
|
||||
sidebarTabs: BASE_TABS,
|
||||
loading: true,
|
||||
errorMessage: '',
|
||||
});
|
||||
try {
|
||||
const [categories, worksheets] = await Promise.all([
|
||||
listCategories(),
|
||||
listAllWorksheets(),
|
||||
]);
|
||||
if (version !== this._loadVersion) return;
|
||||
const dataset = buildCategoryDataset(categories, worksheets);
|
||||
const desiredId =
|
||||
overrideCategoryId ||
|
||||
this.data.activeCategoryId ||
|
||||
this._defaultCategoryId;
|
||||
const activeId = dataset.sidebarTabs.some(
|
||||
(tab) => tab.id === desiredId,
|
||||
)
|
||||
? desiredId
|
||||
: 'all';
|
||||
this._categoryData = dataset;
|
||||
this.setData({
|
||||
loading: false,
|
||||
sidebarTabs: dataset.sidebarTabs,
|
||||
activeCategoryId: activeId,
|
||||
displayItems: getCategoryItems(
|
||||
dataset,
|
||||
activeId,
|
||||
this.data.searchKeyword,
|
||||
),
|
||||
scrollIntoViewId: '',
|
||||
errorMessage: '',
|
||||
});
|
||||
} catch (error) {
|
||||
if (version !== this._loadVersion) return;
|
||||
this._categoryData = null;
|
||||
this.setData({
|
||||
loading: false,
|
||||
displayItems: [],
|
||||
sidebarTabs: BASE_TABS,
|
||||
errorMessage:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: '加载失败,请稍后重试',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
const activeId =
|
||||
overrideCategoryId != null &&
|
||||
String(overrideCategoryId).trim() !== ''
|
||||
? String(overrideCategoryId).trim()
|
||||
: this.data.activeCategoryId || this._defaultCategoryId;
|
||||
const items = getItemsByCategory(activeId);
|
||||
const filtered = filterByKeyword(items, this.data.searchKeyword);
|
||||
|
||||
this.setData({
|
||||
loading: false,
|
||||
searchPlaceholder: _categoryData.searchPlaceholder,
|
||||
activeCategoryId: activeId,
|
||||
displayItems: filtered,
|
||||
});
|
||||
onRetry() {
|
||||
void this.loadWorksheets();
|
||||
},
|
||||
|
||||
onTapCategory(e: WechatMiniprogram.TouchEvent) {
|
||||
const id = e.currentTarget.dataset.id as string;
|
||||
if (!id || id === this.data.activeCategoryId) return;
|
||||
|
||||
const items = getItemsByCategory(id);
|
||||
const filtered = filterByKeyword(items, this.data.searchKeyword);
|
||||
|
||||
if (
|
||||
!this._categoryData ||
|
||||
this.data.loading ||
|
||||
!id ||
|
||||
id === this.data.activeCategoryId ||
|
||||
!this._categoryData.sidebarTabs.some((tab) => tab.id === id)
|
||||
)
|
||||
return;
|
||||
this.setData({
|
||||
activeCategoryId: id,
|
||||
displayItems: filtered,
|
||||
displayItems: getCategoryItems(
|
||||
this._categoryData,
|
||||
id,
|
||||
this.data.searchKeyword,
|
||||
),
|
||||
scrollIntoViewId: '',
|
||||
});
|
||||
tracker.report('category_select', 'pages/category/category', {
|
||||
@@ -294,49 +163,69 @@ Page({
|
||||
|
||||
onSearchInput(e: WechatMiniprogram.Input) {
|
||||
const keyword = (e.detail.value ?? '').trim();
|
||||
const items = getItemsByCategory(this.data.activeCategoryId);
|
||||
const filtered = filterByKeyword(items, keyword);
|
||||
|
||||
this.setData({
|
||||
searchKeyword: keyword,
|
||||
displayItems: filtered,
|
||||
displayItems: getCategoryItems(
|
||||
this._categoryData,
|
||||
this.data.activeCategoryId,
|
||||
keyword,
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
onClearSearch() {
|
||||
const items = getItemsByCategory(this.data.activeCategoryId);
|
||||
this.setData({
|
||||
searchKeyword: '',
|
||||
displayItems: items,
|
||||
displayItems: getCategoryItems(
|
||||
this._categoryData,
|
||||
this.data.activeCategoryId,
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
onTapItem(e: WechatMiniprogram.TouchEvent) {
|
||||
const path = e.currentTarget.dataset.path as string;
|
||||
const title = e.currentTarget.dataset.title as string;
|
||||
const available = e.currentTarget.dataset.available;
|
||||
|
||||
if (!available || !path) {
|
||||
/** 点击时重新检查详情,已下架或网络不可用时不进入内置生成页。 */
|
||||
async onTapItem(e: WechatMiniprogram.TouchEvent) {
|
||||
const id = e.currentTarget.dataset.id as string;
|
||||
if (
|
||||
this._opening ||
|
||||
this.data.loading ||
|
||||
!id ||
|
||||
!this.data.displayItems.some(
|
||||
(item) => item.id === id && item.available,
|
||||
)
|
||||
)
|
||||
return;
|
||||
this._opening = true;
|
||||
const version = this._loadVersion;
|
||||
try {
|
||||
const row = await getWorksheet(id);
|
||||
if (version !== this._loadVersion) return;
|
||||
if (!row.path) throw new Error('内容暂不可用');
|
||||
tracker.reportWorksheetClick('pages/category/category', {
|
||||
worksheet_id: row.id,
|
||||
worksheet_title: row.title,
|
||||
category_id: this.data.activeCategoryId,
|
||||
source: 'category_list',
|
||||
});
|
||||
const fail = () =>
|
||||
wx.showToast({ title: '暂时无法打开该内容', icon: 'none' });
|
||||
if (TAB_BAR_PATHS.has(row.path))
|
||||
wx.switchTab({ url: row.path, fail });
|
||||
else wx.navigateTo({ url: row.path, fail });
|
||||
} catch (error) {
|
||||
if (version !== this._loadVersion) return;
|
||||
wx.showToast({
|
||||
title: title ? `${title} 即将上线` : '即将上线',
|
||||
title:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: '暂时无法打开,请稍后重试',
|
||||
icon: 'none',
|
||||
});
|
||||
return;
|
||||
if (error instanceof ApiRequestError && error.statusCode === 404)
|
||||
void this.loadWorksheets();
|
||||
} finally {
|
||||
this._opening = false;
|
||||
}
|
||||
|
||||
tracker.reportWorksheetClick('pages/category/category', {
|
||||
worksheet_id: String(parseMiniProgramUrl(path).query.id || ''),
|
||||
worksheet_title: title || '',
|
||||
category_id: this.data.activeCategoryId,
|
||||
source: 'category_list',
|
||||
});
|
||||
|
||||
if (TAB_BAR_PATHS.has(path)) {
|
||||
wx.switchTab({ url: path });
|
||||
return;
|
||||
}
|
||||
|
||||
wx.navigateTo({ url: path });
|
||||
},
|
||||
|
||||
...appSharePageMethods,
|
||||
|
||||
@@ -55,9 +55,7 @@
|
||||
wx:for="{{displayItems}}"
|
||||
wx:key="id"
|
||||
class="cat-card {{item.skeleton ? 'cat-card--skeleton' : ''}}"
|
||||
data-path="{{item.path}}"
|
||||
data-title="{{item.title}}"
|
||||
data-available="{{item.available}}"
|
||||
data-id="{{item.id}}"
|
||||
bindtap="{{item.skeleton ? '' : 'onTapItem'}}">
|
||||
<view class="cat-card__thumb">
|
||||
<image
|
||||
@@ -117,7 +115,17 @@
|
||||
</view>
|
||||
|
||||
<view
|
||||
wx:if="{{displayItems.length === 0 && !loading}}"
|
||||
wx:if="{{errorMessage && !loading}}"
|
||||
class="cat-empty">
|
||||
<text class="cat-empty__icon">📡</text>
|
||||
<text class="cat-empty__text">{{errorMessage}}</text>
|
||||
<button class="cat-empty__retry" bindtap="onRetry">
|
||||
重新加载
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view
|
||||
wx:if="{{displayItems.length === 0 && !loading && !errorMessage}}"
|
||||
class="cat-empty">
|
||||
<text class="cat-empty__icon">🔍</text>
|
||||
<text class="cat-empty__text">没有找到匹配的内容</text>
|
||||
|
||||
@@ -222,6 +222,9 @@ Page({
|
||||
this.setData({
|
||||
favoriteList: records.map((r) => mapFavoriteToItem(r)),
|
||||
});
|
||||
} catch {
|
||||
this.setData({ favoriteList: [] });
|
||||
wx.showToast({ title: '收藏加载失败,请重试', icon: 'none' });
|
||||
} finally {
|
||||
this.setData({ favLoading: false });
|
||||
}
|
||||
@@ -232,6 +235,9 @@ Page({
|
||||
try {
|
||||
const records = await getDownloadLogs(1, DOWNLOAD_HISTORY_MAX);
|
||||
this.setData({ downloadSections: groupDownloadsByDate(records) });
|
||||
} catch {
|
||||
this.setData({ downloadSections: [] });
|
||||
wx.showToast({ title: '下载记录加载失败,请重试', icon: 'none' });
|
||||
} finally {
|
||||
this.setData({ dlLoading: false });
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { publicAssetUrl } from '../../config/assets';
|
||||
import { CATEGORY_LIST_WITH_ALL } from '../../core/data/categories';
|
||||
import { AGE_BANDS } from '../../core/data/difficulty';
|
||||
import { CategoryId } from '../../core/models/category';
|
||||
@@ -76,7 +77,7 @@ export const HOME_NOTICES: HomeNotice[] = [
|
||||
},
|
||||
{
|
||||
id: `update-${APP_VERSION}`,
|
||||
text: `${APP_VERSION} 上线:新增幼儿认识时钟,连线练习题`,
|
||||
text: `${APP_VERSION} 上线:新增作业纸`,
|
||||
path: '/mathPages/clockReading/clockReading?id=clock-connect',
|
||||
},
|
||||
];
|
||||
@@ -91,8 +92,9 @@ export const HOME_DAILY_CHECKIN_ITEMS: HomeDisplayItem[] = [
|
||||
ageBand: '5-8岁',
|
||||
difficulty: '基础',
|
||||
icon: '📝',
|
||||
previewImg:
|
||||
'cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/chinese/hanzi-daily-checkin.jpg',
|
||||
previewImg: publicAssetUrl(
|
||||
'assets/previews/chinese/hanzi-daily-checkin.jpg',
|
||||
),
|
||||
path: '/chinesePages/handwritingSheet/handwritingSheet?id=hanzi-daily-checkin',
|
||||
available: true,
|
||||
},
|
||||
@@ -104,8 +106,7 @@ export const HOME_DAILY_CHECKIN_ITEMS: HomeDisplayItem[] = [
|
||||
ageBand: '6-8岁',
|
||||
difficulty: '基础',
|
||||
icon: '🔤',
|
||||
previewImg:
|
||||
'cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/pinyin/pinyin-daily.jpg',
|
||||
previewImg: publicAssetUrl('assets/previews/pinyin/pinyin-daily.jpg'),
|
||||
path: '/pinyinPages/pinyinDictation/pinyinDictation?id=pinyin-daily',
|
||||
available: true,
|
||||
},
|
||||
@@ -117,8 +118,9 @@ export const HOME_DAILY_CHECKIN_ITEMS: HomeDisplayItem[] = [
|
||||
ageBand: '6-8岁',
|
||||
difficulty: '基础',
|
||||
icon: '🔠',
|
||||
previewImg:
|
||||
'cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/english/letter-tracing-daily-checkin.jpg',
|
||||
previewImg: publicAssetUrl(
|
||||
'assets/previews/english/letter-tracing-daily-checkin.jpg',
|
||||
),
|
||||
path: '/englishPages/letterTracing/letterTracing?id=letter-tracing-daily-checkin',
|
||||
available: true,
|
||||
},
|
||||
|
||||
@@ -134,18 +134,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
.home-hero-placeholder {
|
||||
margin: @section-gap @page-padding-x 0;
|
||||
height: 450rpx;
|
||||
border-radius: 40rpx;
|
||||
background: rgba(50, 46, 37, 0.04);
|
||||
/** 紧凑预览轮播:按发布时仅裁头部后的比例展示,标题悬浮于图内。 */
|
||||
.home-hero-gallery {
|
||||
margin-top: 16rpx;
|
||||
background: radial-gradient(ellipse at center, #f3e8cc 0%, @bg-page 70%);
|
||||
}
|
||||
|
||||
/** 首页顶部「精选」swiper 轮播 */
|
||||
.home-hero-swiper {
|
||||
margin-top: 16rpx;
|
||||
width: 100%;
|
||||
height: 450rpx;
|
||||
// 单张宽 750 - 156 * 2 - 16 * 2 = 406rpx;裁头后预览约高 500rpx。
|
||||
// 包含上下阴影留白,不再给副标题、年龄等信息预留高度。
|
||||
height: 548rpx;
|
||||
}
|
||||
|
||||
.home-hero-swiper-item {
|
||||
@@ -153,115 +152,134 @@
|
||||
}
|
||||
|
||||
.home-hero-card-wrap {
|
||||
padding: 24rpx;
|
||||
padding: 16rpx 16rpx 28rpx;
|
||||
box-sizing: border-box;
|
||||
transition: transform 0.45s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
opacity 0.45s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
transform: scale(0.9);
|
||||
opacity: 0.5;
|
||||
transform-origin: center center;
|
||||
transform: translateY(12rpx) scale(0.88);
|
||||
opacity: 0.55;
|
||||
transition:
|
||||
transform 0.45s ease,
|
||||
opacity 0.45s ease;
|
||||
}
|
||||
|
||||
.home-hero-card-wrap--active {
|
||||
transform: scale(1);
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.home-hero-card {
|
||||
width: 100%;
|
||||
border-radius: 32rpx;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 10rpx 28rpx rgba(50, 46, 37, 0.08);
|
||||
box-shadow:
|
||||
0 12rpx 30rpx rgba(96, 75, 33, 0.12),
|
||||
0 2rpx 6rpx rgba(96, 75, 33, 0.06);
|
||||
}
|
||||
|
||||
.home-hero-card__top {
|
||||
height: 260rpx;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
// 与 debugPublish 的 header-only 一致:595 × (842 - 110)。
|
||||
padding-top: 123.02521%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* 顶部背景用自身圆角裁切,替代 card 的 overflow: hidden */
|
||||
border-top-left-radius: 32rpx;
|
||||
border-top-right-radius: 32rpx;
|
||||
overflow: hidden;
|
||||
border-bottom: 1rpx solid rgba(50, 46, 37, 0.08);
|
||||
background: #f5edd8;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.home-hero-card__cover {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.home-hero-card__fallback {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24rpx;
|
||||
background: #fffdf7;
|
||||
}
|
||||
|
||||
.home-hero-card__icon {
|
||||
font-size: 96rpx;
|
||||
line-height: 1;
|
||||
font-size: 100rpx;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.home-hero-card__badge {
|
||||
.home-hero-card__title-overlay {
|
||||
position: absolute;
|
||||
top: 24rpx;
|
||||
right: 24rpx;
|
||||
padding: 8rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(50, 46, 37, 0.12);
|
||||
font-size: 22rpx;
|
||||
color: @text-title;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.home-hero-card__body {
|
||||
padding: 28rpx @page-padding-inner-x 30rpx;
|
||||
}
|
||||
|
||||
.home-hero-card__title-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
/* 允许 flex 子项在文本过长时正确截断 */
|
||||
min-width: 0;
|
||||
left: 12rpx;
|
||||
right: 12rpx;
|
||||
bottom: 12rpx;
|
||||
padding: 10rpx 16rpx;
|
||||
border-radius: 12rpx;
|
||||
background: rgba(255, 252, 244, 0.94);
|
||||
box-shadow: 0 2rpx 12rpx rgba(50, 46, 37, 0.08);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.home-hero-card__title {
|
||||
font-size: 34rpx;
|
||||
line-height: 1.35;
|
||||
display: block;
|
||||
color: @text-title;
|
||||
font-size: 28rpx;
|
||||
line-height: 40rpx;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
/* 标题与 meta 同行:超出后省略号(避免高度抖动) */
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.home-hero-card__sub {
|
||||
margin-top: 10rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.4;
|
||||
color: @text-secondary;
|
||||
/* 第二行:超出后省略号 */
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
.home-hero-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16rpx;
|
||||
height: 32rpx;
|
||||
}
|
||||
|
||||
.home-hero-pagination__dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.home-hero-card__meta {
|
||||
margin: 0;
|
||||
font-size: 22rpx;
|
||||
.home-hero-pagination__target {
|
||||
min-width: 32rpx;
|
||||
height: 40rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.home-hero-pagination__dot {
|
||||
width: 8rpx;
|
||||
height: 8rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #d5c9ad;
|
||||
transition:
|
||||
width 0.3s ease,
|
||||
background 0.3s ease;
|
||||
}
|
||||
|
||||
.home-hero-pagination__dot--active {
|
||||
width: 28rpx;
|
||||
background: @text-title;
|
||||
}
|
||||
|
||||
.home-hero-pagination__count {
|
||||
color: @text-gray;
|
||||
/* meta 也做单行截断,防止极端长文本把布局挤爆 */
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 20rpx;
|
||||
line-height: 28rpx;
|
||||
}
|
||||
|
||||
.home-section-panel {
|
||||
@@ -477,7 +495,6 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
.home-page .category-tabs {
|
||||
background: @bg-page;
|
||||
}
|
||||
@@ -512,11 +529,11 @@
|
||||
pointer-events: none;
|
||||
|
||||
.home-hero-card__top {
|
||||
background: #f3efe5;
|
||||
animation: home-skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.home-hero-card__title,
|
||||
.home-hero-card__sub {
|
||||
.home-hero-card__title {
|
||||
background: #e8e2d5;
|
||||
border-radius: 8rpx;
|
||||
color: transparent;
|
||||
@@ -527,11 +544,6 @@
|
||||
width: 50%;
|
||||
height: 36rpx;
|
||||
}
|
||||
|
||||
.home-hero-card__sub {
|
||||
width: 70%;
|
||||
height: 26rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.home-hot-card--skeleton {
|
||||
@@ -558,4 +570,4 @@
|
||||
width: 65%;
|
||||
height: 24rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolvePreviewUrl } from '../../config/assets';
|
||||
import { appSharePageMethods } from '../../base/pageMixin';
|
||||
import { NAV_INNER_PX } from '../../utils/navMetrics';
|
||||
import {
|
||||
@@ -52,6 +53,7 @@ const TAB_BAR_PATHS = new Set([
|
||||
function toItemView(item: HomeDisplayItem): HomeDisplayItemView {
|
||||
return {
|
||||
...item,
|
||||
previewImg: resolvePreviewUrl(item.previewImg),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -218,11 +220,11 @@ Page({
|
||||
void this.initData();
|
||||
},
|
||||
|
||||
async initData() {
|
||||
wx.showLoading({ title: '加载中', mask: false });
|
||||
async initData(override?: PageConfigData) {
|
||||
if (!override) wx.showLoading({ title: '加载中', mask: false });
|
||||
|
||||
const app = getApp<IAppOption>();
|
||||
const config = await app.getPageConfig();
|
||||
const config = override || (await app.getPageConfig());
|
||||
|
||||
let homeData: HomeDisplayDataset;
|
||||
|
||||
@@ -249,13 +251,26 @@ Page({
|
||||
searchPlaceholder: homeData.searchPlaceholder,
|
||||
tabs,
|
||||
ageBands,
|
||||
activeAgeBand: homeData.ageBands[0]?.key || '3-4',
|
||||
activeAgeBand:
|
||||
override &&
|
||||
homeData.ageBands.some(
|
||||
(band) => band.key === this.data.activeAgeBand,
|
||||
)
|
||||
? this.data.activeAgeBand
|
||||
: homeData.ageBands[0]?.key || '3-4',
|
||||
activeTab: tabs.some((tab) => tab.id === this.data.activeTab)
|
||||
? this.data.activeTab
|
||||
: 'all',
|
||||
heroSwiperCurrent: Math.min(
|
||||
this.data.heroSwiperCurrent,
|
||||
Math.max(0, homeData.featured.length - 1),
|
||||
),
|
||||
featuredItems: homeData.featured.map(toItemView),
|
||||
hotRecommends: homeData.hot.slice(0, 3).map(toItemView),
|
||||
sections,
|
||||
});
|
||||
|
||||
wx.hideLoading();
|
||||
if (!override) wx.hideLoading();
|
||||
},
|
||||
|
||||
// ── Tab & Navigation ──
|
||||
@@ -264,6 +279,19 @@ Page({
|
||||
this.setData({ heroSwiperCurrent: e.detail.current });
|
||||
},
|
||||
|
||||
/** 指示点与手势共用 swiper current,单张内容不自动轮播。 */
|
||||
onTapHeroDot(e: WechatMiniprogram.TouchEvent) {
|
||||
const index = Number(e.currentTarget.dataset.index);
|
||||
if (
|
||||
this.data.loading ||
|
||||
!Number.isInteger(index) ||
|
||||
index < 0 ||
|
||||
index >= this.data.featuredItems.length
|
||||
)
|
||||
return;
|
||||
this.setData({ heroSwiperCurrent: index });
|
||||
},
|
||||
|
||||
onTabChange(e: WechatMiniprogram.CustomEvent) {
|
||||
const id = e.detail.id as string | undefined;
|
||||
if (!id) return;
|
||||
|
||||
@@ -59,63 +59,76 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<swiper
|
||||
class="home-hero-swiper"
|
||||
previous-margin="80rpx"
|
||||
next-margin="80rpx"
|
||||
circular="{{featuredItems.length > 1 && !loading}}"
|
||||
autoplay="{{!loading}}"
|
||||
interval="{{4000}}"
|
||||
duration="{{600}}"
|
||||
easing-function="easeInOutCubic"
|
||||
bindchange="onHeroSwiperChange">
|
||||
<swiper-item
|
||||
wx:for="{{featuredItems}}"
|
||||
wx:key="id"
|
||||
class="home-hero-swiper-item">
|
||||
<view
|
||||
class="home-hero-card-wrap {{heroSwiperCurrent === index ? 'home-hero-card-wrap--active' : ''}}">
|
||||
<view
|
||||
wx:if="{{loading || featuredItems.length > 0}}"
|
||||
class="home-hero-gallery">
|
||||
<!-- 按发布时仅裁页眉后的画幅展示,标题悬浮,不额外占用卡片高度。 -->
|
||||
<swiper
|
||||
class="home-hero-swiper"
|
||||
previous-margin="156rpx"
|
||||
next-margin="156rpx"
|
||||
current="{{heroSwiperCurrent}}"
|
||||
circular="{{featuredItems.length > 1 && !loading}}"
|
||||
autoplay="{{featuredItems.length > 1 && !loading}}"
|
||||
interval="{{5200}}"
|
||||
duration="{{500}}"
|
||||
easing-function="easeInOutCubic"
|
||||
bindchange="onHeroSwiperChange">
|
||||
<swiper-item
|
||||
wx:for="{{featuredItems}}"
|
||||
wx:key="id"
|
||||
class="home-hero-swiper-item">
|
||||
<view
|
||||
class="home-hero-card {{item.skeleton ? 'home-hero-card--skeleton' : ''}}"
|
||||
data-title="{{item.title}}"
|
||||
data-path="{{item.path}}"
|
||||
bindtap="{{item.skeleton ? '' : 'onTapFeatured'}}">
|
||||
<view class="home-hero-card__top">
|
||||
<image
|
||||
wx:if="{{!item.skeleton && item.previewImg}}"
|
||||
class="home-hero-card__cover"
|
||||
src="{{item.previewImg}}"
|
||||
mode="aspectFill" />
|
||||
<text
|
||||
wx:if="{{!item.skeleton && !item.previewImg}}"
|
||||
class="home-hero-card__icon"
|
||||
>{{item.icon}}</text
|
||||
>
|
||||
<text
|
||||
wx:if="{{item.badge}}"
|
||||
class="home-hero-card__badge"
|
||||
>{{item.badge}}</text
|
||||
>
|
||||
</view>
|
||||
<view class="home-hero-card__body">
|
||||
<view class="home-hero-card__title-row">
|
||||
class="home-hero-card-wrap {{heroSwiperCurrent === index ? 'home-hero-card-wrap--active' : ''}}">
|
||||
<view
|
||||
class="home-hero-card {{item.skeleton ? 'home-hero-card--skeleton' : ''}}"
|
||||
data-title="{{item.title}}"
|
||||
data-path="{{item.path}}"
|
||||
bindtap="{{item.skeleton ? '' : 'onTapFeatured'}}">
|
||||
<view class="home-hero-card__top">
|
||||
<image
|
||||
wx:if="{{!item.skeleton && item.previewImg}}"
|
||||
class="home-hero-card__cover"
|
||||
src="{{item.previewImg}}"
|
||||
mode="aspectFit" />
|
||||
<view
|
||||
wx:if="{{!item.skeleton && !item.previewImg}}"
|
||||
class="home-hero-card__fallback">
|
||||
<text class="home-hero-card__icon"
|
||||
>{{item.icon}}</text
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
<view class="home-hero-card__title-overlay">
|
||||
<text class="home-hero-card__title"
|
||||
>{{item.title}}</text
|
||||
>
|
||||
<text
|
||||
wx:if="{{!item.skeleton}}"
|
||||
class="home-hero-card__meta"
|
||||
>{{item.ageBand}}·{{item.difficulty}}</text
|
||||
>
|
||||
</view>
|
||||
<text class="home-hero-card__sub"
|
||||
>{{item.subtitle}}</text
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
<view
|
||||
wx:if="{{!loading && featuredItems.length > 1}}"
|
||||
class="home-hero-pagination">
|
||||
<view class="home-hero-pagination__dots">
|
||||
<view
|
||||
wx:for="{{featuredItems}}"
|
||||
wx:key="id"
|
||||
class="home-hero-pagination__target"
|
||||
data-index="{{index}}"
|
||||
bindtap="onTapHeroDot"
|
||||
aria-role="button"
|
||||
aria-label="{{'查看第' + (index + 1) + '张练习纸'}}">
|
||||
<view
|
||||
class="home-hero-pagination__dot {{heroSwiperCurrent === index ? 'home-hero-pagination__dot--active' : ''}}" />
|
||||
</view>
|
||||
</view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
<text class="home-hero-pagination__count"
|
||||
>{{heroSwiperCurrent + 1}} / {{featuredItems.length}}</text
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="home-section-panel">
|
||||
<view class="home-section-head">
|
||||
|
||||
@@ -123,7 +123,7 @@ page {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.profile-hero__stat+.profile-hero__stat {
|
||||
.profile-hero__stat + .profile-hero__stat {
|
||||
margin-left: 32rpx;
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ page {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.profile-grid__cell+.profile-grid__cell {
|
||||
.profile-grid__cell + .profile-grid__cell {
|
||||
margin-left: 32rpx;
|
||||
}
|
||||
|
||||
@@ -430,14 +430,17 @@ page {
|
||||
}
|
||||
|
||||
.profile-editor__actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 24rpx;
|
||||
display: flex;
|
||||
padding-top: 20rpx;
|
||||
}
|
||||
|
||||
.profile-editor__action {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 92rpx;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -447,6 +450,14 @@ page {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.profile-editor__action::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.profile-editor__action + .profile-editor__action {
|
||||
margin-left: 24rpx;
|
||||
}
|
||||
|
||||
.profile-editor__action--ghost {
|
||||
background: @bg-header;
|
||||
color: @text-title;
|
||||
@@ -461,4 +472,16 @@ page {
|
||||
|
||||
.profile-editor__action--disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
/* 仅在账户恢复需要用户主动确认时展示,正常静默登录不出现。 */
|
||||
.profile-account-restart {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: @section-gap-xs;
|
||||
border-radius: @radius-xl;
|
||||
background: @bg-white;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { uploadAsset, memberStats } from '../../services/member-api';
|
||||
import { getUser as getServerUser, startAccount } from '../../utils/auth';
|
||||
import { disablePageShareMenu } from '../../base/pageMixin';
|
||||
import { APP_VERSION } from '../../config/config';
|
||||
import { getDownloadLogCount } from '../../utils/downloadLogs';
|
||||
import { getFavoriteCount } from '../../utils/favorites';
|
||||
import {
|
||||
getCachedUser,
|
||||
getUser,
|
||||
@@ -22,6 +22,8 @@ Page({
|
||||
draftNickName: '',
|
||||
draftAvatarUrl: '/assets/imgs/doodle-head.png',
|
||||
isSavingProfile: false,
|
||||
serverAccountActionRequired: false,
|
||||
isStartingAccount: false,
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
@@ -35,17 +37,52 @@ Page({
|
||||
},
|
||||
|
||||
onShow() {
|
||||
void this.checkServerAccount();
|
||||
this.syncUserInfoFromApp();
|
||||
void this.refreshStats();
|
||||
},
|
||||
|
||||
/** 正常登录始终静默;账户不存在时才展示用户主动重新开始的入口。 */
|
||||
async checkServerAccount() {
|
||||
try {
|
||||
await getServerUser();
|
||||
this.setData({ serverAccountActionRequired: false });
|
||||
} catch (error) {
|
||||
const reason = (error as { reason?: string }).reason;
|
||||
this.setData({
|
||||
serverAccountActionRequired:
|
||||
reason === 'ACCOUNT_NOT_FOUND' ||
|
||||
reason === 'ACCOUNT_RESTART_REQUIRED',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async onStartServerAccount() {
|
||||
if (this.data.isStartingAccount) return;
|
||||
this.setData({ isStartingAccount: true });
|
||||
try {
|
||||
await startAccount();
|
||||
this.setData({ serverAccountActionRequired: false });
|
||||
wx.showToast({ title: '账户已就绪', icon: 'success' });
|
||||
} catch {
|
||||
wx.showToast({ title: '账户暂不可用,请稍后重试', icon: 'none' });
|
||||
} finally {
|
||||
this.setData({ isStartingAccount: false });
|
||||
}
|
||||
},
|
||||
|
||||
async syncUserInfoFromApp() {
|
||||
const app = getApp<IAppOption>();
|
||||
const cachedUser = app.globalData.user || getCachedUser();
|
||||
|
||||
if (cachedUser) {
|
||||
this.applyUserInfo(cachedUser);
|
||||
}
|
||||
if (cachedUser) this.applyUserInfo(cachedUser);
|
||||
else
|
||||
this.setData({
|
||||
userName: '微信用户',
|
||||
avatarUrl: this.DEFAULT_AVATAR_URL,
|
||||
printCount: 0,
|
||||
favoriteCount: 0,
|
||||
});
|
||||
|
||||
try {
|
||||
const latestUser = await getUser();
|
||||
@@ -69,17 +106,20 @@ Page({
|
||||
|
||||
async refreshStats() {
|
||||
try {
|
||||
const [printCount, favoriteCount] = await Promise.all([
|
||||
getDownloadLogCount(),
|
||||
getFavoriteCount(),
|
||||
]);
|
||||
const stats = await memberStats();
|
||||
const { downloadCount: printCount, favoriteCount } = stats;
|
||||
this.setData({ printCount, favoriteCount });
|
||||
} catch (e) {
|
||||
console.error('refreshStats failed', e);
|
||||
} catch {
|
||||
this.setData({ printCount: 0, favoriteCount: 0 });
|
||||
}
|
||||
},
|
||||
|
||||
onOpenProfileEditor() {
|
||||
console.log(
|
||||
'onOpenProfileEditor',
|
||||
this.data.userName,
|
||||
this.data.avatarUrl,
|
||||
);
|
||||
this.setData({
|
||||
showProfileEditor: true,
|
||||
draftNickName:
|
||||
@@ -121,12 +161,12 @@ Page({
|
||||
this.setData({ isSavingProfile: true });
|
||||
|
||||
try {
|
||||
const avatarUrl = await this.persistAvatarIfNeeded(
|
||||
const avatarAssetId = await this.persistAvatarIfNeeded(
|
||||
this.data.draftAvatarUrl,
|
||||
);
|
||||
const user = await updateUserProfile({
|
||||
nickName,
|
||||
avatarUrl,
|
||||
avatarAssetId,
|
||||
});
|
||||
|
||||
this.applyUserInfo(user);
|
||||
@@ -140,24 +180,11 @@ Page({
|
||||
}
|
||||
},
|
||||
|
||||
async persistAvatarIfNeeded(avatarUrl: string): Promise<string> {
|
||||
if (!avatarUrl) return avatarUrl;
|
||||
if (
|
||||
avatarUrl.startsWith('cloud://') ||
|
||||
avatarUrl.startsWith('http://') ||
|
||||
avatarUrl.startsWith('https://')
|
||||
) {
|
||||
return avatarUrl;
|
||||
}
|
||||
|
||||
async persistAvatarIfNeeded(avatarUrl: string): Promise<string | null> {
|
||||
const user = await getUser();
|
||||
const cloudPath = `user-avatars/${user._id}-${Date.now()}.png`;
|
||||
const uploadRes = await wx.cloud.uploadFile({
|
||||
cloudPath,
|
||||
filePath: avatarUrl,
|
||||
});
|
||||
|
||||
return uploadRes.fileID;
|
||||
if (!avatarUrl || avatarUrl === this.DEFAULT_AVATAR_URL) return null;
|
||||
if (avatarUrl === user.avatarUrl) return user.avatarAssetId || null;
|
||||
return (await uploadAsset(avatarUrl, 'avatar')).id;
|
||||
},
|
||||
|
||||
onGoToDownloads() {
|
||||
|
||||
@@ -38,6 +38,19 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
wx:if="{{serverAccountActionRequired}}"
|
||||
class="profile-account-restart">
|
||||
<text>账户需要重新开始使用</text>
|
||||
<button
|
||||
size="mini"
|
||||
loading="{{isStartingAccount}}"
|
||||
disabled="{{isStartingAccount}}"
|
||||
bindtap="onStartServerAccount">
|
||||
开始使用账户
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<!-- 双格入口 -->
|
||||
<view class="profile-grid">
|
||||
<view class="profile-grid__cell" bindtap="onGoToFavorites">
|
||||
@@ -212,16 +225,19 @@
|
||||
</view>
|
||||
|
||||
<view class="profile-editor__actions">
|
||||
<view
|
||||
<button
|
||||
class="profile-editor__action profile-editor__action--ghost"
|
||||
disabled="{{isSavingProfile}}"
|
||||
bindtap="onCloseProfileEditor">
|
||||
取消
|
||||
</view>
|
||||
<view
|
||||
</button>
|
||||
<button
|
||||
class="profile-editor__action profile-editor__action--primary {{isSavingProfile ? 'profile-editor__action--disabled' : ''}}"
|
||||
loading="{{isSavingProfile}}"
|
||||
disabled="{{isSavingProfile}}"
|
||||
bindtap="onSubmitProfile">
|
||||
{{isSavingProfile ? '保存中...' : '保存'}}
|
||||
</view>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</van-popup>
|
||||
|
||||
@@ -1,53 +1,42 @@
|
||||
import type { DebugPublishMeta } from '../../utils/debugPublish';
|
||||
import { inferGradeFromAge } from '../../utils/debugPublish';
|
||||
import { PAPER_SHEET_WORKSHEET_DEFINITIONS } from '../../config/worksheets/papers';
|
||||
import {
|
||||
PAPER_SHEET_WORKSHEET_DEFINITIONS,
|
||||
PAPER_SHEET_WORKSHEET_ID,
|
||||
PAPER_TYPES,
|
||||
type PaperType,
|
||||
} from '../../config/worksheets/papers';
|
||||
|
||||
type PaperSheetWorksheetRow =
|
||||
(typeof PAPER_SHEET_WORKSHEET_DEFINITIONS)[number];
|
||||
export { PAPER_SHEET_WORKSHEET_ID, type PaperType };
|
||||
export const PAPER_TYPE_OPTIONS = PAPER_TYPES;
|
||||
|
||||
const WORKSHEET_BY_ID = Object.fromEntries(
|
||||
PAPER_SHEET_WORKSHEET_DEFINITIONS.map((m) => [m.id, m]),
|
||||
) as Record<string, PaperSheetWorksheetRow>;
|
||||
const WORKSHEET_BY_ID = new Map(
|
||||
PAPER_SHEET_WORKSHEET_DEFINITIONS.map((entry) => [entry.id, entry]),
|
||||
);
|
||||
|
||||
export const PAPER_SHEET_WORKSHEET_ID = PAPER_SHEET_WORKSHEET_DEFINITIONS[0].id;
|
||||
|
||||
/** 纸张类型 id */
|
||||
export type PaperType =
|
||||
| 'tian'
|
||||
| 'mi'
|
||||
| 'square'
|
||||
| 'four-line'
|
||||
| 'letter'
|
||||
| 'horizontal'
|
||||
| 'vertical'
|
||||
| 'dictation-hanzi'
|
||||
| 'dictation-pinyin'
|
||||
| 'homework-log';
|
||||
|
||||
export interface PaperTypeOption {
|
||||
id: PaperType;
|
||||
name: string;
|
||||
/** 次要说明 */
|
||||
desc: string;
|
||||
/** 只接收已注册条目;旧综合入口继续支持 type,并兼容分享中的 mode。 */
|
||||
export function resolvePaperEntry(options: {
|
||||
id?: string;
|
||||
type?: string;
|
||||
mode?: string;
|
||||
}) {
|
||||
const id = options.id || PAPER_SHEET_WORKSHEET_ID;
|
||||
const entry = WORKSHEET_BY_ID.get(id);
|
||||
if (!entry) throw new Error('当前作业纸暂不可用,请更新小程序');
|
||||
const legacyType = options.type || options.mode;
|
||||
const paperType =
|
||||
entry.renderConfig.paperType ||
|
||||
(legacyType && isValidPaperType(legacyType)
|
||||
? legacyType
|
||||
: DEFAULT_PAPER_TYPE);
|
||||
return { entry, paperType };
|
||||
}
|
||||
|
||||
/** 纸张类型选项(展示顺序与用户需求一致) */
|
||||
export const PAPER_TYPE_OPTIONS: PaperTypeOption[] = [
|
||||
{ id: 'tian', name: '田字格', desc: '汉字练习' },
|
||||
{ id: 'mi', name: '米字格', desc: '书法练习' },
|
||||
{ id: 'square', name: '方格', desc: '书法练字纸' },
|
||||
{ id: 'four-line', name: '四线三格', desc: '拼音 / 字母' },
|
||||
{ id: 'letter', name: '信纸', desc: '横线信笺' },
|
||||
{ id: 'horizontal', name: '横线', desc: '横线本' },
|
||||
{ id: 'vertical', name: '竖线', desc: '竖排书写' },
|
||||
{ id: 'dictation-hanzi', name: '汉字听写', desc: '田字格 + 订正栏' },
|
||||
{
|
||||
id: 'dictation-pinyin',
|
||||
name: '拼音/英语听写',
|
||||
desc: '四线三格 + 订正栏',
|
||||
},
|
||||
{ id: 'homework-log', name: '作业登记表', desc: '分科登记' },
|
||||
];
|
||||
export function getWorksheetIdForPaperType(paperType: PaperType): string {
|
||||
return PAPER_SHEET_WORKSHEET_DEFINITIONS.find(
|
||||
(entry) => entry.renderConfig.paperType === paperType,
|
||||
)!.id;
|
||||
}
|
||||
|
||||
export interface PaperColorOption {
|
||||
id: string;
|
||||
@@ -75,16 +64,16 @@ export function isValidPaperType(id: string): id is PaperType {
|
||||
}
|
||||
|
||||
export function getModeInfo(id: string) {
|
||||
const m = WORKSHEET_BY_ID[id];
|
||||
const m = WORKSHEET_BY_ID.get(id);
|
||||
return m ? { title: m.title, desc: m.subtitle } : undefined;
|
||||
}
|
||||
|
||||
export function isValidMode(id: string): boolean {
|
||||
return id in WORKSHEET_BY_ID;
|
||||
return WORKSHEET_BY_ID.has(id);
|
||||
}
|
||||
|
||||
export function getPublishMetaByMode(id: string): DebugPublishMeta | null {
|
||||
const m = WORKSHEET_BY_ID[id];
|
||||
const m = WORKSHEET_BY_ID.get(id);
|
||||
if (!m) return null;
|
||||
return {
|
||||
id: m.id,
|
||||
|
||||
@@ -8,9 +8,16 @@ import {
|
||||
getModeInfo,
|
||||
getPublishMetaByMode,
|
||||
isValidPaperType,
|
||||
resolvePaperEntry,
|
||||
getWorksheetIdForPaperType,
|
||||
type PaperType,
|
||||
} from './paperSheet.config';
|
||||
import { createPage, type CanvasDataState } from '../../base/pageMixin';
|
||||
import {
|
||||
createPage,
|
||||
getPageCommonMethods,
|
||||
type CanvasDataState,
|
||||
} from '../../base/pageMixin';
|
||||
import { getWorksheet } from '../../services/content-api';
|
||||
import { defaultShareConfig } from '../../config/config';
|
||||
import type { DebugPublishMeta } from '../../utils/debugPublish';
|
||||
import {
|
||||
@@ -20,6 +27,10 @@ import {
|
||||
} from '../../utils/favorites';
|
||||
|
||||
const pageInfoLookup = getModeInfo;
|
||||
const commonMethods = getPageCommonMethods({
|
||||
pageInfoLookup,
|
||||
shareConfig: defaultShareConfig,
|
||||
});
|
||||
|
||||
type PageData = CanvasDataState & {
|
||||
worksheetId: string;
|
||||
@@ -28,6 +39,8 @@ type PageData = CanvasDataState & {
|
||||
selectedPaperType: PaperType;
|
||||
selectedColor: string;
|
||||
isPreviewFavorite: boolean;
|
||||
isWorksheetReady: boolean;
|
||||
favoriteLoading: boolean;
|
||||
isDevEnv: boolean;
|
||||
debugPublishVisible: boolean;
|
||||
debugPublishLoading: boolean;
|
||||
@@ -42,6 +55,8 @@ createPage(
|
||||
boxWidth: 0,
|
||||
drawService: null as PaperDrawService | null,
|
||||
_favoritedMap: {} as Record<string, boolean>,
|
||||
_entryRequest: 0,
|
||||
_favoriteVersions: {} as Record<string, number>,
|
||||
|
||||
data: {
|
||||
pageTitle: '作业纸',
|
||||
@@ -54,28 +69,77 @@ createPage(
|
||||
selectedPaperType: DEFAULT_PAPER_TYPE,
|
||||
selectedColor: DEFAULT_PAPER_COLOR,
|
||||
isPreviewFavorite: false,
|
||||
isWorksheetReady: false,
|
||||
favoriteLoading: false,
|
||||
isDevEnv: false,
|
||||
debugPublishVisible: false,
|
||||
debugPublishLoading: false,
|
||||
debugPublishMeta: null,
|
||||
} as unknown as PageData,
|
||||
|
||||
onLoad(options: { id?: string; type?: string }) {
|
||||
async onLoad(options: { id?: string; type?: string; mode?: string }) {
|
||||
this.syncDebugPublishEnv();
|
||||
|
||||
const initialType =
|
||||
options.type && isValidPaperType(options.type)
|
||||
? (options.type as PaperType)
|
||||
: DEFAULT_PAPER_TYPE;
|
||||
|
||||
this.setData({
|
||||
selectedPaperType: initialType,
|
||||
});
|
||||
|
||||
this.initPageInfo(PAPER_SHEET_WORKSHEET_ID, '作业纸');
|
||||
await this.openEntry(options);
|
||||
this.loadFavoritedMap();
|
||||
},
|
||||
|
||||
onUnload() {
|
||||
this._entryRequest++;
|
||||
},
|
||||
|
||||
/** 开发版直接预览已注册配置以制作封面;体验/正式版切换前校验公开状态。 */
|
||||
async openEntry(options: {
|
||||
id?: string;
|
||||
type?: string;
|
||||
mode?: string;
|
||||
}) {
|
||||
const request = ++this._entryRequest;
|
||||
this.setData({
|
||||
isWorksheetReady: false,
|
||||
hasContent: false,
|
||||
// 换纸张即丢弃旧发布表单,防止旧条目元数据配上新封面。
|
||||
debugPublishVisible: false,
|
||||
debugPublishMeta: null,
|
||||
});
|
||||
try {
|
||||
const { entry, paperType } = resolvePaperEntry(options);
|
||||
// 草稿在公开详情中固定为 404;开发版使用本地注册配置,不通过失败请求判断预览权限。
|
||||
if (!this.data.isDevEnv) {
|
||||
const remote = await getWorksheet(entry.id);
|
||||
if (
|
||||
entry.id !== PAPER_SHEET_WORKSHEET_ID &&
|
||||
(remote.rendering?.rendererKey !== entry.rendererKey ||
|
||||
remote.rendering?.paperType !== paperType)
|
||||
) {
|
||||
throw new Error('作业纸配置暂不可用,请更新小程序');
|
||||
}
|
||||
}
|
||||
if (request !== this._entryRequest) return;
|
||||
this.setData({
|
||||
worksheetId: entry.id,
|
||||
selectedPaperType: paperType,
|
||||
// 综合入口分享保留所选纸张;独立条目由 ID 唯一确定纸张类型。
|
||||
currentMode:
|
||||
entry.id === PAPER_SHEET_WORKSHEET_ID
|
||||
? paperType
|
||||
: entry.id,
|
||||
isPreviewFavorite: this._favoritedMap[entry.id] === true,
|
||||
isWorksheetReady: true,
|
||||
});
|
||||
this.initPageInfo(entry.id, entry.title);
|
||||
await this.drawCanvas();
|
||||
} catch (error) {
|
||||
if (request !== this._entryRequest) return;
|
||||
wx.showToast({
|
||||
title:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: '作业纸加载失败',
|
||||
icon: 'none',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onCanvasReady(e: WechatMiniprogram.CustomEvent) {
|
||||
this.initCanvasFromComponent(e.detail, {
|
||||
createDrawService: (
|
||||
@@ -93,23 +157,35 @@ createPage(
|
||||
},
|
||||
|
||||
async drawCanvas() {
|
||||
if (!this.drawService) return;
|
||||
if (!this.drawService || !this.data.isWorksheetReady) return;
|
||||
const request = this._entryRequest;
|
||||
try {
|
||||
await (this.drawService as PaperDrawService).draw(
|
||||
this.data.selectedPaperType,
|
||||
this.data.selectedColor,
|
||||
);
|
||||
this.setData({ hasContent: true });
|
||||
if (
|
||||
request === this._entryRequest &&
|
||||
this.data.isWorksheetReady
|
||||
)
|
||||
this.setData({ hasContent: true });
|
||||
} catch (err) {
|
||||
console.error('paperSheet draw failed', err);
|
||||
this.setData({ hasContent: false });
|
||||
if (request === this._entryRequest)
|
||||
this.setData({ hasContent: false });
|
||||
}
|
||||
},
|
||||
|
||||
onSelectPaperType(e: WechatMiniprogram.TouchEvent) {
|
||||
const id = e.currentTarget.dataset.id as PaperType | undefined;
|
||||
if (!id || id === this.data.selectedPaperType) return;
|
||||
this.setData({ selectedPaperType: id }, () => this.drawCanvas());
|
||||
const id = e.currentTarget.dataset.id as string | undefined;
|
||||
if (!id || !isValidPaperType(id)) return;
|
||||
const worksheetId = getWorksheetIdForPaperType(id);
|
||||
if (
|
||||
worksheetId === this.data.worksheetId &&
|
||||
this.data.isWorksheetReady
|
||||
)
|
||||
return;
|
||||
return this.openEntry({ id: worksheetId });
|
||||
},
|
||||
|
||||
onSelectColor(e: WechatMiniprogram.TouchEvent) {
|
||||
@@ -124,33 +200,77 @@ createPage(
|
||||
|
||||
onShare() {},
|
||||
|
||||
async onPreviewFavorite() {
|
||||
const next = !this.data.isPreviewFavorite;
|
||||
this.setData({ isPreviewFavorite: next });
|
||||
const id = this.data.worksheetId;
|
||||
if (id) {
|
||||
this._favoritedMap[id] = next;
|
||||
if (next) {
|
||||
addFavorite(id);
|
||||
} else {
|
||||
removeFavorite(id);
|
||||
}
|
||||
async exportToPrint() {
|
||||
if (!this.data.isWorksheetReady || !this.data.hasContent) return;
|
||||
return commonMethods.exportToPrint.call(this);
|
||||
},
|
||||
|
||||
async onShareSuccess() {
|
||||
if (!this.data.isWorksheetReady || !this.data.hasContent) {
|
||||
this.setData({ showShareDialog: false });
|
||||
return;
|
||||
}
|
||||
return commonMethods.onShareSuccess.call(this);
|
||||
},
|
||||
|
||||
/** 请求失败不显示收藏成功,且响应只更新操作时的条目。 */
|
||||
async onPreviewFavorite() {
|
||||
if (!this.data.isWorksheetReady || this.data.favoriteLoading)
|
||||
return;
|
||||
const id = this.data.worksheetId;
|
||||
const next = !this.data.isPreviewFavorite;
|
||||
this._favoriteVersions[id] = (this._favoriteVersions[id] || 0) + 1;
|
||||
this.setData({ favoriteLoading: true });
|
||||
try {
|
||||
const ok = await (next ? addFavorite(id) : removeFavorite(id));
|
||||
if (ok) {
|
||||
this._favoritedMap[id] = next;
|
||||
if (this.data.worksheetId === id)
|
||||
this.setData({ isPreviewFavorite: next });
|
||||
}
|
||||
wx.showToast({
|
||||
title: ok
|
||||
? next
|
||||
? '收藏成功'
|
||||
: '已取消收藏'
|
||||
: '收藏操作失败,请重试',
|
||||
icon: 'none',
|
||||
});
|
||||
} finally {
|
||||
this.setData({ favoriteLoading: false });
|
||||
}
|
||||
wx.showToast({
|
||||
title: next ? '收藏成功' : '已取消收藏',
|
||||
icon: 'none',
|
||||
});
|
||||
},
|
||||
|
||||
async loadFavoritedMap() {
|
||||
const ids = [PAPER_SHEET_WORKSHEET_ID];
|
||||
this._favoritedMap = await batchCheckFavorited(ids);
|
||||
if (this._favoritedMap[this.data.worksheetId]) {
|
||||
this.setData({ isPreviewFavorite: true });
|
||||
const ids = [
|
||||
PAPER_SHEET_WORKSHEET_ID,
|
||||
...PAPER_TYPE_OPTIONS.map((paper) =>
|
||||
getWorksheetIdForPaperType(paper.id),
|
||||
),
|
||||
];
|
||||
const versions = { ...this._favoriteVersions };
|
||||
try {
|
||||
const map = await batchCheckFavorited(ids);
|
||||
// 慢速初次查询不能覆盖查询期间完成的收藏操作。
|
||||
for (const id of ids) {
|
||||
if (versions[id] === this._favoriteVersions[id])
|
||||
this._favoritedMap[id] = map[id] === true;
|
||||
}
|
||||
this.setData({
|
||||
isPreviewFavorite:
|
||||
this._favoritedMap[this.data.worksheetId] === true,
|
||||
});
|
||||
} catch {
|
||||
// 收藏加载失败不阻止预览;后续操作仍以服务端写入结果为准。
|
||||
}
|
||||
},
|
||||
|
||||
getWorksheetStatsId(): string {
|
||||
return this.data.worksheetId;
|
||||
},
|
||||
|
||||
getPublishMeta(): DebugPublishMeta {
|
||||
if (!this.data.isWorksheetReady) throw new Error('请先加载作业纸');
|
||||
const meta = getPublishMetaByMode(this.data.worksheetId);
|
||||
if (!meta) {
|
||||
throw new Error('当前题型配置不存在');
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<nav-bar title="作业纸" />
|
||||
<nav-bar title="{{pageTitle}}" />
|
||||
|
||||
<view class="ps-page">
|
||||
<view class="ps-main">
|
||||
<preview-card
|
||||
id="previewCard"
|
||||
showRefresh="{{true}}"
|
||||
showFavorite="{{true}}"
|
||||
showFavorite="{{isWorksheetReady && !favoriteLoading}}"
|
||||
favorited="{{isPreviewFavorite}}"
|
||||
bind:canvas-ready="onCanvasReady"
|
||||
bind:refresh="onPreviewRefresh"
|
||||
@@ -63,6 +63,7 @@
|
||||
<debug-publish-tools
|
||||
wx:if="{{isDevEnv && hasContent}}"
|
||||
id="debugPublishTools"
|
||||
defaultCropMode="none"
|
||||
visible="{{debugPublishVisible}}"
|
||||
loading="{{debugPublishLoading}}"
|
||||
meta="{{debugPublishMeta}}"
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* 云服务平台适配器
|
||||
* 封装微信云开发 API
|
||||
*/
|
||||
|
||||
export interface CloudResult {
|
||||
code?: string | number;
|
||||
message?: string;
|
||||
info?: string;
|
||||
data?: any;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用云函数
|
||||
*/
|
||||
export async function callCloud(
|
||||
action: string,
|
||||
options?: Record<string, any>,
|
||||
): Promise<CloudResult> {
|
||||
try {
|
||||
const name = 'doodle';
|
||||
|
||||
const { errMsg, result } = await wx.cloud.callFunction({
|
||||
name,
|
||||
data: {
|
||||
action,
|
||||
...options,
|
||||
},
|
||||
});
|
||||
if (!/ok/.test(errMsg)) {
|
||||
wx.showToast({
|
||||
title: '服务器错误',
|
||||
icon: 'none',
|
||||
duration: 2000,
|
||||
});
|
||||
return {
|
||||
code: '500',
|
||||
info: '服务器繁忙',
|
||||
message: errMsg,
|
||||
};
|
||||
}
|
||||
return result as CloudResult;
|
||||
} catch (e: any) {
|
||||
return { code: '500', info: e.message || '服务器繁忙' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询云数据库集合
|
||||
*/
|
||||
export async function queryCollection(
|
||||
collection: string,
|
||||
where?: Record<string, any>,
|
||||
limit: number = 20,
|
||||
orderBy?: { field: string; order: 'asc' | 'desc' },
|
||||
): Promise<any[]> {
|
||||
try {
|
||||
const db = wx.cloud.database();
|
||||
let query = db.collection(collection).where(where || {}).limit(limit);
|
||||
if (orderBy) {
|
||||
query = query.orderBy(orderBy.field, orderBy.order);
|
||||
}
|
||||
const { data } = await query.get();
|
||||
return data;
|
||||
} catch (e) {
|
||||
console.error('queryCollection failed:', collection, e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { apiRequest } from './api-request';
|
||||
import type { ApiWorksheet } from './content-api';
|
||||
|
||||
export type AdminAction =
|
||||
| 'categories.list'
|
||||
| 'categories.create'
|
||||
| 'categories.update'
|
||||
| 'worksheets.list'
|
||||
| 'worksheets.status'
|
||||
| 'worksheets.batch'
|
||||
| 'site.config'
|
||||
| 'site.rebuild'
|
||||
| 'site.timer';
|
||||
interface AdminWorksheet extends ApiWorksheet {
|
||||
status: 'draft' | 'active' | 'hidden';
|
||||
likesDisplay: string;
|
||||
downloadsDisplay: string;
|
||||
likesSeed: string;
|
||||
downloadsSeed: string;
|
||||
}
|
||||
interface AdminPage {
|
||||
items: AdminWorksheet[];
|
||||
total: number;
|
||||
hasNext: boolean;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
/** 管理页迁移适配:只调用具名 REST 接口,旧页面展示字段在客户端映射,不传旧用户身份。 */
|
||||
export async function managementRequest<T>(
|
||||
action: AdminAction,
|
||||
payload: Record<string, unknown> = {},
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: T;
|
||||
stats?: { total: number; active: number; draft: number; hidden: number };
|
||||
message?: string;
|
||||
}> {
|
||||
let data: unknown;
|
||||
let stats;
|
||||
switch (action) {
|
||||
case 'categories.list': {
|
||||
const rows =
|
||||
await apiRequest<Array<{ id: string }>>('/admin/categories');
|
||||
data = rows.map((row) => ({ ...row, _id: row.id }));
|
||||
break;
|
||||
}
|
||||
case 'categories.create':
|
||||
data = await apiRequest('/admin/categories', {
|
||||
method: 'POST',
|
||||
data: payload,
|
||||
});
|
||||
break;
|
||||
case 'categories.update': {
|
||||
const { id, ...fields } = payload;
|
||||
data = await apiRequest(
|
||||
`/admin/categories/${encodeURIComponent(String(id))}`,
|
||||
{ method: 'PUT', data: fields },
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'worksheets.list': {
|
||||
const rows: AdminWorksheet[] = [];
|
||||
let expected: number | undefined;
|
||||
for (let page = 1; ; page++) {
|
||||
const response = await apiRequest<AdminPage>(
|
||||
'/admin/catalog/worksheets',
|
||||
{
|
||||
data: {
|
||||
page,
|
||||
pageSize: 100,
|
||||
...(payload.category
|
||||
? { categoryId: payload.category }
|
||||
: {}),
|
||||
...(payload.status
|
||||
? { status: payload.status }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
);
|
||||
if (
|
||||
response.page !== page ||
|
||||
response.pageSize !== 100 ||
|
||||
!Array.isArray(response.items) ||
|
||||
!Number.isSafeInteger(response.total) ||
|
||||
(expected !== undefined && response.total !== expected) ||
|
||||
response.hasNext !== page * 100 < response.total
|
||||
)
|
||||
throw new Error('管理目录发生变化,请重试');
|
||||
expected = response.total;
|
||||
rows.push(...response.items);
|
||||
if (!response.hasNext) break;
|
||||
}
|
||||
if (
|
||||
rows.length !== expected ||
|
||||
new Set(rows.map((row) => row.id)).size !== rows.length
|
||||
)
|
||||
throw new Error('管理目录发生变化,请重试');
|
||||
data = rows.map((row) => ({
|
||||
...row,
|
||||
_id: row.id,
|
||||
likes: payload.rawStats ? row.likes : row.likesDisplay,
|
||||
downloads: payload.rawStats
|
||||
? row.downloads
|
||||
: row.downloadsDisplay,
|
||||
}));
|
||||
stats = {
|
||||
total: rows.length,
|
||||
active: rows.filter((row) => row.status === 'active').length,
|
||||
draft: rows.filter((row) => row.status === 'draft').length,
|
||||
hidden: rows.filter((row) => row.status === 'hidden').length,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'worksheets.status':
|
||||
data = await apiRequest(
|
||||
`/admin/worksheets/${encodeURIComponent(String(payload.id))}/status`,
|
||||
{ method: 'PUT', data: { status: payload.status } },
|
||||
);
|
||||
break;
|
||||
case 'worksheets.batch': {
|
||||
const patches = (
|
||||
payload.patches as Array<Record<string, unknown>>
|
||||
).map(({ likes_seed, downloads_seed, ...row }) => ({
|
||||
...row,
|
||||
...(likes_seed === undefined ? {} : { likesSeed: likes_seed }),
|
||||
...(downloads_seed === undefined
|
||||
? {}
|
||||
: { downloadsSeed: downloads_seed }),
|
||||
}));
|
||||
data = await apiRequest('/admin/worksheets/batch', {
|
||||
method: 'POST',
|
||||
timeout: 60000,
|
||||
data: { patches, dryRun: payload.dryRun === true },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'site.config':
|
||||
data = await apiRequest('/admin/site/config');
|
||||
break;
|
||||
case 'site.rebuild':
|
||||
data = await apiRequest('/admin/site/rebuild', {
|
||||
method: 'POST',
|
||||
timeout: 60000,
|
||||
data: payload,
|
||||
});
|
||||
break;
|
||||
case 'site.timer':
|
||||
data = await apiRequest('/admin/site/timer', {
|
||||
method: 'PUT',
|
||||
data: { enabled: payload.enabled },
|
||||
});
|
||||
break;
|
||||
default:
|
||||
throw new Error('不支持的管理操作');
|
||||
}
|
||||
if (
|
||||
[
|
||||
'categories.create',
|
||||
'categories.update',
|
||||
'worksheets.status',
|
||||
'worksheets.batch',
|
||||
'site.rebuild',
|
||||
].includes(action)
|
||||
) {
|
||||
const app = getApp<IAppOption>();
|
||||
if (typeof app.refreshPageConfig === 'function')
|
||||
await app.refreshPageConfig();
|
||||
}
|
||||
return { success: true, data: data as T, ...(stats ? { stats } : {}) };
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
let pendingWrites = 0;
|
||||
|
||||
/** 覆盖登录等待、上传及重试全过程,防止写操作进行中切换服务端。 */
|
||||
export function beginApiWrite() {
|
||||
pendingWrites++;
|
||||
return () => {
|
||||
pendingWrites--;
|
||||
};
|
||||
}
|
||||
|
||||
export function hasPendingApiWrites() {
|
||||
return pendingWrites > 0;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { getApiConfig } from '../config/api';
|
||||
import { beginApiWrite } from './api-activity';
|
||||
import {
|
||||
ApiRequestError,
|
||||
requestApi,
|
||||
type RequestOptions,
|
||||
} from './api-transport';
|
||||
import {
|
||||
cachedServerUser,
|
||||
clearSession,
|
||||
ensureSession,
|
||||
getAuthContext,
|
||||
recoverSession,
|
||||
rejectSession,
|
||||
} from './auth-session';
|
||||
export { ApiRequestError } from './api-transport';
|
||||
|
||||
const recoverable = new Set([
|
||||
'AUTH_REQUIRED',
|
||||
'AUTH_INVALID',
|
||||
'AUTH_EXPIRED',
|
||||
'AUTH_REVOKED',
|
||||
]);
|
||||
const blocked = new Set([
|
||||
'ACCOUNT_NOT_FOUND',
|
||||
'ACCOUNT_DISABLED',
|
||||
'WECHAT_LOGIN_BLOCKED',
|
||||
]);
|
||||
|
||||
/** 公开接口显式 auth=none;其余接口合并登录,最多恢复并重发一次,不跨账户补发。 */
|
||||
export async function apiRequest<T>(
|
||||
path: string,
|
||||
options: RequestOptions & { auth?: 'required' | 'none' } = {},
|
||||
): Promise<T> {
|
||||
const finish =
|
||||
options.upload || (options.method && options.method !== 'GET')
|
||||
? beginApiWrite()
|
||||
: undefined;
|
||||
try {
|
||||
return await performApiRequest<T>(path, options);
|
||||
} finally {
|
||||
finish?.();
|
||||
}
|
||||
}
|
||||
|
||||
async function performApiRequest<T>(
|
||||
path: string,
|
||||
options: RequestOptions & { auth?: 'required' | 'none' } = {},
|
||||
): Promise<T> {
|
||||
// Authorization 由会话层统一提供;公开请求不附带调用方误传的凭证。
|
||||
const header = Object.fromEntries(
|
||||
Object.entries(options.header || {}).filter(
|
||||
([key]) => key.toLowerCase() !== 'authorization',
|
||||
),
|
||||
);
|
||||
if (options.auth === 'none')
|
||||
return requestApi(path, { ...options, header }, getApiConfig());
|
||||
const context = getAuthContext();
|
||||
const previousUserId = cachedServerUser()?.id;
|
||||
const initial = await ensureSession(context);
|
||||
if (previousUserId && initial.user.id !== previousUserId) {
|
||||
clearSession(false, context);
|
||||
throw new ApiRequestError(
|
||||
'账户已变化,请重新操作',
|
||||
401,
|
||||
'ACCOUNT_CHANGED',
|
||||
);
|
||||
}
|
||||
const send = async (accessToken: string) => {
|
||||
if (getAuthContext().key !== context.key)
|
||||
throw new ApiRequestError('接口环境已变化,请重新操作');
|
||||
const result = await requestApi<T>(
|
||||
path,
|
||||
{
|
||||
...options,
|
||||
header: { ...header, Authorization: `Bearer ${accessToken}` },
|
||||
},
|
||||
context,
|
||||
);
|
||||
if (
|
||||
getAuthContext().key !== context.key ||
|
||||
cachedServerUser()?.id !== initial.user.id
|
||||
)
|
||||
throw new ApiRequestError('账户状态已变化,请重新操作');
|
||||
return result;
|
||||
};
|
||||
try {
|
||||
return await send(initial.accessToken);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ApiRequestError) || error.statusCode !== 401)
|
||||
throw error;
|
||||
if (!recoverable.has(error.reason)) {
|
||||
if (blocked.has(error.reason))
|
||||
rejectSession(context, initial.accessToken, true);
|
||||
throw error;
|
||||
}
|
||||
const renewed = await recoverSession(context, initial);
|
||||
if (renewed.user.id !== initial.user.id) {
|
||||
clearSession(false, context);
|
||||
throw new ApiRequestError(
|
||||
'账户已变化,请重新操作',
|
||||
401,
|
||||
'ACCOUNT_CHANGED',
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await send(renewed.accessToken);
|
||||
} catch (second) {
|
||||
if (second instanceof ApiRequestError && second.statusCode === 401)
|
||||
rejectSession(
|
||||
context,
|
||||
renewed.accessToken,
|
||||
blocked.has(second.reason),
|
||||
);
|
||||
throw second;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { getApiConfig, isCurrentApiConfig } from '../config/api';
|
||||
|
||||
export class ApiRequestError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly statusCode = 0,
|
||||
readonly reason = '',
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface RequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
data?: Record<string, unknown>;
|
||||
header?: Record<string, string>;
|
||||
timeout?: number;
|
||||
upload?: { filePath: string; uploadId: string };
|
||||
}
|
||||
|
||||
/** 底层只发请求,不触发登录;登录接口与鉴权包装共用,避免循环依赖。 */
|
||||
export function requestApi<T>(
|
||||
path: string,
|
||||
options: RequestOptions = {},
|
||||
config = getApiConfig(),
|
||||
): Promise<T> {
|
||||
if (!path.startsWith('/') || path.startsWith('//'))
|
||||
throw new Error('接口路径不正确');
|
||||
return new Promise((resolve, reject) => {
|
||||
// 公开请求、上传和登录共用检查,旧环境的响应不进入上层业务回调。
|
||||
const stale = () => {
|
||||
if (isCurrentApiConfig(config)) return false;
|
||||
reject(new ApiRequestError('接口环境已变化,请重新操作'));
|
||||
return true;
|
||||
};
|
||||
if (stale()) return;
|
||||
const success = (res: { statusCode: number; data: unknown }) => {
|
||||
if (stale()) return;
|
||||
const body = res.data as {
|
||||
code?: number;
|
||||
data?: T;
|
||||
reason?: string;
|
||||
} | null;
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
reject(
|
||||
new ApiRequestError(
|
||||
res.statusCode === 404
|
||||
? '内容暂不可用'
|
||||
: '接口请求失败,请稍后重试',
|
||||
res.statusCode,
|
||||
typeof body?.reason === 'string' &&
|
||||
/^[A-Z_]{1,64}$/.test(body.reason)
|
||||
? body.reason
|
||||
: '',
|
||||
),
|
||||
);
|
||||
} else if (
|
||||
!body ||
|
||||
body.code !== 0 ||
|
||||
!Object.prototype.hasOwnProperty.call(body, 'data')
|
||||
) {
|
||||
reject(
|
||||
new ApiRequestError(
|
||||
'接口返回异常,请稍后重试',
|
||||
res.statusCode,
|
||||
),
|
||||
);
|
||||
} else resolve(body.data as T);
|
||||
};
|
||||
const fail = () => {
|
||||
if (!stale())
|
||||
reject(
|
||||
new ApiRequestError('连接失败,请确认后端已启动及网络配置'),
|
||||
);
|
||||
};
|
||||
if (options.upload) {
|
||||
// wx.uploadFile 由运行时生成 multipart boundary,不能自行设置 JSON Content-Type。
|
||||
wx.uploadFile({
|
||||
url: `${config.baseUrl}${path}`,
|
||||
filePath: options.upload.filePath,
|
||||
name: 'file',
|
||||
formData: { uploadId: options.upload.uploadId },
|
||||
header: options.header,
|
||||
timeout: 60000,
|
||||
success(res) {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(res.data);
|
||||
} catch {
|
||||
reject(new ApiRequestError('上传响应格式异常'));
|
||||
return;
|
||||
}
|
||||
success({ statusCode: res.statusCode, data });
|
||||
},
|
||||
fail,
|
||||
});
|
||||
} else
|
||||
wx.request({
|
||||
url: `${config.baseUrl}${path}`,
|
||||
method: options.method || 'GET',
|
||||
data: options.data,
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.header,
|
||||
},
|
||||
timeout: options.timeout ?? config.timeout,
|
||||
success,
|
||||
fail,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { getApiConfig, apiContextKey, isCurrentApiConfig } from '../config/api';
|
||||
import { ApiRequestError, requestApi } from './api-transport';
|
||||
|
||||
export interface ServerUser {
|
||||
id: string;
|
||||
nickName: string | null;
|
||||
avatarUrl: string | null;
|
||||
avatarAssetId?: string | null;
|
||||
avatarUrlExpiresAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
export interface AuthSession {
|
||||
accessToken: string;
|
||||
expiresAt: number;
|
||||
user: ServerUser;
|
||||
}
|
||||
type Context = ReturnType<typeof getAuthContext>;
|
||||
type History = 'new' | 'used' | 'blocked';
|
||||
interface SessionState {
|
||||
session: AuthSession | null;
|
||||
history: History;
|
||||
generation: number;
|
||||
flight?: Promise<AuthSession>;
|
||||
}
|
||||
const states = new Map<string, SessionState>();
|
||||
const EARLY_RENEW_MS = 60000;
|
||||
|
||||
/** 环境、完整接口入口和 AppID 隔离;正式版不会读取开发版凭证。 */
|
||||
export function getAuthContext() {
|
||||
const config = getApiConfig();
|
||||
const appId = wx.getAccountInfoSync().miniProgram.appId;
|
||||
if (!appId) throw new ApiRequestError('无法识别小程序身份');
|
||||
return {
|
||||
...config,
|
||||
key: `doodle_auth_v2:${apiContextKey(config)}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** 用户资料白名单只供展示,不缓存角色、微信身份或服务端内部字段。 */
|
||||
export function parseServerUser(value: unknown): ServerUser {
|
||||
const row = value as ServerUser | null;
|
||||
if (
|
||||
!row ||
|
||||
typeof row.id !== 'string' ||
|
||||
!row.id ||
|
||||
(row.nickName !== null && typeof row.nickName !== 'string') ||
|
||||
(row.avatarUrl !== null && typeof row.avatarUrl !== 'string') ||
|
||||
(row.avatarUrlExpiresAt !== null &&
|
||||
typeof row.avatarUrlExpiresAt !== 'string') ||
|
||||
typeof row.createdAt !== 'string' ||
|
||||
!Number.isFinite(Date.parse(row.createdAt))
|
||||
)
|
||||
throw new ApiRequestError('登录资料格式异常');
|
||||
return {
|
||||
id: row.id,
|
||||
nickName: row.nickName,
|
||||
avatarUrl: row.avatarUrl,
|
||||
avatarAssetId:
|
||||
typeof row.avatarAssetId === 'string' ? row.avatarAssetId : null,
|
||||
avatarUrlExpiresAt: row.avatarUrlExpiresAt,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
function readState(context: Context): SessionState {
|
||||
const existing = states.get(context.key);
|
||||
if (existing) return existing;
|
||||
const state: SessionState = {
|
||||
session: null,
|
||||
history: 'new',
|
||||
generation: 0,
|
||||
};
|
||||
try {
|
||||
// 旧版会话只迁入原默认环境,不能复制到同址的 staging/production 开发配置。
|
||||
const legacyDefault =
|
||||
context.version === 'develop'
|
||||
? context.environment === 'local'
|
||||
: context.environment === 'production';
|
||||
const stored =
|
||||
wx.getStorageSync(context.key) ||
|
||||
(legacyDefault
|
||||
? wx.getStorageSync(
|
||||
`doodle_auth_v1:${encodeURIComponent(wx.getAccountInfoSync().miniProgram.appId)}:${encodeURIComponent(context.baseUrl)}`,
|
||||
)
|
||||
: null);
|
||||
if (stored) {
|
||||
state.history = 'used';
|
||||
if (
|
||||
stored.version === 1 &&
|
||||
['new', 'used', 'blocked'].includes(stored.history)
|
||||
) {
|
||||
state.history = stored.history;
|
||||
const session = stored.session;
|
||||
if (
|
||||
state.history === 'used' &&
|
||||
session &&
|
||||
typeof session.accessToken === 'string' &&
|
||||
session.accessToken &&
|
||||
!/\s/.test(session.accessToken) &&
|
||||
Number.isFinite(session.expiresAt)
|
||||
)
|
||||
state.session = {
|
||||
accessToken: session.accessToken,
|
||||
expiresAt: session.expiresAt,
|
||||
user: parseServerUser(session.user),
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 损坏资料不作为有效凭证;保留已使用账户标记。 */
|
||||
}
|
||||
states.set(context.key, state);
|
||||
return state;
|
||||
}
|
||||
function syncGlobal(context: Context, user: ServerUser | null) {
|
||||
try {
|
||||
if (getAuthContext().key === context.key)
|
||||
getApp<IAppOption>().globalData.user = user;
|
||||
} catch {
|
||||
/* App 未初始化时不影响认证流程。 */
|
||||
}
|
||||
}
|
||||
function persist(context: Context, state: SessionState) {
|
||||
try {
|
||||
wx.setStorageSync(context.key, {
|
||||
version: 1,
|
||||
history: state.history,
|
||||
session: state.session,
|
||||
});
|
||||
} catch {
|
||||
/* Storage 不可写时仍可使用本次内存会话,不记录任何凭据。 */
|
||||
}
|
||||
syncGlobal(context, state.session?.user || null);
|
||||
}
|
||||
function usable(session: AuthSession | null): session is AuthSession {
|
||||
return !!session && session.expiresAt > Date.now() + EARLY_RENEW_MS;
|
||||
}
|
||||
function wechatCode(): Promise<string> {
|
||||
return new Promise((resolve, reject) =>
|
||||
wx.login({
|
||||
timeout: 10000,
|
||||
success(res) {
|
||||
res.code
|
||||
? resolve(res.code)
|
||||
: reject(new ApiRequestError('微信登录失败'));
|
||||
},
|
||||
fail() {
|
||||
reject(new ApiRequestError('微信登录失败'));
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** 合并启动、续期及 401 恢复;每次尝试取新 code,明确无效 code 最多补试一次。 */
|
||||
export async function ensureSession(
|
||||
context = getAuthContext(),
|
||||
force = false,
|
||||
explicitStart = false,
|
||||
): Promise<AuthSession> {
|
||||
if (!isCurrentApiConfig(context))
|
||||
throw new ApiRequestError('接口环境已变化,请重新操作');
|
||||
const state = readState(context);
|
||||
if (state.history === 'blocked' && !explicitStart)
|
||||
throw new ApiRequestError(
|
||||
'账户需要重新确认后才能使用',
|
||||
401,
|
||||
'ACCOUNT_RESTART_REQUIRED',
|
||||
);
|
||||
if (!force && usable(state.session)) {
|
||||
syncGlobal(context, state.session.user);
|
||||
return state.session;
|
||||
}
|
||||
if (state.flight) return state.flight;
|
||||
const generation = state.generation;
|
||||
const mode = explicitStart || state.history === 'new' ? 'start' : 'resume';
|
||||
const flight = (async () => {
|
||||
try {
|
||||
let result:
|
||||
| {
|
||||
accessToken: string;
|
||||
tokenType: string;
|
||||
expiresIn: number;
|
||||
expiresAt: string;
|
||||
user: ServerUser;
|
||||
}
|
||||
| undefined;
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
result = await requestApi(
|
||||
'/auth/wechat/login',
|
||||
{
|
||||
method: 'POST',
|
||||
data: { code: await wechatCode(), mode },
|
||||
},
|
||||
context,
|
||||
);
|
||||
break;
|
||||
} catch (error) {
|
||||
if (
|
||||
!(
|
||||
error instanceof ApiRequestError &&
|
||||
error.reason === 'WECHAT_CODE_INVALID' &&
|
||||
attempt === 0
|
||||
)
|
||||
)
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (
|
||||
!result ||
|
||||
result.tokenType !== 'Bearer' ||
|
||||
typeof result.accessToken !== 'string' ||
|
||||
!result.accessToken ||
|
||||
/\s/.test(result.accessToken) ||
|
||||
!Number.isSafeInteger(result.expiresIn) ||
|
||||
result.expiresIn < 60 ||
|
||||
result.expiresIn > 604800 ||
|
||||
!Number.isFinite(Date.parse(result.expiresAt))
|
||||
)
|
||||
throw new ApiRequestError('登录响应格式异常');
|
||||
const session = {
|
||||
accessToken: result.accessToken,
|
||||
expiresAt: Date.now() + result.expiresIn * 1000,
|
||||
user: parseServerUser(result.user),
|
||||
};
|
||||
// 清理、切换环境期间迟到的登录响应不得恢复会话或写入当前界面。
|
||||
if (state.generation !== generation || !isCurrentApiConfig(context))
|
||||
throw new ApiRequestError('登录状态已变化,请重新操作');
|
||||
state.session = session;
|
||||
state.history = 'used';
|
||||
persist(context, state);
|
||||
return session;
|
||||
} catch (error) {
|
||||
if (
|
||||
state.generation === generation &&
|
||||
isCurrentApiConfig(context) &&
|
||||
error instanceof ApiRequestError &&
|
||||
[
|
||||
'ACCOUNT_NOT_FOUND',
|
||||
'ACCOUNT_DISABLED',
|
||||
'WECHAT_LOGIN_BLOCKED',
|
||||
].includes(error.reason)
|
||||
) {
|
||||
state.session = null;
|
||||
state.history = 'blocked';
|
||||
persist(context, state);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
state.flight = flight;
|
||||
try {
|
||||
return await flight;
|
||||
} finally {
|
||||
if (state.flight === flight) state.flight = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** 保留各环境已保存会话和账户历史,只作废尚未完成的登录任务。 */
|
||||
export function invalidatePendingAuthentication() {
|
||||
for (const state of states.values()) {
|
||||
state.generation++;
|
||||
state.flight = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** 同一原请求只恢复一次;迟到的旧 401 直接复用较新凭证。 */
|
||||
export async function recoverSession(context: Context, failed: AuthSession) {
|
||||
const state = readState(context);
|
||||
if (
|
||||
usable(state.session) &&
|
||||
state.session.accessToken !== failed.accessToken
|
||||
)
|
||||
return state.session;
|
||||
// 已获得过凭证就只恢复账户,绝不在恢复失败后自动切回 start 建号。
|
||||
state.session = null;
|
||||
state.history = state.history === 'blocked' ? 'blocked' : 'used';
|
||||
persist(context, state);
|
||||
return ensureSession(context, true);
|
||||
}
|
||||
|
||||
/** 清理保留账户历史;注销/账号消失后只能由用户主动调用 startAccount 再建号。 */
|
||||
export function clearSession(blocked = false, context = getAuthContext()) {
|
||||
const state = readState(context);
|
||||
state.generation++;
|
||||
state.flight = undefined;
|
||||
state.session = null;
|
||||
state.history = blocked
|
||||
? 'blocked'
|
||||
: state.history === 'new'
|
||||
? 'new'
|
||||
: 'used';
|
||||
persist(context, state);
|
||||
}
|
||||
export function cachedServerUser(): ServerUser | null {
|
||||
const context = getAuthContext();
|
||||
return readState(context).session?.user || null;
|
||||
}
|
||||
/** GET /me 结果只更新同一环境、同一用户的现有会话,不混入旧云业务资料。 */
|
||||
export function updateSessionUser(
|
||||
value: unknown,
|
||||
context = getAuthContext(),
|
||||
): ServerUser {
|
||||
const user = parseServerUser(value);
|
||||
const state = readState(context);
|
||||
if (!state.session || state.session.user.id !== user.id)
|
||||
throw new ApiRequestError('账户已变化,请重新操作');
|
||||
state.session = { ...state.session, user };
|
||||
persist(context, state);
|
||||
return user;
|
||||
}
|
||||
|
||||
/** 只清理本次请求使用的凭证;旧请求迟到的错误不能撤销新登录。 */
|
||||
export function rejectSession(
|
||||
context: Context,
|
||||
token: string,
|
||||
blocked: boolean,
|
||||
) {
|
||||
if (readState(context).session?.accessToken === token)
|
||||
clearSession(blocked, context);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { PaperType } from '../config/worksheets/papers';
|
||||
import { apiRequest } from './api-request';
|
||||
|
||||
/** 小程序使用的公开接口字段,不复用旧云集合的 _id 和管理配置。 */
|
||||
export interface ApiCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
parentId: string | null;
|
||||
status: 'active';
|
||||
}
|
||||
|
||||
export interface ApiWorksheet {
|
||||
rendering?: { rendererKey: 'paper-sheet'; paperType: PaperType };
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
category: string;
|
||||
path: string;
|
||||
ageMin: number;
|
||||
ageMax: number;
|
||||
difficulty: 1 | 2 | 3 | 4;
|
||||
previewImg: string | null;
|
||||
likes: string;
|
||||
downloads: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
contentUpdatedAt: string | null;
|
||||
}
|
||||
|
||||
interface WorksheetPage {
|
||||
items: ApiWorksheet[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
hasNext: boolean;
|
||||
}
|
||||
|
||||
/** 分类直接由服务端发布状态决定,不用本地目录补齐或恢复下架分类。 */
|
||||
export async function listCategories(): Promise<ApiCategory[]> {
|
||||
const rows = await apiRequest<ApiCategory[]>('/categories', {
|
||||
auth: 'none',
|
||||
});
|
||||
if (
|
||||
!Array.isArray(rows) ||
|
||||
rows.some(
|
||||
(row) =>
|
||||
!row ||
|
||||
typeof row.id !== 'string' ||
|
||||
!row.id ||
|
||||
typeof row.name !== 'string' ||
|
||||
typeof row.icon !== 'string' ||
|
||||
row.status !== 'active',
|
||||
)
|
||||
) {
|
||||
throw new Error('分类数据异常,请稍后重试');
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 展示计数保持十进制字符串,不转换 Number 或重复叠加展示种子。 */
|
||||
function validateWorksheet(row: ApiWorksheet): void {
|
||||
if (
|
||||
!row ||
|
||||
!row.id ||
|
||||
typeof row.id !== 'string' ||
|
||||
typeof row.title !== 'string' ||
|
||||
typeof row.subtitle !== 'string' ||
|
||||
typeof row.category !== 'string' ||
|
||||
typeof row.path !== 'string' ||
|
||||
!Number.isInteger(row.ageMin) ||
|
||||
!Number.isInteger(row.ageMax) ||
|
||||
row.ageMin < 0 ||
|
||||
row.ageMax < row.ageMin ||
|
||||
![1, 2, 3, 4].includes(row.difficulty) ||
|
||||
typeof row.downloads !== 'string' ||
|
||||
!/^\d+$/.test(row.downloads) ||
|
||||
typeof row.likes !== 'string' ||
|
||||
!/^\d+$/.test(row.likes) ||
|
||||
(row.previewImg !== null && typeof row.previewImg !== 'string')
|
||||
) {
|
||||
throw new Error('题型数据异常,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
/** 取完整分页后才能在客户端计算收藏榜和混合排序;分页变化或缺页时整次重试。 */
|
||||
export async function listAllWorksheets(): Promise<ApiWorksheet[]> {
|
||||
const pageSize = 100;
|
||||
const rows: ApiWorksheet[] = [];
|
||||
const ids = new Set<string>();
|
||||
let expectedTotal: number | undefined;
|
||||
for (let page = 1; ; page++) {
|
||||
const result = await apiRequest<WorksheetPage>('/worksheets', {
|
||||
auth: 'none',
|
||||
data: { page, pageSize },
|
||||
});
|
||||
if (
|
||||
!result ||
|
||||
!Array.isArray(result.items) ||
|
||||
!Number.isSafeInteger(result.total) ||
|
||||
result.total < 0 ||
|
||||
result.page !== page ||
|
||||
result.pageSize !== pageSize ||
|
||||
result.hasNext !== page * pageSize < result.total ||
|
||||
result.items.length !==
|
||||
Math.max(
|
||||
0,
|
||||
Math.min(pageSize, result.total - (page - 1) * pageSize),
|
||||
) ||
|
||||
(expectedTotal !== undefined && result.total !== expectedTotal)
|
||||
) {
|
||||
throw new Error('题型列表已变化,请重试');
|
||||
}
|
||||
expectedTotal = result.total;
|
||||
for (const row of result.items) {
|
||||
validateWorksheet(row);
|
||||
if (ids.has(row.id)) throw new Error('题型列表已变化,请重试');
|
||||
ids.add(row.id);
|
||||
rows.push(row);
|
||||
}
|
||||
if (!result.hasNext) return rows;
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开题型前重新检查公开详情,避免已下架内容从旧列表继续进入。 */
|
||||
export async function getWorksheet(id: string): Promise<ApiWorksheet> {
|
||||
const row = await apiRequest<ApiWorksheet>(
|
||||
`/worksheets/${encodeURIComponent(id)}`,
|
||||
{ auth: 'none' },
|
||||
);
|
||||
validateWorksheet(row);
|
||||
if (row.id !== id) throw new Error('题型数据异常,请稍后重试');
|
||||
return row;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
getApiEnvironment,
|
||||
selectApiEnvironment,
|
||||
type ApiEnvironment,
|
||||
} from '../config/api';
|
||||
import { hasPendingApiWrites } from './api-activity';
|
||||
import { invalidatePendingAuthentication } from './auth-session';
|
||||
|
||||
let switching = false;
|
||||
|
||||
/** 统一切换入口:写入中不切换,保留分环境会话,重建全部页面以丢弃旧业务状态。 */
|
||||
export async function switchApiEnvironment(environment: ApiEnvironment) {
|
||||
if (wx.getAccountInfoSync().miniProgram.envVersion !== 'develop')
|
||||
throw new Error('仅开发版可切换环境');
|
||||
if (switching) throw new Error('正在切换环境,请稍候');
|
||||
if (getApiEnvironment() === environment) return false;
|
||||
const busyPage = getCurrentPages().some(
|
||||
(page) => page.data.debugPublishLoading || page.data.isSavingProfile,
|
||||
);
|
||||
if (hasPendingApiWrites() || busyPage)
|
||||
throw new Error('请等待当前保存或上传完成后再切换');
|
||||
switching = true;
|
||||
try {
|
||||
selectApiEnvironment(environment);
|
||||
invalidatePendingAuthentication();
|
||||
const app = getApp<IAppOption>();
|
||||
app.resetApiState();
|
||||
// reLaunch 只重建页面,不重新执行 App.onLaunch;目标数据需要主动初始化。
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
wx.reLaunch({
|
||||
url: '/pages/home/home',
|
||||
success: () => resolve(),
|
||||
fail: () => reject(new Error('环境已切换,请重新进入首页')),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
app.warmAuthentication();
|
||||
if (!(await app.loadPageConfig(true)))
|
||||
throw new Error('已切换,目标接口连接失败');
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
switching = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { apiRequest } from './api-request';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import type { ApiWorksheet } from './content-api';
|
||||
export interface UserStats {
|
||||
favoriteCount: number;
|
||||
downloadCount: number;
|
||||
totalDownloads: string;
|
||||
}
|
||||
export interface MemberRecord {
|
||||
id: string;
|
||||
worksheetId: string;
|
||||
exportId?: string | null;
|
||||
createdAt: string;
|
||||
worksheet: ApiWorksheet | null;
|
||||
}
|
||||
export interface MemberPage {
|
||||
items: MemberRecord[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
hasNext: boolean;
|
||||
}
|
||||
export const memberStats = () => apiRequest<UserStats>('/me/stats');
|
||||
/** uploadId 在一次操作中固定;401 重发沿用同一 ID,网络失败不自动重复上传。 */
|
||||
export async function uploadAsset(
|
||||
filePath: string,
|
||||
purpose: 'avatar' | 'preview',
|
||||
) {
|
||||
return apiRequest<{
|
||||
id: string;
|
||||
url: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
}>(
|
||||
purpose === 'avatar'
|
||||
? '/me/avatar-upload'
|
||||
: '/admin/assets/preview-upload',
|
||||
{ method: 'POST', upload: { filePath, uploadId: generateUUID() } },
|
||||
);
|
||||
}
|
||||
export function legacyWorksheet(row: ApiWorksheet | null) {
|
||||
return row
|
||||
? {
|
||||
...row,
|
||||
_id: row.id,
|
||||
subcategory:
|
||||
(row as ApiWorksheet & { subcategory?: string | null })
|
||||
.subcategory || '',
|
||||
tags: (row as ApiWorksheet & { tags?: string[] }).tags || [],
|
||||
previewImg: row.previewImg || '',
|
||||
}
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { apiRequest } from './api-request';
|
||||
import type { ApiWorksheet } from './content-api';
|
||||
/** 保留微信预拉取,动态兜底与后台刷新改由新后端提供。 */
|
||||
export function fetchPageConfig() {
|
||||
return apiRequest<PageConfigData>('/page-config', { auth: 'none' });
|
||||
}
|
||||
export function fetchAgeRecommendations(ageBand: string) {
|
||||
return apiRequest<{ ageBand: string; items: ApiWorksheet[] }>(
|
||||
'/recommendations/age',
|
||||
{ auth: 'none', data: { ageBand } },
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { compareCountDescending } from '../../pages/category/category.model';
|
||||
import { managementRequest } from '../../services/admin-api';
|
||||
import { resolvePreviewUrl } from '../../config/assets';
|
||||
import { CATEGORY_LIST } from '../../core/data/categories';
|
||||
|
||||
type WorksheetItem = {
|
||||
@@ -13,8 +16,8 @@ type WorksheetItem = {
|
||||
tagsText: string;
|
||||
status: 'draft' | 'active' | 'hidden';
|
||||
sortOrder: number;
|
||||
downloads: number;
|
||||
likes: number;
|
||||
downloads: string;
|
||||
likes: string;
|
||||
updatedAt: string;
|
||||
// display helpers
|
||||
ageBand: string;
|
||||
@@ -32,13 +35,6 @@ type Stats = {
|
||||
hidden: number;
|
||||
};
|
||||
|
||||
type CloudFunctionResult<T> = {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
data?: T;
|
||||
stats?: Stats;
|
||||
};
|
||||
|
||||
const DIFFICULTY_LABELS: Record<number, string> = {
|
||||
1: '入门',
|
||||
2: '基础',
|
||||
@@ -82,7 +78,7 @@ function formatWorksheet(raw: Record<string, unknown>): WorksheetItem {
|
||||
title: String(raw.title || ''),
|
||||
subtitle: String(raw.subtitle || ''),
|
||||
category: String(raw.category || ''),
|
||||
previewImg: String(raw.previewImg || ''),
|
||||
previewImg: resolvePreviewUrl(raw.previewImg),
|
||||
ageMin,
|
||||
ageMax,
|
||||
difficulty,
|
||||
@@ -92,8 +88,8 @@ function formatWorksheet(raw: Record<string, unknown>): WorksheetItem {
|
||||
: '',
|
||||
status: status as WorksheetItem['status'],
|
||||
sortOrder: Number(raw.sortOrder) || 0,
|
||||
downloads: Number(raw.downloads) || 0,
|
||||
likes: Number(raw.likes) || 0,
|
||||
downloads: String(raw.downloads || '0'),
|
||||
likes: String(raw.likes || '0'),
|
||||
updatedAt: raw.updatedAt ? String(raw.updatedAt) : '',
|
||||
ageBand: `${ageMin}-${ageMax}岁`,
|
||||
difficultyLabel: DIFFICULTY_LABELS[difficulty] || '基础',
|
||||
@@ -104,17 +100,6 @@ function formatWorksheet(raw: Record<string, unknown>): WorksheetItem {
|
||||
};
|
||||
}
|
||||
|
||||
async function callCloudFunction<T>(
|
||||
name: string,
|
||||
data?: Record<string, unknown>,
|
||||
): Promise<CloudFunctionResult<T>> {
|
||||
const response = (await wx.cloud.callFunction({
|
||||
name,
|
||||
data: data || {},
|
||||
})) as { result?: CloudFunctionResult<T> };
|
||||
return response.result || {};
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
categories: CATEGORY_LIST,
|
||||
@@ -139,8 +124,8 @@ Page({
|
||||
this.setData({ loading: true });
|
||||
|
||||
try {
|
||||
const result = await callCloudFunction<WorksheetItem[]>(
|
||||
'worksheetsQuery',
|
||||
const result = await managementRequest<WorksheetItem[]>(
|
||||
'worksheets.list',
|
||||
{
|
||||
category: this.data.activeCategoryId,
|
||||
rawStats: true,
|
||||
@@ -155,7 +140,9 @@ Page({
|
||||
.map((raw) =>
|
||||
formatWorksheet(raw as unknown as Record<string, unknown>),
|
||||
)
|
||||
.sort((a, b) => b.downloads - a.downloads);
|
||||
.sort((a, b) =>
|
||||
compareCountDescending(a.downloads, b.downloads),
|
||||
);
|
||||
|
||||
this.setData({
|
||||
loading: false,
|
||||
@@ -204,7 +191,7 @@ Page({
|
||||
this.setData({ updatingId: id });
|
||||
|
||||
try {
|
||||
const result = await callCloudFunction('worksheetsUpdateStatus', {
|
||||
const result = await managementRequest('worksheets.status', {
|
||||
id,
|
||||
status: target,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { managementRequest } from '../../services/admin-api';
|
||||
import { CATEGORY_LIST } from '../../core/data/categories';
|
||||
|
||||
type CloudCategory = {
|
||||
@@ -23,12 +24,6 @@ type CompareItem = {
|
||||
cloudCategory: CloudCategory | null;
|
||||
};
|
||||
|
||||
type CloudFunctionResult<T> = {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
data?: T;
|
||||
};
|
||||
|
||||
const DEFAULT_CATEGORY_COLORS = [
|
||||
'#F7EE47',
|
||||
'#93D333',
|
||||
@@ -133,18 +128,6 @@ function buildCompareItems(cloudCategories: CloudCategory[]): {
|
||||
};
|
||||
}
|
||||
|
||||
async function callCategoryFunction<T>(
|
||||
name: string,
|
||||
data?: Record<string, unknown>,
|
||||
): Promise<CloudFunctionResult<T>> {
|
||||
const response = (await wx.cloud.callFunction({
|
||||
name,
|
||||
data: data || {},
|
||||
})) as { result?: CloudFunctionResult<T> };
|
||||
|
||||
return response.result || {};
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
loading: false,
|
||||
@@ -170,7 +153,7 @@ Page({
|
||||
|
||||
try {
|
||||
const result =
|
||||
await callCategoryFunction<CloudCategory[]>('categoriesQuery');
|
||||
await managementRequest<CloudCategory[]>('categories.list');
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || '获取分类失败');
|
||||
@@ -204,7 +187,7 @@ Page({
|
||||
const confirmed = await new Promise<boolean>((resolve) => {
|
||||
wx.showModal({
|
||||
title: `${item.actionText}分类`,
|
||||
content: `确认将「${item.name}」同步到云端吗?`,
|
||||
content: `确认将「${item.name}」同步到新后端吗?`,
|
||||
success: (res) => resolve(!!res.confirm),
|
||||
fail: () => resolve(false),
|
||||
});
|
||||
@@ -228,9 +211,9 @@ Page({
|
||||
try {
|
||||
const functionName =
|
||||
item.status === 'missing'
|
||||
? 'categoriesCreate'
|
||||
: 'categoriesUpdate';
|
||||
const result = await callCategoryFunction<CloudCategory>(
|
||||
? 'categories.create'
|
||||
: 'categories.update';
|
||||
const result = await managementRequest<CloudCategory>(
|
||||
functionName,
|
||||
payload,
|
||||
);
|
||||
|
||||
@@ -12,28 +12,6 @@ page {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.debug-page__hero {
|
||||
padding: 48rpx 40rpx;
|
||||
background: @bg-white;
|
||||
border-radius: @radius-xl;
|
||||
box-shadow: @shadow;
|
||||
}
|
||||
|
||||
.debug-page__hero-title {
|
||||
display: block;
|
||||
font-size: 40rpx;
|
||||
font-weight: 800;
|
||||
color: @text-title;
|
||||
}
|
||||
|
||||
.debug-page__hero-desc {
|
||||
display: block;
|
||||
margin-top: 16rpx;
|
||||
font-size: 26rpx;
|
||||
line-height: 1.6;
|
||||
color: @text-secondary;
|
||||
}
|
||||
|
||||
.debug-page__section {
|
||||
margin-top: 32rpx;
|
||||
}
|
||||
@@ -125,3 +103,65 @@ page {
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.debug-page__environment {
|
||||
padding: 32rpx;
|
||||
background: @bg-white;
|
||||
border-radius: @radius-xl;
|
||||
box-shadow: @shadow;
|
||||
}
|
||||
|
||||
.debug-page__environment-url {
|
||||
display: block;
|
||||
word-break: break-all;
|
||||
color: @text-secondary;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.debug-page__environment-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
/* 明确按钮高度及整行点击区域,避免原生 button 被全局样式压缩。 */
|
||||
.debug-page__environment-options .debug-environment-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 96rpx;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0 24rpx;
|
||||
border: 0;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.4;
|
||||
color: @text-title;
|
||||
background: @bg-header;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
.debug-page__environment-options .debug-environment-option::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.debug-page__environment-options
|
||||
.debug-environment-option
|
||||
+ .debug-environment-option {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.debug-page__environment-options .debug-environment-option--selected {
|
||||
background: @brand;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.debug-page__environment-note {
|
||||
display: block;
|
||||
margin-top: 20rpx;
|
||||
color: @text-secondary;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import {
|
||||
getApiEnvironment,
|
||||
getApiEnvironmentOptions,
|
||||
isApiEnvironment,
|
||||
} from '../../config/api';
|
||||
import { switchApiEnvironment } from '../../services/environment-switch';
|
||||
|
||||
type DebugEntry = {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -47,7 +54,45 @@ const DEBUG_ENTRIES: DebugEntry[] = [
|
||||
Page({
|
||||
data: {
|
||||
entries: DEBUG_ENTRIES,
|
||||
isDevEnv: true,
|
||||
isDevEnv: false,
|
||||
currentEnvironment: '',
|
||||
currentBaseUrl: '',
|
||||
environments: getApiEnvironmentOptions(),
|
||||
isSwitchingEnvironment: false,
|
||||
},
|
||||
|
||||
onShow() {
|
||||
const environment = getApiEnvironment();
|
||||
const environments = getApiEnvironmentOptions();
|
||||
this.setData({
|
||||
currentEnvironment: environment,
|
||||
currentBaseUrl:
|
||||
environments.find((item) => item.environment === environment)
|
||||
?.baseUrl || '',
|
||||
environments,
|
||||
});
|
||||
},
|
||||
|
||||
async onSelectEnvironment(e: WechatMiniprogram.TouchEvent) {
|
||||
if (!this.data.isDevEnv || this.data.isSwitchingEnvironment) return;
|
||||
const environment: unknown = e.currentTarget.dataset.environment;
|
||||
if (!isApiEnvironment(environment)) return;
|
||||
this.setData({ isSwitchingEnvironment: true });
|
||||
wx.showLoading({ title: '切换环境中', mask: true });
|
||||
let errorMessage = '';
|
||||
try {
|
||||
await switchApiEnvironment(environment);
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof Error ? error.message : '切换失败';
|
||||
} finally {
|
||||
wx.hideLoading();
|
||||
// 成功时本页已被 reLaunch 销毁,失败时才需要恢复按钮。
|
||||
if (getCurrentPages().includes(this)) {
|
||||
this.setData({ isSwitchingEnvironment: false });
|
||||
this.onShow();
|
||||
}
|
||||
}
|
||||
if (errorMessage) wx.showToast({ title: errorMessage, icon: 'none' });
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
<view class="debug-page">
|
||||
<view class="debug-page__hero">
|
||||
<text class="debug-page__hero-title">Debug 工具页</text>
|
||||
<text class="debug-page__hero-desc">
|
||||
仅开发环境可见,用于发布前检查和同步调试数据。
|
||||
</text>
|
||||
<view wx:if="{{isDevEnv}}" class="debug-page__environment">
|
||||
<text class="debug-page__section-title"
|
||||
>接口环境 · {{currentEnvironment}}</text
|
||||
>
|
||||
<text class="debug-page__environment-url">{{currentBaseUrl}}</text>
|
||||
<view class="debug-page__environment-options">
|
||||
<button
|
||||
wx:for="{{environments}}"
|
||||
wx:key="environment"
|
||||
class="debug-environment-option {{currentEnvironment === item.environment ? 'debug-environment-option--selected' : ''}}"
|
||||
data-environment="{{item.environment}}"
|
||||
disabled="{{!item.available || isSwitchingEnvironment}}"
|
||||
bindtap="onSelectEnvironment">
|
||||
{{item.environment}}
|
||||
<text wx:if="{{!item.available}}">(未配置)</text>
|
||||
</button>
|
||||
</view>
|
||||
<text class="debug-page__environment-note"
|
||||
>切换后自动返回首页;下次打开开发版继续使用所选环境。</text
|
||||
>
|
||||
</view>
|
||||
|
||||
<view class="debug-page__section">
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { compareCountDescending } from '../../pages/category/category.model';
|
||||
import { apiRequest } from '../../services/api-request';
|
||||
import { managementRequest } from '../../services/admin-api';
|
||||
import { resolvePreviewUrl } from '../../config/assets';
|
||||
import { CATEGORY_LIST } from '../../core/data/categories';
|
||||
|
||||
type SimpleWorksheet = {
|
||||
@@ -13,8 +17,8 @@ type SimpleWorksheet = {
|
||||
isHot: boolean;
|
||||
path: string;
|
||||
ageBand: string;
|
||||
downloads: number;
|
||||
likes: number;
|
||||
downloads: string;
|
||||
likes: string;
|
||||
};
|
||||
|
||||
type SectionConfig = {
|
||||
@@ -43,12 +47,6 @@ type PageConfig = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type CloudFunctionResult<T> = {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
data?: T;
|
||||
};
|
||||
|
||||
function formatSimple(raw: Record<string, unknown>): SimpleWorksheet {
|
||||
const ageMin = Number(raw.ageMin) || 0;
|
||||
const ageMax = Number(raw.ageMax) || 0;
|
||||
@@ -57,7 +55,7 @@ function formatSimple(raw: Record<string, unknown>): SimpleWorksheet {
|
||||
title: String(raw.title || ''),
|
||||
subtitle: String(raw.subtitle || ''),
|
||||
category: String(raw.category || ''),
|
||||
previewImg: String(raw.previewImg || ''),
|
||||
previewImg: resolvePreviewUrl(raw.previewImg),
|
||||
ageMin,
|
||||
ageMax,
|
||||
difficulty: Number(raw.difficulty) || 2,
|
||||
@@ -65,22 +63,11 @@ function formatSimple(raw: Record<string, unknown>): SimpleWorksheet {
|
||||
isHot: !!raw.isHot,
|
||||
path: String(raw.path || ''),
|
||||
ageBand: `${ageMin}-${ageMax}岁`,
|
||||
downloads: Number(raw.downloads) || 0,
|
||||
likes: Number(raw.likes) || 0,
|
||||
downloads: String(raw.downloads || '0'),
|
||||
likes: String(raw.likes || '0'),
|
||||
};
|
||||
}
|
||||
|
||||
async function callCloudFunction<T>(
|
||||
name: string,
|
||||
data?: Record<string, unknown>,
|
||||
): Promise<CloudFunctionResult<T>> {
|
||||
const response = (await wx.cloud.callFunction({
|
||||
name,
|
||||
data: data || {},
|
||||
})) as { result?: CloudFunctionResult<T> };
|
||||
return response.result || {};
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
featured: [] as SimpleWorksheet[],
|
||||
@@ -122,15 +109,15 @@ Page({
|
||||
|
||||
async loadTimerStatus() {
|
||||
try {
|
||||
const db = wx.cloud.database();
|
||||
const { data } = await db
|
||||
.collection('settings')
|
||||
.doc('homeAutoRefresh')
|
||||
.get();
|
||||
this.setData({ timerEnabled: (data as { enabled?: boolean }).enabled !== false });
|
||||
const data = await apiRequest<{ enabled: boolean }>(
|
||||
'/admin/site/timer',
|
||||
);
|
||||
this.setData({
|
||||
timerEnabled: (data as { enabled?: boolean }).enabled !== false,
|
||||
});
|
||||
} catch {
|
||||
// Document doesn't exist — default enabled
|
||||
this.setData({ timerEnabled: true });
|
||||
// 权限或网络失败不能被当成已启用。
|
||||
this.setData({ timerEnabled: false });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -139,13 +126,14 @@ Page({
|
||||
try {
|
||||
// Fetch in parallel: active worksheets + saved page config
|
||||
const [wsResult, configResult] = await Promise.all([
|
||||
callCloudFunction<SimpleWorksheet[]>('worksheetsQuery', {
|
||||
managementRequest<SimpleWorksheet[]>('worksheets.list', {
|
||||
status: 'active',
|
||||
}),
|
||||
callCloudFunction<PageConfig>('pageConfigFetch'),
|
||||
managementRequest<PageConfig>('site.config'),
|
||||
]);
|
||||
|
||||
if (!wsResult.success) throw new Error(wsResult.message || '查询失败');
|
||||
if (!wsResult.success)
|
||||
throw new Error(wsResult.message || '查询失败');
|
||||
|
||||
const allActive = (wsResult.data || []).map((raw) =>
|
||||
formatSimple(raw as unknown as Record<string, unknown>),
|
||||
@@ -156,16 +144,21 @@ Page({
|
||||
|
||||
// featured: always top-5 by downloads; hot: always top-5 by likes
|
||||
const featured: SimpleWorksheet[] = [...allActive]
|
||||
.sort((a, b) => (b.downloads ?? 0) - (a.downloads ?? 0))
|
||||
.sort((a, b) =>
|
||||
compareCountDescending(a.downloads, b.downloads),
|
||||
)
|
||||
.slice(0, 5);
|
||||
const hot: SimpleWorksheet[] = [...allActive]
|
||||
.sort((a, b) => (b.likes ?? 0) - (a.likes ?? 0))
|
||||
.sort((a, b) => compareCountDescending(a.likes, b.likes))
|
||||
.slice(0, 5);
|
||||
|
||||
// Restore saved sections from config
|
||||
const savedHome = (configResult.data as PageConfig | undefined)?.home;
|
||||
const savedHome = (configResult.data as PageConfig | undefined)
|
||||
?.home;
|
||||
const savedSections: HomeConfigItem[] = savedHome?.sections || [];
|
||||
const savedSectionMap = new Map(savedSections.map((s) => [s.id, s]));
|
||||
const savedSectionMap = new Map(
|
||||
savedSections.map((s) => [s.id, s]),
|
||||
);
|
||||
|
||||
const sections: SectionConfig[] = CATEGORY_LIST.map((c) => {
|
||||
const saved = savedSectionMap.get(c.id);
|
||||
@@ -184,7 +177,13 @@ Page({
|
||||
};
|
||||
});
|
||||
|
||||
this.setData({ loading: false, allActive, featured, hot, sections });
|
||||
this.setData({
|
||||
loading: false,
|
||||
allActive,
|
||||
featured,
|
||||
hot,
|
||||
sections,
|
||||
});
|
||||
} catch (error) {
|
||||
this.setData({ loading: false });
|
||||
wx.showToast({
|
||||
@@ -202,7 +201,7 @@ Page({
|
||||
|
||||
this.setData({ timerLoading: true });
|
||||
try {
|
||||
const result = await callCloudFunction('homeTimerControl', {
|
||||
const result = await managementRequest('site.timer', {
|
||||
enabled: newEnabled,
|
||||
});
|
||||
if (!result.success) {
|
||||
@@ -335,9 +334,9 @@ Page({
|
||||
this.setData({ building: true });
|
||||
|
||||
try {
|
||||
const result = await callCloudFunction<{
|
||||
const result = await managementRequest<{
|
||||
version: number;
|
||||
}>('pageContentBuild', {
|
||||
}>('site.rebuild', {
|
||||
page: 'home',
|
||||
featuredIds: this.data.featured.map((w) => w._id),
|
||||
hotIds: this.data.hot.map((w) => w._id),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { apiRequest } from '../../services/api-request';
|
||||
import { disablePageShareMenu } from '../../base/pageMixin';
|
||||
import { clearUser } from '../../utils/auth';
|
||||
import { clearUser, refreshUser } from '../../utils/auth';
|
||||
|
||||
Page({
|
||||
onLoad() {
|
||||
@@ -22,15 +23,8 @@ Page({
|
||||
async doClearProfile() {
|
||||
wx.showLoading({ title: '处理中...', mask: true });
|
||||
try {
|
||||
const res = await wx.cloud.callFunction({
|
||||
name: 'userSettings',
|
||||
data: { action: 'clearProfile' },
|
||||
});
|
||||
const result = (res?.result as any) || {};
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.message || '操作失败');
|
||||
}
|
||||
clearUser();
|
||||
await apiRequest('/me/profile', { method: 'DELETE' });
|
||||
await refreshUser();
|
||||
wx.hideLoading();
|
||||
wx.showToast({ title: '已删除', icon: 'success' });
|
||||
} catch (e) {
|
||||
@@ -58,15 +52,8 @@ Page({
|
||||
async doDeleteAccount() {
|
||||
wx.showLoading({ title: '处理中...', mask: true });
|
||||
try {
|
||||
const res = await wx.cloud.callFunction({
|
||||
name: 'userSettings',
|
||||
data: { action: 'deleteAccount' },
|
||||
});
|
||||
const result = (res?.result as any) || {};
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.message || '操作失败');
|
||||
}
|
||||
clearUser();
|
||||
await apiRequest('/me', { method: 'DELETE' });
|
||||
clearUser(true);
|
||||
wx.hideLoading();
|
||||
|
||||
wx.reLaunch({ url: '/pages/home/home' });
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { managementRequest } from '../../services/admin-api';
|
||||
import { MOCK_SEED_COUNTS } from './mockLikes';
|
||||
import {
|
||||
MATH_WORKSHEET_DEFINITIONS,
|
||||
@@ -21,17 +22,6 @@ type CloudFunctionResult<T> = {
|
||||
results?: Array<{ id: string | null; ok: boolean; error?: string }>;
|
||||
};
|
||||
|
||||
async function callCloudFunction<T>(
|
||||
name: string,
|
||||
data?: Record<string, unknown>,
|
||||
): Promise<CloudFunctionResult<T>> {
|
||||
const response = (await wx.cloud.callFunction({
|
||||
name,
|
||||
data: data || {},
|
||||
})) as { result?: CloudFunctionResult<T> };
|
||||
return response.result || {};
|
||||
}
|
||||
|
||||
function inferGradeFromAge(ageMin: number, ageMax: number): number {
|
||||
const centerAge = Math.round((ageMin + ageMax) / 2);
|
||||
const ageGradeMap: Record<number, number> = {
|
||||
@@ -224,7 +214,7 @@ Page({
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
const patchRes = await callCloudFunction('worksheetsBatchPatch', {
|
||||
const patchRes = await managementRequest('worksheets.batch', {
|
||||
patches,
|
||||
dryRun: false,
|
||||
});
|
||||
@@ -262,7 +252,7 @@ Page({
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
const rebuildRes = await callCloudFunction('pageContentBuild', {
|
||||
const rebuildRes = await managementRequest('site.rebuild', {
|
||||
page: 'all',
|
||||
});
|
||||
const elapsed = Math.round((Date.now() - startedAt) / 1000);
|
||||
@@ -358,7 +348,7 @@ Page({
|
||||
}),
|
||||
);
|
||||
|
||||
const patchRes = await callCloudFunction('worksheetsBatchPatch', {
|
||||
const patchRes = await managementRequest('worksheets.batch', {
|
||||
patches,
|
||||
dryRun: false,
|
||||
});
|
||||
@@ -415,8 +405,8 @@ Page({
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
const queryRes = await callCloudFunction<Array<{ _id: string }>>(
|
||||
'worksheetsQuery',
|
||||
const queryRes = await managementRequest<Array<{ _id: string }>>(
|
||||
'worksheets.list',
|
||||
{},
|
||||
);
|
||||
|
||||
@@ -438,7 +428,7 @@ Page({
|
||||
return;
|
||||
}
|
||||
|
||||
const patchRes = await callCloudFunction('worksheetsBatchPatch', {
|
||||
const patchRes = await managementRequest('worksheets.batch', {
|
||||
patches,
|
||||
dryRun: false,
|
||||
});
|
||||
|
||||
+39
-148
@@ -1,165 +1,56 @@
|
||||
interface UserInfo {
|
||||
_id: string;
|
||||
openid: string;
|
||||
unionid: string | null;
|
||||
nickName: string | null;
|
||||
avatarUrl: string | null;
|
||||
totalDownloads: number;
|
||||
createdAt: string;
|
||||
lastActiveAt: string;
|
||||
}
|
||||
import { apiRequest } from '../services/api-request';
|
||||
import {
|
||||
cachedServerUser,
|
||||
clearSession,
|
||||
ensureSession,
|
||||
getAuthContext,
|
||||
updateSessionUser,
|
||||
type ServerUser,
|
||||
} from '../services/auth-session';
|
||||
|
||||
const STORAGE_KEY = 'user_info';
|
||||
const STORAGE_EXPIRE_KEY = 'user_info_expire';
|
||||
const EXPIRE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 天
|
||||
export type UserInfo = ServerUser;
|
||||
export { ensureSession as ensureAuthenticated } from '../services/auth-session';
|
||||
|
||||
/** 内存中的用户数据(App 生命周期内有效) */
|
||||
let currentUser: UserInfo | null = null;
|
||||
|
||||
/** 登录进行中的 Promise(防止并发重复调用) */
|
||||
let loginPromise: Promise<UserInfo> | null = null;
|
||||
|
||||
function syncAppGlobalUser(user: UserInfo | null): void {
|
||||
try {
|
||||
const app = getApp<IAppOption>();
|
||||
if (app?.globalData) {
|
||||
app.globalData.user = user;
|
||||
}
|
||||
} catch {
|
||||
// App 尚未初始化时忽略
|
||||
}
|
||||
}
|
||||
|
||||
function syncUserState(user: UserInfo, persist = true): UserInfo {
|
||||
currentUser = user;
|
||||
if (persist) {
|
||||
saveToStorage(user);
|
||||
}
|
||||
syncAppGlobalUser(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户,优先内存 → Storage → 云函数登录
|
||||
*/
|
||||
/** 用户摘要与新后端认证绑定,所有远端用户业务只使用本站用户 ID。 */
|
||||
export async function getUser(): Promise<UserInfo> {
|
||||
// 1. 内存中有,直接返回
|
||||
if (currentUser) {
|
||||
syncAppGlobalUser(currentUser);
|
||||
return currentUser;
|
||||
}
|
||||
|
||||
// 2. 尝试从 Storage 读取
|
||||
const cached = loadFromStorage();
|
||||
if (cached) {
|
||||
return syncUserState(cached, false);
|
||||
}
|
||||
|
||||
// 3. 静默登录(防止并发)
|
||||
if (!loginPromise) {
|
||||
loginPromise = silentLogin().finally(() => {
|
||||
loginPromise = null;
|
||||
});
|
||||
}
|
||||
return loginPromise;
|
||||
return (await ensureSession()).user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 静默登录:调用云函数
|
||||
*/
|
||||
async function silentLogin(): Promise<UserInfo> {
|
||||
const res = await wx.cloud.callFunction({
|
||||
name: 'userLogin',
|
||||
});
|
||||
const result = (res?.result as any) || {};
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error('登录失败');
|
||||
}
|
||||
|
||||
const user = result.data as UserInfo;
|
||||
return syncUserState(user);
|
||||
}
|
||||
|
||||
/** 缓存只供展示,不能证明服务端仍认可当前凭证。 */
|
||||
export function getCachedUser(): UserInfo | null {
|
||||
if (currentUser) {
|
||||
syncAppGlobalUser(currentUser);
|
||||
return currentUser;
|
||||
}
|
||||
|
||||
const cached = loadFromStorage();
|
||||
if (!cached) return null;
|
||||
return syncUserState(cached, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Storage 读取用户数据(检查过期)
|
||||
*/
|
||||
function loadFromStorage(): UserInfo | null {
|
||||
try {
|
||||
const expire = wx.getStorageSync(STORAGE_EXPIRE_KEY);
|
||||
if (!expire || Date.now() > expire) {
|
||||
wx.removeStorageSync(STORAGE_KEY);
|
||||
wx.removeStorageSync(STORAGE_EXPIRE_KEY);
|
||||
return null;
|
||||
}
|
||||
const user = wx.getStorageSync(STORAGE_KEY);
|
||||
return user || null;
|
||||
return cachedServerUser();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入 Storage
|
||||
*/
|
||||
function saveToStorage(user: UserInfo): void {
|
||||
try {
|
||||
wx.setStorageSync(STORAGE_KEY, user);
|
||||
wx.setStorageSync(STORAGE_EXPIRE_KEY, Date.now() + EXPIRE_DURATION);
|
||||
} catch {
|
||||
// Storage 写入失败不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除登录状态
|
||||
*/
|
||||
export function clearUser(): void {
|
||||
currentUser = null;
|
||||
syncAppGlobalUser(null);
|
||||
wx.removeStorageSync(STORAGE_KEY);
|
||||
wx.removeStorageSync(STORAGE_EXPIRE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制刷新用户数据(跳过缓存)
|
||||
*/
|
||||
/** 读取本人资料;GET /me 会按统一规则完成过期与 401 恢复。 */
|
||||
export async function refreshUser(): Promise<UserInfo> {
|
||||
clearUser();
|
||||
return await silentLogin();
|
||||
const context = getAuthContext();
|
||||
const user = await apiRequest<UserInfo>('/me');
|
||||
if (getAuthContext().key !== context.key)
|
||||
throw new Error('接口环境已变化,请重新操作');
|
||||
return updateSessionUser(user, context);
|
||||
}
|
||||
/** 暂停账户自动恢复时保留标记,不能通过清缓存自动注册。 */
|
||||
export function clearUser(blocked = false): void {
|
||||
clearSession(blocked);
|
||||
}
|
||||
/** 仅供用户主动确认“重新开始使用账户”的动作调用,禁止在失败回调自动调用。 */
|
||||
export async function startAccount(): Promise<UserInfo> {
|
||||
return (await ensureSession(getAuthContext(), true, true)).user;
|
||||
}
|
||||
|
||||
/** 本人资料更新只提交头像资产 ID,不允许传任意外链或用户身份字段。 */
|
||||
export async function updateUserProfile(payload: {
|
||||
nickName?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
nickName?: string;
|
||||
avatarAssetId?: string | null;
|
||||
}): Promise<UserInfo> {
|
||||
await getUser();
|
||||
|
||||
const res = await wx.cloud.callFunction({
|
||||
name: 'userLogin',
|
||||
data: {
|
||||
action: 'updateProfile',
|
||||
...payload,
|
||||
},
|
||||
const context = getAuthContext();
|
||||
const user = await apiRequest<UserInfo>('/me/profile', {
|
||||
method: 'PUT',
|
||||
data: payload,
|
||||
});
|
||||
const result = (res?.result as any) || {};
|
||||
|
||||
if (result.code !== 0 || !result.data) {
|
||||
throw new Error(result.message || '更新用户资料失败');
|
||||
}
|
||||
|
||||
return syncUserState(result.data as UserInfo);
|
||||
if (getAuthContext().key !== context.key)
|
||||
throw new Error('接口环境已变化,请重新操作');
|
||||
return updateSessionUser(user, context);
|
||||
}
|
||||
|
||||
export type { UserInfo };
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type {
|
||||
WorksheetCloudCategory,
|
||||
WorksheetStatus,
|
||||
} from '../core/models/worksheet';
|
||||
import type { CategoryId } from '../core/models/category';
|
||||
import type { WorksheetConfig } from '../core/models/worksheet';
|
||||
|
||||
/**
|
||||
* 调试发布时的裁剪模式。
|
||||
@@ -19,7 +17,7 @@ export interface DebugPublishMeta {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
category: WorksheetCloudCategory;
|
||||
category: CategoryId;
|
||||
subcategory?: string;
|
||||
path: string;
|
||||
ageMin: number;
|
||||
@@ -31,7 +29,7 @@ export interface DebugPublishMeta {
|
||||
isNew: boolean;
|
||||
isHot: boolean;
|
||||
sortOrder?: number;
|
||||
status: WorksheetStatus;
|
||||
status: WorksheetConfig['status'];
|
||||
}
|
||||
|
||||
/** 发布弹窗里可调的图片处理参数。 */
|
||||
@@ -118,7 +116,7 @@ export function getCropRatios(mode: DebugCropMode): {
|
||||
|
||||
/** 统一构造预览图在云存储中的路径。 */
|
||||
export function buildWorksheetPreviewCloudPath(
|
||||
category: WorksheetCloudCategory,
|
||||
category: CategoryId,
|
||||
id: string,
|
||||
): string {
|
||||
return `assets/previews/${category}/${id}.jpg`;
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { getUser } from './auth';
|
||||
import { apiRequest } from '../services/api-request';
|
||||
import {
|
||||
legacyWorksheet,
|
||||
memberStats,
|
||||
type MemberPage,
|
||||
} from '../services/member-api';
|
||||
import { generateUUID } from './uuid';
|
||||
|
||||
export interface DownloadLogRecord {
|
||||
_id: string;
|
||||
userId: string;
|
||||
worksheetId: string;
|
||||
exportId?: string;
|
||||
exportId?: string | null;
|
||||
createdAt: string;
|
||||
worksheet?: {
|
||||
_id: string;
|
||||
@@ -20,66 +26,39 @@ export interface DownloadLogRecord {
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录下载日志
|
||||
*/
|
||||
/** 一次成功保存沿用同一个 exportId,服务端保证记录及下载计数幂等。 */
|
||||
export async function addDownloadLog(
|
||||
worksheetId: string,
|
||||
exportId?: string,
|
||||
exportId = generateUUID(),
|
||||
expectedUserId?: string,
|
||||
): Promise<boolean> {
|
||||
if (!worksheetId) return false;
|
||||
try {
|
||||
await getUser();
|
||||
const res = await wx.cloud.callFunction({
|
||||
name: 'userDownloadLogs',
|
||||
data: { action: 'add', worksheetId, exportId },
|
||||
const user = await getUser();
|
||||
if (expectedUserId && user.id !== expectedUserId) return false;
|
||||
await apiRequest('/me/downloads', {
|
||||
method: 'POST',
|
||||
data: { worksheetId, exportId },
|
||||
});
|
||||
const result = (res?.result as any) || {};
|
||||
return result.code === 0;
|
||||
} catch (e) {
|
||||
console.error('addDownloadLog failed', e);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 收藏页下载历史展示条数上限 */
|
||||
export const DOWNLOAD_HISTORY_MAX = 30;
|
||||
|
||||
/**
|
||||
* 获取下载历史(默认最近 30 条)
|
||||
*/
|
||||
export async function getDownloadLogs(
|
||||
page = 1,
|
||||
pageSize = DOWNLOAD_HISTORY_MAX,
|
||||
): Promise<DownloadLogRecord[]> {
|
||||
try {
|
||||
await getUser();
|
||||
const res = await wx.cloud.callFunction({
|
||||
name: 'userDownloadLogs',
|
||||
data: { action: 'list', page, pageSize },
|
||||
});
|
||||
const result = (res?.result as any) || {};
|
||||
return result.code === 0 ? result.data : [];
|
||||
} catch (e) {
|
||||
console.error('getDownloadLogs failed', e);
|
||||
return [];
|
||||
}
|
||||
const result = await apiRequest<MemberPage>('/me/downloads', {
|
||||
data: { page, pageSize },
|
||||
});
|
||||
return result.items.map((row) => ({
|
||||
...row,
|
||||
_id: row.id,
|
||||
worksheet: legacyWorksheet(row.worksheet),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户下载(打印)总数
|
||||
*/
|
||||
export async function getDownloadLogCount(): Promise<number> {
|
||||
try {
|
||||
await getUser();
|
||||
const res = await wx.cloud.callFunction({
|
||||
name: 'userDownloadLogs',
|
||||
data: { action: 'count' },
|
||||
});
|
||||
const result = (res?.result as any) || {};
|
||||
return result.code === 0 ? Number(result.data?.count || 0) : 0;
|
||||
} catch (e) {
|
||||
console.error('getDownloadLogCount failed', e);
|
||||
return 0;
|
||||
}
|
||||
return (await memberStats()).downloadCount;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getCachedUser } from './auth';
|
||||
/**
|
||||
* 下载打印通用工具
|
||||
* 支持下载次数限制和激励广告
|
||||
@@ -67,7 +68,9 @@ function showVideoAd(): Promise<boolean> {
|
||||
mask: true,
|
||||
});
|
||||
|
||||
const onClose: WechatMiniprogram.RewardedVideoAdOnCloseCallback = (res) => {
|
||||
const onClose: WechatMiniprogram.RewardedVideoAdOnCloseCallback = (
|
||||
res,
|
||||
) => {
|
||||
videoAd.offClose(onClose);
|
||||
wx.hideLoading();
|
||||
resolve(!!(res && res.isEnded));
|
||||
@@ -137,6 +140,7 @@ function doDownload(
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const exportId = generateUUID();
|
||||
const expectedUserId = getCachedUser()?.id;
|
||||
const trackerPage = options.trackerPage || options.trackerName || '';
|
||||
const trackerParams = {
|
||||
export_id: exportId,
|
||||
@@ -154,7 +158,11 @@ function doDownload(
|
||||
recordDownloadSuccess();
|
||||
tracker.reportExportResult(trackerPage, true, trackerParams);
|
||||
if (options.worksheetId) {
|
||||
await addDownloadLog(options.worksheetId, exportId);
|
||||
await addDownloadLog(
|
||||
options.worksheetId,
|
||||
exportId,
|
||||
expectedUserId,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
tracker.reportExportResult(trackerPage, false, trackerParams);
|
||||
|
||||
+38
-102
@@ -1,14 +1,13 @@
|
||||
import { getUser } from './auth';
|
||||
import { apiRequest } from '../services/api-request';
|
||||
import {
|
||||
legacyWorksheet,
|
||||
memberStats,
|
||||
type MemberPage,
|
||||
} from '../services/member-api';
|
||||
import tracker from './tracker';
|
||||
|
||||
function getCurrentPageRoute(): string {
|
||||
const pages = getCurrentPages();
|
||||
return pages[pages.length - 1]?.route || '';
|
||||
}
|
||||
|
||||
export interface FavoriteRecord {
|
||||
_id: string;
|
||||
userId: string;
|
||||
worksheetId: string;
|
||||
createdAt: string;
|
||||
worksheet?: {
|
||||
@@ -26,122 +25,59 @@ export interface FavoriteRecord {
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加收藏
|
||||
*/
|
||||
/** 收藏操作使用新后端当前账户;调用失败不能向其他用户补发。 */
|
||||
export async function addFavorite(worksheetId: string): Promise<boolean> {
|
||||
try {
|
||||
await getUser(); // 确保已登录
|
||||
const res = await wx.cloud.callFunction({
|
||||
name: 'userFavorites',
|
||||
data: { action: 'add', worksheetId },
|
||||
await apiRequest(`/me/favorites/${encodeURIComponent(worksheetId)}`, {
|
||||
method: 'PUT',
|
||||
});
|
||||
const result = (res?.result as any) || {};
|
||||
const success = result.code === 0;
|
||||
if (success) tracker.reportFavorite(getCurrentPageRoute(), worksheetId, true);
|
||||
return success;
|
||||
} catch (e) {
|
||||
console.error('addFavorite failed', e);
|
||||
tracker.reportFavorite(currentRoute(), worksheetId, true);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消收藏
|
||||
*/
|
||||
export async function removeFavorite(worksheetId: string): Promise<boolean> {
|
||||
try {
|
||||
await getUser();
|
||||
const res = await wx.cloud.callFunction({
|
||||
name: 'userFavorites',
|
||||
data: { action: 'remove', worksheetId },
|
||||
await apiRequest(`/me/favorites/${encodeURIComponent(worksheetId)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const result = (res?.result as any) || {};
|
||||
const success = result.code === 0;
|
||||
if (success) {
|
||||
tracker.reportFavorite(getCurrentPageRoute(), worksheetId, false);
|
||||
}
|
||||
return success;
|
||||
} catch (e) {
|
||||
console.error('removeFavorite failed', e);
|
||||
tracker.reportFavorite(currentRoute(), worksheetId, false);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取收藏列表
|
||||
*/
|
||||
export async function getFavoriteList(
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
): Promise<FavoriteRecord[]> {
|
||||
try {
|
||||
await getUser();
|
||||
const res = await wx.cloud.callFunction({
|
||||
name: 'userFavorites',
|
||||
data: { action: 'list', page, pageSize },
|
||||
});
|
||||
const result = (res?.result as any) || {};
|
||||
return result.code === 0 ? result.data || [] : [];
|
||||
} catch (e) {
|
||||
console.error('getFavoriteList failed', e);
|
||||
return [];
|
||||
}
|
||||
const result = await apiRequest<MemberPage>('/me/favorites', {
|
||||
data: { page, pageSize },
|
||||
});
|
||||
return result.items.map((row) => ({
|
||||
...row,
|
||||
_id: row.id,
|
||||
worksheet: legacyWorksheet(row.worksheet),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户收藏总数
|
||||
*/
|
||||
export async function getFavoriteCount(): Promise<number> {
|
||||
try {
|
||||
await getUser();
|
||||
const res = await wx.cloud.callFunction({
|
||||
name: 'userFavorites',
|
||||
data: { action: 'count' },
|
||||
});
|
||||
const result = (res?.result as any) || {};
|
||||
return result.code === 0 ? Number(result.data?.count || 0) : 0;
|
||||
} catch (e) {
|
||||
console.error('getFavoriteCount failed', e);
|
||||
return 0;
|
||||
}
|
||||
return (await memberStats()).favoriteCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已收藏
|
||||
*/
|
||||
export async function checkFavorited(worksheetId: string): Promise<boolean> {
|
||||
try {
|
||||
await getUser();
|
||||
const res = await wx.cloud.callFunction({
|
||||
name: 'userFavorites',
|
||||
data: { action: 'check', worksheetId },
|
||||
});
|
||||
const result = (res?.result as any) || {};
|
||||
return result.code === 0 && result.data?.favorited;
|
||||
} catch (e) {
|
||||
console.error('checkFavorited failed', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量检查是否已收藏,返回 { worksheetId: true } 的 map
|
||||
*/
|
||||
export async function batchCheckFavorited(
|
||||
worksheetIds: string[],
|
||||
): Promise<Record<string, boolean>> {
|
||||
if (!worksheetIds.length) return {};
|
||||
try {
|
||||
await getUser();
|
||||
const res = await wx.cloud.callFunction({
|
||||
name: 'userFavorites',
|
||||
data: { action: 'batchCheck', worksheetIds },
|
||||
});
|
||||
const result = (res?.result as any) || {};
|
||||
return result.code === 0 ? result.data || {} : {};
|
||||
} catch (e) {
|
||||
console.error('batchCheckFavorited failed', e);
|
||||
return {};
|
||||
}
|
||||
return apiRequest('/me/favorites/check', {
|
||||
method: 'POST',
|
||||
data: { worksheetIds },
|
||||
});
|
||||
}
|
||||
export async function checkFavorited(worksheetId: string): Promise<boolean> {
|
||||
return (await batchCheckFavorited([worksheetId]))[worksheetId] === true;
|
||||
}
|
||||
|
||||
function currentRoute() {
|
||||
const pages = getCurrentPages();
|
||||
return pages[pages.length - 1]?.route || '';
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
type WorksheetStatsField = 'likes' | 'downloads';
|
||||
|
||||
export async function updateWorksheetStats(
|
||||
id: string,
|
||||
field: WorksheetStatsField,
|
||||
): Promise<boolean> {
|
||||
const worksheetId = String(id || '').trim();
|
||||
if (!worksheetId) return false;
|
||||
|
||||
try {
|
||||
const { result } = await wx.cloud.callFunction({
|
||||
name: 'worksheetsStatsUpdate',
|
||||
data: {
|
||||
id: worksheetId,
|
||||
field,
|
||||
},
|
||||
});
|
||||
return !!(result as { success?: boolean } | undefined)?.success;
|
||||
} catch (error) {
|
||||
console.error('update worksheet stats failed', field, worksheetId, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function incrementWorksheetLikes(id: string): Promise<boolean> {
|
||||
return updateWorksheetStats(id, 'likes');
|
||||
}
|
||||
|
||||
export function incrementWorksheetDownloads(id: string): Promise<boolean> {
|
||||
return updateWorksheetStats(id, 'downloads');
|
||||
}
|
||||
+7
-2
@@ -7,7 +7,11 @@
|
||||
"worksheet:patch:ages": "tsx scripts/worksheet-sync-cloud-payload.ts --ages",
|
||||
"worksheet:patch:tags": "tsx scripts/worksheet-sync-cloud-payload.ts --tags",
|
||||
"worksheet:patch:all": "tsx scripts/worksheet-sync-cloud-payload.ts --all",
|
||||
"worksheet:patch:dry": "tsx scripts/worksheet-sync-cloud-payload.ts --all --dry-run"
|
||||
"worksheet:patch:dry": "tsx scripts/worksheet-sync-cloud-payload.ts --all --dry-run",
|
||||
"test:category": "tsx --test tests/category-api.test.mjs",
|
||||
"test:assets": "tsx --test tests/preview-assets.test.mjs",
|
||||
"test:auth": "tsx --test tests/auth-api.test.mjs",
|
||||
"test:papers": "tsx --test tests/paper-sheet.test.mjs"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -22,6 +26,7 @@
|
||||
"globals": "^15.14.0",
|
||||
"miniprogram-api-typings": "^4.0.5",
|
||||
"prettier": "3.4.2",
|
||||
"typescript-eslint": "^8.18.1"
|
||||
"typescript-eslint": "^8.18.1",
|
||||
"typescript": "5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"projectname": "doodle-mini",
|
||||
"setting": {
|
||||
"compileHotReLoad": true,
|
||||
"urlCheck": true,
|
||||
"urlCheck": false,
|
||||
"coverView": false,
|
||||
"lazyloadPlaceholderEnable": false,
|
||||
"skylineRenderEnable": true,
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
import vm from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
|
||||
const root = resolve('miniprogram');
|
||||
const user = (id = 'server-user') => ({
|
||||
id,
|
||||
nickName: null,
|
||||
avatarUrl: null,
|
||||
avatarUrlExpiresAt: null,
|
||||
createdAt: '2026-10-19T00:00:00Z',
|
||||
});
|
||||
const result = (token, id) => ({
|
||||
accessToken: token,
|
||||
tokenType: 'Bearer',
|
||||
expiresIn: 86400,
|
||||
expiresAt: '2026-10-20T00:00:00Z',
|
||||
user: { ...user(id), openid: 'must-not-persist', role: 'admin' },
|
||||
});
|
||||
const failure = (reason, statusCode = 401) => ({
|
||||
statusCode,
|
||||
raw: { code: statusCode, reason },
|
||||
});
|
||||
const deferred = () => {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
/** 隔离微信与时间;登录、缓存、HTTP 包装都加载实际源码,不使用真实凭据。 */
|
||||
function fixture(initialStorage = new Map()) {
|
||||
let now = Date.now();
|
||||
const storage = initialStorage;
|
||||
const app = { globalData: {} };
|
||||
let loginCalls = 0;
|
||||
let handler = async (options) =>
|
||||
options.url.endsWith('/auth/wechat/login')
|
||||
? { data: result(`token-${loginCalls}`) }
|
||||
: { data: user() };
|
||||
const requests = [];
|
||||
const uploads = [];
|
||||
const wx = {
|
||||
getAccountInfoSync: () => ({
|
||||
miniProgram: { appId: 'wx-test', envVersion: 'develop' },
|
||||
}),
|
||||
getStorageSync: (key) => storage.get(key),
|
||||
setStorageSync(key, value) {
|
||||
storage.set(key, JSON.parse(JSON.stringify(value)));
|
||||
},
|
||||
login(options) {
|
||||
loginCalls++;
|
||||
options.success({ code: `one-use-code-${loginCalls}` });
|
||||
},
|
||||
uploadFile(options) {
|
||||
uploads.push(options);
|
||||
Promise.resolve()
|
||||
.then(() => handler({ ...options, data: options.formData }))
|
||||
.then((value) => {
|
||||
if (value.networkError) options.fail({});
|
||||
else
|
||||
options.success({
|
||||
statusCode: value.statusCode ?? 200,
|
||||
data: JSON.stringify(
|
||||
value.raw ?? { code: 0, data: value.data },
|
||||
),
|
||||
});
|
||||
});
|
||||
},
|
||||
request(options) {
|
||||
requests.push(options);
|
||||
Promise.resolve()
|
||||
.then(() => handler(options))
|
||||
.then((value) => {
|
||||
if (value.networkError) options.fail({});
|
||||
else
|
||||
options.success({
|
||||
statusCode: value.statusCode ?? 200,
|
||||
data: value.raw ?? { code: 0, data: value.data },
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
class Clock extends Date {
|
||||
static now() {
|
||||
return now;
|
||||
}
|
||||
}
|
||||
const context = vm.createContext({
|
||||
wx,
|
||||
getApp: () => app,
|
||||
Date: Clock,
|
||||
console,
|
||||
});
|
||||
const modules = new Map();
|
||||
function load(file) {
|
||||
file = resolve(root, file);
|
||||
if (!file.endsWith('.ts')) file += '.ts';
|
||||
if (modules.has(file)) return modules.get(file);
|
||||
const exports = {};
|
||||
modules.set(file, exports);
|
||||
const source = readFileSync(file, 'utf8');
|
||||
const code = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
},
|
||||
}).outputText;
|
||||
const run = vm.runInContext(
|
||||
`(function(require,exports){${code}\n})`,
|
||||
context,
|
||||
);
|
||||
run((name) => load(resolve(dirname(file), name)), exports);
|
||||
return exports;
|
||||
}
|
||||
return {
|
||||
auth: load('utils/auth'),
|
||||
session: load('services/auth-session'),
|
||||
api: load('services/api-request'),
|
||||
member: load('services/member-api'),
|
||||
admin: load('services/admin-api'),
|
||||
site: load('services/site-api'),
|
||||
config: load('config/api').apiConfig,
|
||||
wx,
|
||||
requests,
|
||||
uploads,
|
||||
storage,
|
||||
app,
|
||||
get loginCalls() {
|
||||
return loginCalls;
|
||||
},
|
||||
handle(fn) {
|
||||
handler = fn;
|
||||
},
|
||||
advance(ms) {
|
||||
now += ms;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const loginRequests = (f) =>
|
||||
f.requests.filter((r) => r.url.endsWith('/auth/wechat/login'));
|
||||
|
||||
test('静默登录:首次 start,并发合并、缓存复用,过期后 resume 且不缓存微信身份/角色', async () => {
|
||||
const f = fixture();
|
||||
await Promise.all(Array.from({ length: 20 }, () => f.auth.getUser()));
|
||||
assert.equal(f.loginCalls, 1);
|
||||
assert.equal(loginRequests(f)[0].data.mode, 'start');
|
||||
assert.equal(f.app.globalData.user.id, 'server-user');
|
||||
assert.ok(
|
||||
!JSON.stringify([...f.storage.values()]).includes('must-not-persist'),
|
||||
);
|
||||
assert.ok(!JSON.stringify([...f.storage.values()]).includes('admin'));
|
||||
assert.equal(f.auth.getCachedUser().id, 'server-user');
|
||||
const reload = fixture(f.storage);
|
||||
await reload.auth.getUser();
|
||||
assert.equal(reload.loginCalls, 0);
|
||||
f.advance(86400 * 1000);
|
||||
await f.auth.getUser();
|
||||
assert.equal(f.loginCalls, 2);
|
||||
assert.equal(loginRequests(f)[1].data.mode, 'resume');
|
||||
assert.notEqual(
|
||||
loginRequests(f)[0].data.code,
|
||||
loginRequests(f)[1].data.code,
|
||||
);
|
||||
});
|
||||
|
||||
test('公开请求不登录不带凭证;受保护请求登录并 GET /me 刷新用户', async () => {
|
||||
const f = fixture();
|
||||
await f.api.apiRequest('/categories', {
|
||||
auth: 'none',
|
||||
header: { authorization: 'do-not-forward' },
|
||||
});
|
||||
assert.equal(f.loginCalls, 0);
|
||||
assert.equal(
|
||||
Object.keys(f.requests[0].header).some(
|
||||
(k) => k.toLowerCase() === 'authorization',
|
||||
),
|
||||
false,
|
||||
);
|
||||
const refreshed = await f.auth.refreshUser();
|
||||
assert.equal(refreshed.id, 'server-user');
|
||||
assert.equal(f.requests.at(-1).header.Authorization, 'Bearer token-1');
|
||||
await f.api.apiRequest('/categories', { auth: 'none' });
|
||||
assert.equal(f.requests.at(-1).header.Authorization, undefined);
|
||||
});
|
||||
|
||||
test('多个并发 401 共用一次 resume,迟到的旧 401 复用新 token', async () => {
|
||||
const f = fixture();
|
||||
await f.auth.getUser();
|
||||
const late = deferred();
|
||||
const receivedLate = deferred();
|
||||
f.handle(async (options) => {
|
||||
if (options.url.endsWith('/auth/wechat/login'))
|
||||
return { data: result('token-2') };
|
||||
if (options.header.Authorization === 'Bearer token-1') {
|
||||
if (options.url.endsWith('/late')) {
|
||||
receivedLate.resolve();
|
||||
await late.promise;
|
||||
}
|
||||
return failure('AUTH_EXPIRED');
|
||||
}
|
||||
return { data: 'ok' };
|
||||
});
|
||||
const delayed = f.api.apiRequest('/late');
|
||||
await receivedLate.promise;
|
||||
const values = await Promise.all(
|
||||
Array.from({ length: 8 }, () => f.api.apiRequest('/me')),
|
||||
);
|
||||
assert.ok(values.every((v) => v === 'ok'));
|
||||
assert.equal(f.loginCalls, 2);
|
||||
late.resolve();
|
||||
assert.equal(await delayed, 'ok');
|
||||
assert.equal(f.loginCalls, 2);
|
||||
});
|
||||
|
||||
test('第二次 401 停止恢复;403、429、503 和网络错误不自动重登', async () => {
|
||||
for (const status of [401, 403, 429, 503, 0]) {
|
||||
const f = fixture();
|
||||
await f.auth.getUser();
|
||||
f.handle(async (options) =>
|
||||
options.url.endsWith('/auth/wechat/login')
|
||||
? { data: result('token-2') }
|
||||
: status
|
||||
? failure('AUTH_EXPIRED', status)
|
||||
: { networkError: true },
|
||||
);
|
||||
await assert.rejects(f.api.apiRequest('/me'));
|
||||
assert.equal(f.loginCalls, status === 401 ? 2 : 1);
|
||||
const business = f.requests.filter((r) => r.url.endsWith('/me'));
|
||||
assert.equal(business.length, status === 401 ? 2 : 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('账户被删除或禁用时阻止自动重建,标记重启后保留;用户可主动重新开始', async () => {
|
||||
const f = fixture();
|
||||
await f.auth.getUser();
|
||||
f.handle(async (options) =>
|
||||
options.url.endsWith('/auth/wechat/login')
|
||||
? failure('ACCOUNT_NOT_FOUND')
|
||||
: failure('AUTH_EXPIRED'),
|
||||
);
|
||||
await assert.rejects(f.api.apiRequest('/me'));
|
||||
await assert.rejects(f.auth.getUser(), /重新确认/);
|
||||
assert.equal(f.loginCalls, 2);
|
||||
assert.equal(loginRequests(f)[1].data.mode, 'resume');
|
||||
const reload = fixture(f.storage);
|
||||
await assert.rejects(reload.auth.getUser(), /重新确认/);
|
||||
assert.equal(reload.loginCalls, 0);
|
||||
await reload.auth.startAccount();
|
||||
assert.equal(loginRequests(reload)[0].data.mode, 'start');
|
||||
});
|
||||
|
||||
test('恢复后身份变化中止原业务写入,不把请求补发到另一个账户', async () => {
|
||||
const f = fixture();
|
||||
await f.auth.getUser();
|
||||
f.handle(async (options) =>
|
||||
options.url.endsWith('/auth/wechat/login')
|
||||
? { data: result('token-other', 'another-user') }
|
||||
: failure('AUTH_EXPIRED'),
|
||||
);
|
||||
await assert.rejects(
|
||||
f.api.apiRequest('/me/action', { method: 'POST', data: { value: 1 } }),
|
||||
(e) => e.reason === 'ACCOUNT_CHANGED',
|
||||
);
|
||||
assert.equal(
|
||||
f.requests.filter((r) => r.url.endsWith('/me/action')).length,
|
||||
1,
|
||||
);
|
||||
assert.equal(f.auth.getCachedUser(), null);
|
||||
});
|
||||
|
||||
test('微信无效 code 只补试一次且使用新 code,网络失败不重试登录', async () => {
|
||||
const f = fixture();
|
||||
let count = 0;
|
||||
f.handle(async () =>
|
||||
++count === 1
|
||||
? failure('WECHAT_CODE_INVALID')
|
||||
: { data: result('token-2') },
|
||||
);
|
||||
await f.auth.getUser();
|
||||
assert.equal(f.loginCalls, 2);
|
||||
assert.notEqual(
|
||||
loginRequests(f)[0].data.code,
|
||||
loginRequests(f)[1].data.code,
|
||||
);
|
||||
const g = fixture();
|
||||
g.handle(async () => ({ networkError: true }));
|
||||
await assert.rejects(g.auth.getUser());
|
||||
assert.equal(g.loginCalls, 1);
|
||||
});
|
||||
|
||||
test('接口环境与 AppID 隔离;清理或切换环境后迟到登录不得恢复原会话', async () => {
|
||||
const f = fixture();
|
||||
await f.auth.getUser();
|
||||
f.config.localBaseUrl = 'http://127.0.0.1:8801/api/v1';
|
||||
assert.equal(f.auth.getCachedUser(), null);
|
||||
await f.auth.getUser();
|
||||
assert.equal(loginRequests(f)[1].data.mode, 'start');
|
||||
assert.equal(f.storage.size, 2);
|
||||
f.wx.getAccountInfoSync = () => ({
|
||||
miniProgram: { appId: 'wx-other', envVersion: 'develop' },
|
||||
});
|
||||
assert.equal(f.auth.getCachedUser(), null);
|
||||
const pending = deferred();
|
||||
const received = deferred();
|
||||
f.handle(async () => {
|
||||
received.resolve();
|
||||
return pending.promise;
|
||||
});
|
||||
const login = f.auth.getUser();
|
||||
await received.promise;
|
||||
f.auth.clearUser();
|
||||
pending.resolve({ data: result('late') });
|
||||
await assert.rejects(login, /状态已变化/);
|
||||
assert.equal(f.auth.getCachedUser(), null);
|
||||
});
|
||||
|
||||
test('畸形登录响应不保存凭证;账户禁用不会触发新的自动注册', async () => {
|
||||
const f = fixture();
|
||||
f.handle(async () => ({
|
||||
data: { ...result('invalid'), tokenType: 'Basic' },
|
||||
}));
|
||||
await assert.rejects(f.auth.getUser(), /格式异常/);
|
||||
assert.equal(f.auth.getCachedUser(), null);
|
||||
assert.equal(f.storage.size, 0);
|
||||
const g = fixture();
|
||||
await g.auth.getUser();
|
||||
g.handle(async () => failure('ACCOUNT_DISABLED'));
|
||||
await assert.rejects(g.api.apiRequest('/me'));
|
||||
assert.equal(g.loginCalls, 1);
|
||||
await assert.rejects(g.auth.getUser(), /重新确认/);
|
||||
assert.equal(g.loginCalls, 1);
|
||||
});
|
||||
|
||||
test('切换环境后原业务的 401 不向旧接口发起新登录', async () => {
|
||||
const f = fixture();
|
||||
await f.auth.getUser();
|
||||
const pending = deferred();
|
||||
const received = deferred();
|
||||
f.handle(async () => {
|
||||
received.resolve();
|
||||
return pending.promise;
|
||||
});
|
||||
const request = f.api.apiRequest('/me');
|
||||
await received.promise;
|
||||
f.config.localBaseUrl = 'http://127.0.0.1:8801/api/v1';
|
||||
pending.resolve(failure('AUTH_EXPIRED'));
|
||||
await assert.rejects(request, /环境已变化/);
|
||||
assert.equal(f.loginCalls, 1);
|
||||
});
|
||||
|
||||
test('提前续登后身份变化时,原页面发起的业务请求也不会首次发送到新账户', async () => {
|
||||
const f = fixture();
|
||||
await f.auth.getUser();
|
||||
f.advance(86400 * 1000);
|
||||
f.handle(async () => ({ data: result('another-token', 'another-user') }));
|
||||
await assert.rejects(
|
||||
f.api.apiRequest('/me/action', { method: 'POST' }),
|
||||
(e) => e.reason === 'ACCOUNT_CHANGED',
|
||||
);
|
||||
assert.equal(
|
||||
f.requests.filter((r) => r.url.endsWith('/me/action')).length,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('七牛头像/预览上传沿用统一鉴权;401 重试沿用 uploadId,不自行设置 multipart boundary', async () => {
|
||||
const f = fixture();
|
||||
await f.auth.getUser();
|
||||
f.handle(async (options) => {
|
||||
if (options.url.endsWith('/auth/wechat/login'))
|
||||
return { data: result('token-2') };
|
||||
if (options.header.Authorization === 'Bearer token-1')
|
||||
return failure('AUTH_EXPIRED');
|
||||
return {
|
||||
data: {
|
||||
id: 'avatar-resource',
|
||||
url: 'https://cdn.test/avatar.png',
|
||||
mimeType: 'image/png',
|
||||
sizeBytes: 100,
|
||||
},
|
||||
};
|
||||
});
|
||||
const uploaded = await f.member.uploadAsset(
|
||||
'wxfile://chosen-avatar.png',
|
||||
'avatar',
|
||||
);
|
||||
assert.equal(uploaded.id, 'avatar-resource');
|
||||
assert.equal(f.uploads.length, 2);
|
||||
assert.equal(
|
||||
f.uploads[0].formData.uploadId,
|
||||
f.uploads[1].formData.uploadId,
|
||||
);
|
||||
assert.equal(f.uploads[0].header['Content-Type'], undefined);
|
||||
assert.equal(f.uploads[1].header.Authorization, 'Bearer token-2');
|
||||
assert.ok(f.uploads[0].url.endsWith('/me/avatar-upload'));
|
||||
await f.member.uploadAsset('wxfile://preview.jpg', 'preview');
|
||||
assert.ok(f.uploads.at(-1).url.endsWith('/admin/assets/preview-upload'));
|
||||
});
|
||||
|
||||
test('资料更新只提交资源 ID,服务端响应更新当前用户摘要', async () => {
|
||||
const f = fixture();
|
||||
await f.auth.getUser();
|
||||
f.handle(async () => ({
|
||||
data: {
|
||||
...user(),
|
||||
nickName: '新名字',
|
||||
avatarUrl: 'https://cdn.test/avatar.png',
|
||||
avatarAssetId: 'avatar-resource',
|
||||
role: 'admin',
|
||||
},
|
||||
}));
|
||||
const updated = await f.auth.updateUserProfile({
|
||||
nickName: '新名字',
|
||||
avatarAssetId: 'avatar-resource',
|
||||
});
|
||||
const request = f.requests.at(-1);
|
||||
assert.ok(request.url.endsWith('/me/profile'));
|
||||
assert.equal(request.method, 'PUT');
|
||||
assert.equal(request.data.avatarAssetId, 'avatar-resource');
|
||||
assert.equal(request.data.avatarUrl, undefined);
|
||||
assert.equal(updated.avatarAssetId, 'avatar-resource');
|
||||
assert.equal(f.auth.getCachedUser().nickName, '新名字');
|
||||
assert.equal(f.auth.getCachedUser().role, undefined);
|
||||
});
|
||||
|
||||
test('管理目录完整分页,真实计数和展示数分开;批次与 PUT 状态映射至新接口', async () => {
|
||||
const f = fixture();
|
||||
const rows = Array.from({ length: 121 }, (_, index) => ({
|
||||
id: `item-${index}`,
|
||||
likes: '2',
|
||||
downloads: '3',
|
||||
likesDisplay: '22',
|
||||
downloadsDisplay: '33',
|
||||
status: 'active',
|
||||
}));
|
||||
f.handle(async (options) => {
|
||||
if (options.url.endsWith('/auth/wechat/login'))
|
||||
return { data: result('token-1') };
|
||||
if (options.url.endsWith('/admin/catalog/worksheets')) {
|
||||
const { page } = options.data;
|
||||
return {
|
||||
data: {
|
||||
items: rows.slice((page - 1) * 100, page * 100),
|
||||
total: rows.length,
|
||||
page,
|
||||
pageSize: 100,
|
||||
hasNext: page * 100 < rows.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { data: {} };
|
||||
});
|
||||
const display = await f.admin.managementRequest('worksheets.list');
|
||||
assert.equal(display.data.length, 121);
|
||||
assert.equal(display.data[0]._id, 'item-0');
|
||||
assert.equal(display.data[0].downloads, '33');
|
||||
const raw = await f.admin.managementRequest('worksheets.list', {
|
||||
rawStats: true,
|
||||
});
|
||||
assert.equal(raw.data[0].downloads, '3');
|
||||
await f.admin.managementRequest('worksheets.batch', {
|
||||
patches: [{ id: 'item-0', likes_seed: 10, downloads_seed: 20 }],
|
||||
dryRun: false,
|
||||
});
|
||||
assert.ok(f.requests.at(-1).url.endsWith('/admin/worksheets/batch'));
|
||||
assert.equal(f.requests.at(-1).data.patches[0].likesSeed, 10);
|
||||
assert.equal(f.requests.at(-1).data.patches[0].likes_seed, undefined);
|
||||
await f.admin.managementRequest('worksheets.status', {
|
||||
id: 'item-0',
|
||||
status: 'hidden',
|
||||
});
|
||||
assert.equal(f.requests.at(-1).method, 'PUT');
|
||||
await f.admin.managementRequest('categories.update', {
|
||||
id: 'cat-0',
|
||||
name: '分类',
|
||||
});
|
||||
assert.ok(f.requests.at(-1).url.endsWith('/admin/categories/cat-0'));
|
||||
assert.equal(f.requests.at(-1).data.id, undefined);
|
||||
});
|
||||
|
||||
test('首页兜底与分龄推荐不携带个人凭证,也不触发登录', async () => {
|
||||
const f = fixture();
|
||||
f.handle(async () => ({ data: { home: {}, items: [] } }));
|
||||
await f.site.fetchPageConfig();
|
||||
await f.site.fetchAgeRecommendations('5-6');
|
||||
assert.equal(f.loginCalls, 0);
|
||||
assert.ok(f.requests[0].url.endsWith('/page-config'));
|
||||
assert.ok(f.requests[1].url.endsWith('/recommendations/age'));
|
||||
assert.equal(f.requests[1].data.ageBand, '5-6');
|
||||
assert.ok(f.requests.every((r) => !r.header.Authorization));
|
||||
});
|
||||
|
||||
test('清理账户后迟到的成功响应不能回填旧账户视图', async () => {
|
||||
const f = fixture();
|
||||
await f.auth.getUser();
|
||||
const pending = deferred();
|
||||
const received = deferred();
|
||||
f.handle(async () => {
|
||||
received.resolve();
|
||||
return pending.promise;
|
||||
});
|
||||
const request = f.api.apiRequest('/me/stats');
|
||||
await received.promise;
|
||||
f.auth.clearUser(true);
|
||||
pending.resolve({ data: { favoriteCount: 10 } });
|
||||
await assert.rejects(request, /账户状态已变化/);
|
||||
});
|
||||
@@ -0,0 +1,378 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { test, beforeEach } from 'node:test';
|
||||
import vm from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
import { apiConfig, resolveApiConfig } from '../miniprogram/config/api.ts';
|
||||
import {
|
||||
apiRequest,
|
||||
ApiRequestError,
|
||||
} from '../miniprogram/services/api-request.ts';
|
||||
import * as contentApi from '../miniprogram/services/content-api.ts';
|
||||
import * as model from '../miniprogram/pages/category/category.model.ts';
|
||||
|
||||
const categories = [
|
||||
{
|
||||
id: 'math',
|
||||
name: '数感启蒙',
|
||||
icon: '📐',
|
||||
parentId: null,
|
||||
status: 'active',
|
||||
},
|
||||
];
|
||||
const worksheet = (id, extra = {}) => ({
|
||||
id,
|
||||
title: id,
|
||||
subtitle: '',
|
||||
category: 'math',
|
||||
path: `/mathPages/mathDraw/mathDraw?id=${id}`,
|
||||
ageMin: 3,
|
||||
ageMax: 8,
|
||||
difficulty: 1,
|
||||
previewImg: null,
|
||||
likes: '2',
|
||||
downloads: '10',
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
updatedAt: '2025-01-01T00:00:00Z',
|
||||
contentUpdatedAt: null,
|
||||
...extra,
|
||||
});
|
||||
const pageOf = (items, page = 1, total = items.length) => ({
|
||||
items,
|
||||
page,
|
||||
pageSize: 100,
|
||||
total,
|
||||
hasNext: page * 100 < total,
|
||||
});
|
||||
let requests;
|
||||
let navigations;
|
||||
let handler;
|
||||
|
||||
beforeEach(() => {
|
||||
requests = [];
|
||||
navigations = [];
|
||||
apiConfig.developmentEnvironment = 'local';
|
||||
apiConfig.productionBaseUrl = 'http://127.0.0.1:8800/api/v1';
|
||||
handler = () => ({ data: [] });
|
||||
globalThis.wx = {
|
||||
getAccountInfoSync: () => ({ miniProgram: { envVersion: 'develop' } }),
|
||||
request(options) {
|
||||
requests.push(options);
|
||||
Promise.resolve()
|
||||
.then(() => handler(options))
|
||||
.then((result) => {
|
||||
options.success({
|
||||
statusCode: result.statusCode ?? 200,
|
||||
data: result.raw ?? {
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: result.data,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch(() => options.fail({ errMsg: 'request:fail' }));
|
||||
},
|
||||
showToast() {},
|
||||
navigateTo(options) {
|
||||
navigations.push(options.url);
|
||||
},
|
||||
switchTab(options) {
|
||||
navigations.push(options.url);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
/** 仅隔离埋点与绘图分享依赖;分类页、请求层及数据模型均使用真实源码。 */
|
||||
function createCategoryPage(api = contentApi) {
|
||||
let options;
|
||||
const source = readFileSync(
|
||||
new URL('../miniprogram/pages/category/category.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const code = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
},
|
||||
}).outputText;
|
||||
const modules = {
|
||||
'../../base/pageMixin': { appSharePageMethods: {} },
|
||||
'../../utils/index': { takePendingCategoryTabId: () => null },
|
||||
'../../utils/tracker': {
|
||||
default: {
|
||||
reportPageView() {},
|
||||
report() {},
|
||||
reportWorksheetClick() {},
|
||||
},
|
||||
},
|
||||
'../../services/api-request': { ApiRequestError },
|
||||
'../../services/content-api': api,
|
||||
'./category.model': model,
|
||||
};
|
||||
vm.runInNewContext(code, {
|
||||
exports: {},
|
||||
wx: globalThis.wx,
|
||||
Error,
|
||||
require(path) {
|
||||
assert.ok(path in modules, path);
|
||||
return modules[path];
|
||||
},
|
||||
Page(value) {
|
||||
options = value;
|
||||
},
|
||||
});
|
||||
const instance = {
|
||||
...options,
|
||||
data: structuredClone(options.data),
|
||||
setData(value) {
|
||||
Object.assign(this.data, value);
|
||||
},
|
||||
};
|
||||
return instance;
|
||||
}
|
||||
|
||||
function useRows(rows, selectedCategories = categories) {
|
||||
handler = ({ url, data }) => {
|
||||
const path = new URL(url).pathname;
|
||||
if (path.endsWith('/categories')) return { data: selectedCategories };
|
||||
if (path.endsWith('/worksheets')) {
|
||||
const page = data.page;
|
||||
return {
|
||||
data: pageOf(
|
||||
rows.slice((page - 1) * 100, page * 100),
|
||||
page,
|
||||
rows.length,
|
||||
),
|
||||
};
|
||||
}
|
||||
const row = rows.find((item) =>
|
||||
path.endsWith(`/${encodeURIComponent(item.id)}`),
|
||||
);
|
||||
return row ? { data: row } : { statusCode: 404 };
|
||||
};
|
||||
}
|
||||
|
||||
test('环境配置:体验/正式/未知包固定 production,暂允许指定的本机占位地址', () => {
|
||||
assert.equal(resolveApiConfig('develop').environment, 'local');
|
||||
for (const version of ['release', 'trial', 'unknown']) {
|
||||
assert.equal(resolveApiConfig(version).environment, 'production');
|
||||
assert.equal(
|
||||
resolveApiConfig(version).baseUrl,
|
||||
'http://127.0.0.1:8800/api/v1',
|
||||
);
|
||||
}
|
||||
apiConfig.productionBaseUrl = '';
|
||||
assert.throws(() => resolveApiConfig('release'), /production/);
|
||||
apiConfig.productionBaseUrl = 'https://api.example.test/api/v1/';
|
||||
assert.equal(
|
||||
resolveApiConfig('release').baseUrl,
|
||||
'https://api.example.test/api/v1',
|
||||
);
|
||||
apiConfig.productionBaseUrl = 'http://api.example.test/api/v1';
|
||||
assert.throws(() => resolveApiConfig('release'), /配置/);
|
||||
});
|
||||
|
||||
test('请求层:HTTP、业务错误、畸形响应、网络错误均拒绝,不当作空成功', async () => {
|
||||
useRows([]);
|
||||
assert.deepEqual(
|
||||
await apiRequest('/categories', { auth: 'none' }),
|
||||
categories,
|
||||
);
|
||||
assert.equal(requests[0].url, 'http://127.0.0.1:8800/api/v1/categories');
|
||||
for (const result of [
|
||||
{ statusCode: 404 },
|
||||
{ statusCode: 503 },
|
||||
{ raw: { code: 1, data: [] } },
|
||||
{ raw: { code: 0 } },
|
||||
{ raw: '<html>error</html>' },
|
||||
]) {
|
||||
handler = () => result;
|
||||
await assert.rejects(
|
||||
apiRequest('/categories', { auth: 'none' }),
|
||||
ApiRequestError,
|
||||
);
|
||||
}
|
||||
handler = () => {
|
||||
throw new Error('offline');
|
||||
};
|
||||
await assert.rejects(
|
||||
apiRequest('/categories', { auth: 'none' }),
|
||||
/连接失败/,
|
||||
);
|
||||
});
|
||||
|
||||
test('完整分页:121 条无遗漏,计数字符串保留精度,空数据正常结束', async () => {
|
||||
const rows = Array.from({ length: 121 }, (_, index) =>
|
||||
worksheet(`item-${index}`, { downloads: '9223372036854775906' }),
|
||||
);
|
||||
useRows(rows);
|
||||
const actual = await contentApi.listAllWorksheets();
|
||||
assert.deepEqual(actual, rows);
|
||||
assert.equal(requests.length, 2);
|
||||
assert.equal(actual[120].downloads, '9223372036854775906');
|
||||
useRows([]);
|
||||
assert.deepEqual(await contentApi.listAllWorksheets(), []);
|
||||
});
|
||||
|
||||
test('分页变化、重复记录、缺页及非法计数拒绝返回部分内容', async () => {
|
||||
for (const result of [
|
||||
pageOf([worksheet('one'), worksheet('one')]),
|
||||
pageOf([worksheet('one')], 1, 2),
|
||||
pageOf([worksheet('one', { likes: 9007199254740992 })]),
|
||||
{ ...pageOf([]), hasNext: true },
|
||||
]) {
|
||||
handler = () => ({ data: result });
|
||||
await assert.rejects(contentApi.listAllWorksheets());
|
||||
}
|
||||
const rows = Array.from({ length: 100 }, (_, index) =>
|
||||
worksheet(`item-${index}`),
|
||||
);
|
||||
handler = ({ data }) => ({
|
||||
data:
|
||||
data.page === 1
|
||||
? pageOf(rows, 1, 101)
|
||||
: pageOf([worksheet('last')], 2, 102),
|
||||
});
|
||||
await assert.rejects(contentApi.listAllWorksheets());
|
||||
});
|
||||
|
||||
test('展示模型:采用服务端分类、空图有图标、不恢复失联分类,榜单精确比较 64 位计数', () => {
|
||||
const rows = [
|
||||
worksheet('one', { likes: '9223372036854775806', downloads: '5' }),
|
||||
worksheet('two', {
|
||||
likes: '9223372036854775807',
|
||||
downloads: '9223372036854775906',
|
||||
}),
|
||||
worksheet('new', { likes: '1', updatedAt: '2026-10-01T00:00:00Z' }),
|
||||
worksheet('hidden-category', { category: 'papers' }),
|
||||
];
|
||||
const dataset = model.buildCategoryDataset(
|
||||
[...categories, { ...categories[0], id: 'craft', name: '创意手工' }],
|
||||
rows,
|
||||
);
|
||||
assert.deepEqual(
|
||||
dataset.sidebarTabs.map((row) => row.id),
|
||||
['all', 'hot', 'top-downloads', 'math', 'craft'],
|
||||
);
|
||||
const all = model.getCategoryItems(dataset, 'all');
|
||||
assert.equal(all.length, 3);
|
||||
assert.equal(all[0].id, 'new');
|
||||
assert.equal(all[0].icon, '📐');
|
||||
assert.equal(all[0].previewImg, '');
|
||||
assert.equal(model.getCategoryItems(dataset, 'hot')[0].id, 'two');
|
||||
assert.equal(model.getCategoryItems(dataset, 'top-downloads')[0].id, 'two');
|
||||
assert.equal(model.getCategoryItems(dataset, 'math', 'new').length, 1);
|
||||
assert.equal(model.compareCountDescending('0005', '5'), 0);
|
||||
});
|
||||
|
||||
test('页面:接口成功展示真实数据,失败清空旧目录/内容,重试可恢复,失效分类回到全部', async () => {
|
||||
const page = createCategoryPage();
|
||||
page.onLoad({ id: 'papers' });
|
||||
useRows([worksheet('real')]);
|
||||
await page.loadWorksheets();
|
||||
assert.equal(page.data.activeCategoryId, 'all');
|
||||
assert.equal(page.data.displayItems[0].id, 'real');
|
||||
assert.ok(!page.data.sidebarTabs.some((tab) => tab.id === 'papers'));
|
||||
handler = () => {
|
||||
throw new Error('offline');
|
||||
};
|
||||
await page.loadWorksheets();
|
||||
assert.equal(page.data.displayItems.length, 0);
|
||||
assert.equal(page.data.sidebarTabs.length, 3);
|
||||
assert.ok(page.data.errorMessage);
|
||||
useRows([worksheet('retry')]);
|
||||
await page.loadWorksheets();
|
||||
assert.equal(page.data.errorMessage, '');
|
||||
assert.equal(page.data.displayItems[0].id, 'retry');
|
||||
});
|
||||
|
||||
test('页面:旧请求/离开页面后的响应不会覆盖新请求', async () => {
|
||||
let finishOld;
|
||||
let calls = 0;
|
||||
const page = createCategoryPage({
|
||||
...contentApi,
|
||||
listCategories: () =>
|
||||
++calls === 1
|
||||
? new Promise((resolve) => {
|
||||
finishOld = resolve;
|
||||
})
|
||||
: Promise.resolve(categories),
|
||||
listAllWorksheets: async () => [worksheet(calls === 1 ? 'old' : 'new')],
|
||||
});
|
||||
const first = page.loadWorksheets();
|
||||
await page.loadWorksheets();
|
||||
assert.equal(page.data.displayItems[0].id, 'new');
|
||||
finishOld(categories);
|
||||
await first;
|
||||
assert.equal(page.data.displayItems[0].id, 'new');
|
||||
let finish;
|
||||
const hiddenPage = createCategoryPage({
|
||||
...contentApi,
|
||||
listCategories: () =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
listAllWorksheets: async () => [],
|
||||
});
|
||||
const pending = hiddenPage.loadWorksheets();
|
||||
hiddenPage.onHide();
|
||||
finish(categories);
|
||||
await pending;
|
||||
assert.equal(hiddenPage.data.loading, true);
|
||||
});
|
||||
|
||||
test('页面:点击前检查详情;正常跳转,404 下架刷新并阻止进入本地页面', async () => {
|
||||
const page = createCategoryPage();
|
||||
const row = worksheet('live');
|
||||
useRows([row]);
|
||||
await page.loadWorksheets();
|
||||
await page.onTapItem({ currentTarget: { dataset: { id: 'live' } } });
|
||||
assert.deepEqual(navigations, [row.path]);
|
||||
navigations.length = 0;
|
||||
useRows([]);
|
||||
await page.onTapItem({ currentTarget: { dataset: { id: 'live' } } });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(navigations.length, 0);
|
||||
assert.equal(page.data.displayItems.length, 0);
|
||||
});
|
||||
|
||||
test(
|
||||
'本地联调:真实 8800 分类、全部题型、页面展示与点击详情一致',
|
||||
{ skip: process.env.TEST_LOCAL_API !== '1' },
|
||||
async () => {
|
||||
const realRequest = globalThis.wx.request;
|
||||
globalThis.wx.request = (options) => {
|
||||
const url = new URL(options.url);
|
||||
for (const [key, value] of Object.entries(options.data || {}))
|
||||
url.searchParams.set(key, String(value));
|
||||
fetch(url, { signal: AbortSignal.timeout(5000) })
|
||||
.then(async (response) =>
|
||||
options.success({
|
||||
statusCode: response.status,
|
||||
data: await response.json(),
|
||||
}),
|
||||
)
|
||||
.catch(() => options.fail({}));
|
||||
};
|
||||
try {
|
||||
const page = createCategoryPage();
|
||||
await page.loadWorksheets();
|
||||
assert.equal(page.data.errorMessage, '');
|
||||
const rows = await contentApi.listAllWorksheets();
|
||||
assert.ok(rows.length > 0);
|
||||
assert.equal(page.data.displayItems.length, rows.length);
|
||||
assert.ok(page.data.displayItems.every((row) => row.icon));
|
||||
await page.onTapItem({
|
||||
currentTarget: {
|
||||
dataset: { id: page.data.displayItems[0].id },
|
||||
},
|
||||
});
|
||||
assert.equal(navigations.length, 1);
|
||||
console.log(
|
||||
`本地联调:${page.data.sidebarTabs.length - 3} 个分类、${rows.length} 个题型。`,
|
||||
);
|
||||
} finally {
|
||||
globalThis.wx.request = realRequest;
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,425 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { test } from 'node:test';
|
||||
import ts from 'typescript';
|
||||
|
||||
const deferred = () => {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
const tick = () => new Promise(setImmediate);
|
||||
|
||||
/** 加载真实配置、请求、认证、App 与切换流程,仅模拟微信运行时和服务端响应。 */
|
||||
function fixture(storage = new Map()) {
|
||||
let version = 'develop';
|
||||
let app;
|
||||
let pages = [];
|
||||
let prefetchCalls = 0;
|
||||
let navigationCount = 0;
|
||||
let loginCount = 0;
|
||||
let handler = ({ url, environment }) => {
|
||||
if (url.endsWith('/auth/wechat/login')) {
|
||||
return {
|
||||
accessToken: `token-${environment}`,
|
||||
tokenType: 'Bearer',
|
||||
expiresIn: 86400,
|
||||
expiresAt: '2099-01-01T00:00:00Z',
|
||||
user: {
|
||||
id: `user-${environment}`,
|
||||
nickName: environment,
|
||||
avatarUrl: null,
|
||||
avatarUrlExpiresAt: null,
|
||||
createdAt: '2026-10-19T00:00:00Z',
|
||||
},
|
||||
};
|
||||
}
|
||||
return { home: { marker: environment } };
|
||||
};
|
||||
const requests = [];
|
||||
const wx = {
|
||||
getAccountInfoSync: () => ({
|
||||
miniProgram: { appId: 'wx-env-test', envVersion: version },
|
||||
}),
|
||||
getStorageSync: (key) => storage.get(key),
|
||||
setStorageSync: (key, value) =>
|
||||
storage.set(key, structuredClone(value)),
|
||||
removeStorageSync: (key) => storage.delete(key),
|
||||
login({ success }) {
|
||||
loginCount++;
|
||||
success({ code: `code-${loginCount}` });
|
||||
},
|
||||
request(options) {
|
||||
send(options);
|
||||
},
|
||||
uploadFile(options) {
|
||||
send(options, true);
|
||||
},
|
||||
getBackgroundFetchData({ success }) {
|
||||
prefetchCalls++;
|
||||
success({
|
||||
fetchedData: JSON.stringify({
|
||||
home: { marker: 'legacy-prefetch' },
|
||||
}),
|
||||
});
|
||||
},
|
||||
reLaunch({ url, success }) {
|
||||
assert.equal(url, '/pages/home/home');
|
||||
navigationCount++;
|
||||
const page = {
|
||||
route: 'pages/home/home',
|
||||
data: {},
|
||||
initData(config) {
|
||||
this.data.config = config;
|
||||
},
|
||||
};
|
||||
pages = [page];
|
||||
void app.getPageConfig().then((config) => page.initData(config));
|
||||
success();
|
||||
},
|
||||
};
|
||||
function send(options, upload = false) {
|
||||
const request = {
|
||||
...options,
|
||||
environment: load('config/api').getApiConfig().environment,
|
||||
};
|
||||
requests.push(request);
|
||||
Promise.resolve()
|
||||
.then(() => handler(request))
|
||||
.then(
|
||||
(data) => {
|
||||
const body = { code: 0, data };
|
||||
options.success({
|
||||
statusCode: 200,
|
||||
data: upload ? JSON.stringify(body) : body,
|
||||
});
|
||||
},
|
||||
() => options.fail({}),
|
||||
);
|
||||
}
|
||||
const modules = new Map();
|
||||
const context = vm.createContext({
|
||||
wx,
|
||||
console,
|
||||
Error,
|
||||
getApp: () => app,
|
||||
getCurrentPages: () => pages,
|
||||
App(value) {
|
||||
app = value;
|
||||
},
|
||||
});
|
||||
function load(file) {
|
||||
file = resolve('miniprogram', file);
|
||||
if (!file.endsWith('.ts')) file += '.ts';
|
||||
if (modules.has(file)) return modules.get(file);
|
||||
const exports = {};
|
||||
modules.set(file, exports);
|
||||
const code = ts.transpileModule(readFileSync(file, 'utf8'), {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
},
|
||||
}).outputText;
|
||||
vm.runInContext(`(function(require,exports){${code}\n})`, context)(
|
||||
(name) => load(resolve(dirname(file), name)),
|
||||
exports,
|
||||
);
|
||||
return exports;
|
||||
}
|
||||
load('app');
|
||||
return {
|
||||
config: load('config/api'),
|
||||
auth: load('utils/auth'),
|
||||
api: load('services/api-request'),
|
||||
member: load('services/member-api'),
|
||||
switcher: load('services/environment-switch'),
|
||||
activity: load('services/api-activity'),
|
||||
app,
|
||||
wx,
|
||||
storage,
|
||||
requests,
|
||||
handle(fn) {
|
||||
handler = fn;
|
||||
},
|
||||
version(value) {
|
||||
version = value;
|
||||
},
|
||||
pages(value) {
|
||||
pages = value;
|
||||
},
|
||||
get currentPages() {
|
||||
return pages;
|
||||
},
|
||||
get prefetchCalls() {
|
||||
return prefetchCalls;
|
||||
},
|
||||
get navigationCount() {
|
||||
return navigationCount;
|
||||
},
|
||||
get loginCount() {
|
||||
return loginCount;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('同址三环境仍隔离凭证与缓存;切换重建首页和个人状态,切回复用会话', async () => {
|
||||
const f = fixture(
|
||||
new Map([['pageConfig', { home: { marker: 'legacy-storage' } }]]),
|
||||
);
|
||||
await f.app.loadPageConfig();
|
||||
await f.auth.getUser();
|
||||
assert.equal(f.app.globalData.pageConfig.home.marker, 'local');
|
||||
const localConfig = f.config.getApiConfig();
|
||||
f.app.globalData.pendingCategoryTabId = 'math';
|
||||
for (const target of ['staging', 'production', 'local']) {
|
||||
await f.switcher.switchApiEnvironment(target);
|
||||
await tick();
|
||||
assert.equal(f.app.globalData.user.id, `user-${target}`);
|
||||
assert.equal(f.app.globalData.pageConfig.home.marker, target);
|
||||
assert.equal(f.currentPages[0].data.config.home.marker, target);
|
||||
assert.equal(f.app.globalData.pendingCategoryTabId, undefined);
|
||||
}
|
||||
assert.equal(f.loginCount, 3);
|
||||
assert.equal(f.navigationCount, 3);
|
||||
assert.equal(f.prefetchCalls, 0);
|
||||
assert.equal(f.config.isCurrentApiConfig(localConfig), false);
|
||||
const cacheKeys = [...f.storage.keys()].filter((key) =>
|
||||
key.startsWith('doodle_page_config_v2:'),
|
||||
);
|
||||
assert.equal(cacheKeys.length, 3);
|
||||
const authKeys = [...f.storage.keys()].filter((key) =>
|
||||
key.startsWith('doodle_auth_v2:'),
|
||||
);
|
||||
assert.equal(authKeys.length, 3);
|
||||
const restored = fixture(f.storage);
|
||||
assert.equal(restored.config.getApiConfig().environment, 'local');
|
||||
f.config.selectApiEnvironment('staging');
|
||||
assert.equal(
|
||||
fixture(f.storage).config.getApiConfig().environment,
|
||||
'staging',
|
||||
);
|
||||
});
|
||||
|
||||
test('trial/release/未知包固定 production,不能通过缓存或切换入口覆盖;保留原预拉取', async () => {
|
||||
const f = fixture();
|
||||
f.config.selectApiEnvironment('staging');
|
||||
for (const version of ['trial', 'release', 'unknown']) {
|
||||
f.version(version);
|
||||
assert.equal(f.config.getApiConfig().environment, 'production');
|
||||
await assert.rejects(
|
||||
f.switcher.switchApiEnvironment('local'),
|
||||
/仅开发版/,
|
||||
);
|
||||
assert.throws(() => f.config.selectApiEnvironment('local'), /仅开发版/);
|
||||
}
|
||||
f.version('trial');
|
||||
await f.app.loadPageConfig();
|
||||
assert.equal(f.prefetchCalls, 1);
|
||||
assert.equal(f.app.globalData.pageConfig.home.marker, 'legacy-prefetch');
|
||||
});
|
||||
|
||||
test('公开接口旧响应在 A→B→A 后仍作废;不会作为当前请求成功返回', async () => {
|
||||
const f = fixture();
|
||||
const gate = deferred();
|
||||
f.handle(() => gate.promise);
|
||||
const pending = f.api.apiRequest('/categories', { auth: 'none' });
|
||||
const rejected = assert.rejects(pending, /环境已变化/);
|
||||
await tick();
|
||||
f.config.selectApiEnvironment('staging');
|
||||
f.config.selectApiEnvironment('local');
|
||||
gate.resolve([]);
|
||||
await rejected;
|
||||
});
|
||||
|
||||
test('写请求、上传及登录等待全过程禁止切换,完成后可切换', async () => {
|
||||
const f = fixture();
|
||||
await f.auth.getUser();
|
||||
const gate = deferred();
|
||||
f.handle(() => gate.promise);
|
||||
const pending = f.member.uploadAsset('/tmp/test.jpg', 'preview');
|
||||
await tick();
|
||||
assert.equal(f.activity.hasPendingApiWrites(), true);
|
||||
await assert.rejects(
|
||||
f.switcher.switchApiEnvironment('staging'),
|
||||
/等待当前保存或上传/,
|
||||
);
|
||||
assert.equal(f.config.getApiConfig().environment, 'local');
|
||||
gate.resolve({
|
||||
id: 'test-preview',
|
||||
url: 'https://example.test/preview.jpg',
|
||||
});
|
||||
await pending;
|
||||
assert.equal(f.activity.hasPendingApiWrites(), false);
|
||||
f.pages([{ data: { debugPublishLoading: true } }]);
|
||||
await assert.rejects(
|
||||
f.switcher.switchApiEnvironment('staging'),
|
||||
/等待当前保存或上传/,
|
||||
);
|
||||
});
|
||||
|
||||
test('未配置地址与缓存写入失败不改变当前环境;目标连接失败不回退、不展示旧内容', async () => {
|
||||
const f = fixture();
|
||||
f.config.apiConfig.stagingBaseUrl = '';
|
||||
assert.equal(f.config.getApiEnvironmentOptions()[1].available, false);
|
||||
await assert.rejects(
|
||||
f.switcher.switchApiEnvironment('staging'),
|
||||
/配置 staging/,
|
||||
);
|
||||
assert.equal(f.config.getApiConfig().environment, 'local');
|
||||
f.config.apiConfig.stagingBaseUrl = f.config.apiConfig.localBaseUrl;
|
||||
const original = f.wx.setStorageSync;
|
||||
f.wx.setStorageSync = () => {
|
||||
throw new Error('storage full');
|
||||
};
|
||||
await assert.rejects(
|
||||
f.switcher.switchApiEnvironment('staging'),
|
||||
/storage full/,
|
||||
);
|
||||
assert.equal(f.config.getApiConfig().environment, 'local');
|
||||
f.wx.setStorageSync = original;
|
||||
f.app.globalData.pageConfig = { home: { marker: 'local-old' } };
|
||||
f.handle(() => {
|
||||
throw new Error('offline');
|
||||
});
|
||||
await assert.rejects(
|
||||
f.switcher.switchApiEnvironment('staging'),
|
||||
/目标接口连接失败/,
|
||||
);
|
||||
assert.equal(f.config.getApiConfig().environment, 'staging');
|
||||
assert.equal(f.app.globalData.pageConfig, undefined);
|
||||
assert.equal(f.currentPages[0].data.config, null);
|
||||
});
|
||||
|
||||
test('旧首页初始化与登录迟到响应不污染切换后的共享状态,旧等待者被释放', async () => {
|
||||
const f = fixture();
|
||||
const gate = deferred();
|
||||
f.handle(({ environment, url }) => {
|
||||
if (environment === 'local') return gate.promise;
|
||||
if (url.endsWith('/page-config'))
|
||||
return { home: { marker: 'staging' } };
|
||||
throw new Error('skip login');
|
||||
});
|
||||
const waiter = f.app.getPageConfig();
|
||||
const pendingConfig = f.app.loadPageConfig();
|
||||
const pendingLogin = f.auth.getUser();
|
||||
const rejected = assert.rejects(pendingLogin, /环境已变化|登录状态已变化/);
|
||||
await tick();
|
||||
await f.switcher.switchApiEnvironment('staging');
|
||||
assert.equal(await waiter, null);
|
||||
gate.resolve({ home: { marker: 'old-local' } });
|
||||
await pendingConfig;
|
||||
await rejected;
|
||||
assert.equal(f.app.globalData.pageConfig.home.marker, 'staging');
|
||||
assert.equal(f.app.globalData.user, null);
|
||||
});
|
||||
|
||||
test('配置变更使当前地址失效时仍能切走;写请求等待首次登录期间也禁止切换', async () => {
|
||||
const f = fixture();
|
||||
f.config.selectApiEnvironment('staging');
|
||||
f.config.apiConfig.stagingBaseUrl = '';
|
||||
assert.throws(() => f.config.getApiConfig(), /配置 staging/);
|
||||
const waiting = f.app.getPageConfig();
|
||||
assert.equal(await f.app.loadPageConfig(), false);
|
||||
assert.equal(await waiting, null);
|
||||
await f.switcher.switchApiEnvironment('local');
|
||||
assert.equal(f.config.getApiConfig().environment, 'local');
|
||||
const g = fixture();
|
||||
const gate = deferred();
|
||||
g.handle(({ url }) =>
|
||||
url.endsWith('/auth/wechat/login') ? gate.promise : {},
|
||||
);
|
||||
const write = g.api.apiRequest('/me/profile', {
|
||||
method: 'PUT',
|
||||
data: { nickName: 'test' },
|
||||
});
|
||||
await tick();
|
||||
assert.equal(
|
||||
g.requests.filter((row) => row.url.endsWith('/me/profile')).length,
|
||||
0,
|
||||
);
|
||||
await assert.rejects(
|
||||
g.switcher.switchApiEnvironment('staging'),
|
||||
/等待当前保存或上传/,
|
||||
);
|
||||
gate.resolve({
|
||||
accessToken: 'token-local',
|
||||
tokenType: 'Bearer',
|
||||
expiresIn: 86400,
|
||||
expiresAt: '2099-01-01T00:00:00Z',
|
||||
user: {
|
||||
id: 'user-local',
|
||||
nickName: null,
|
||||
avatarUrl: null,
|
||||
avatarUrlExpiresAt: null,
|
||||
createdAt: '2026-10-19T00:00:00Z',
|
||||
},
|
||||
});
|
||||
await write;
|
||||
assert.equal(g.activity.hasPendingApiWrites(), false);
|
||||
});
|
||||
|
||||
test('Debug 页面调用统一切换入口,失败时隐藏 loading 后显示错误;非开发包拒绝操作', async () => {
|
||||
const f = fixture();
|
||||
let definition;
|
||||
let toasts = [];
|
||||
let loading = false;
|
||||
f.wx.showLoading = () => {
|
||||
loading = true;
|
||||
};
|
||||
f.wx.hideLoading = () => {
|
||||
loading = false;
|
||||
};
|
||||
f.wx.showToast = (options) => {
|
||||
assert.equal(loading, false);
|
||||
toasts.push(options.title);
|
||||
};
|
||||
const source = ts.transpileModule(
|
||||
readFileSync('miniprogram/supportPages/debug/debug.ts', 'utf8'),
|
||||
{
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
},
|
||||
},
|
||||
).outputText;
|
||||
vm.runInNewContext(source, {
|
||||
exports: {},
|
||||
wx: f.wx,
|
||||
Error,
|
||||
getCurrentPages: () => [definition],
|
||||
require(name) {
|
||||
return name === '../../config/api' ? f.config : f.switcher;
|
||||
},
|
||||
Page(value) {
|
||||
definition = {
|
||||
...value,
|
||||
setData(value) {
|
||||
Object.assign(this.data, value);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
definition.onLoad();
|
||||
definition.onShow();
|
||||
assert.equal(definition.data.currentEnvironment, 'local');
|
||||
f.config.apiConfig.stagingBaseUrl = '';
|
||||
await definition.onSelectEnvironment({
|
||||
currentTarget: { dataset: { environment: 'staging' } },
|
||||
});
|
||||
assert.match(toasts.at(-1), /配置 staging/);
|
||||
assert.equal(definition.data.isSwitchingEnvironment, false);
|
||||
f.version('trial');
|
||||
definition.onLoad();
|
||||
definition.onShow();
|
||||
assert.equal(definition.data.isDevEnv, false);
|
||||
assert.equal(definition.data.currentEnvironment, 'production');
|
||||
toasts = [];
|
||||
await definition.onSelectEnvironment({
|
||||
currentTarget: { dataset: { environment: 'local' } },
|
||||
});
|
||||
assert.equal(toasts.length, 0);
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
import vm from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
import * as config from '../miniprogram/papersPages/paperSheet/paperSheet.config.ts';
|
||||
import { PAPER_SHEET_WORKSHEET_DEFINITIONS } from '../miniprogram/config/worksheets/papers.ts';
|
||||
import * as debugPublish from '../miniprogram/utils/debugPublish.ts';
|
||||
import { ApiRequestError } from '../miniprogram/services/api-transport.ts';
|
||||
|
||||
const route = 'papersPages/paperSheet/paperSheet';
|
||||
const deferred = () => {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
/** 使用真实页面和公共分享/导出方法,仅隔离微信、网络、绘图与埋点。 */
|
||||
function createPage({
|
||||
getWorksheet,
|
||||
favorite = async () => true,
|
||||
check = async () => ({}),
|
||||
} = {}) {
|
||||
const toasts = [],
|
||||
downloads = [],
|
||||
favorites = [];
|
||||
const wx = {
|
||||
getAccountInfoSync: () => ({ miniProgram: { envVersion: 'release' } }),
|
||||
setNavigationBarTitle() {},
|
||||
showToast: (options) => toasts.push(options.title),
|
||||
};
|
||||
const load = (file, modules) => {
|
||||
const exports = {};
|
||||
const source = readFileSync(new URL(file, import.meta.url), 'utf8');
|
||||
vm.runInNewContext(
|
||||
ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
},
|
||||
}).outputText,
|
||||
{
|
||||
exports,
|
||||
wx,
|
||||
console,
|
||||
Error,
|
||||
require(id) {
|
||||
assert.ok(id in modules, id);
|
||||
return modules[id];
|
||||
},
|
||||
},
|
||||
);
|
||||
return exports;
|
||||
};
|
||||
const defaultShareConfig = {
|
||||
title: '分享',
|
||||
path: '/pages/index/index',
|
||||
imageUrl: '',
|
||||
};
|
||||
const mixin = load('../miniprogram/base/pageMixin.ts', {
|
||||
'../services/member-api': {},
|
||||
'../services/api-request': {},
|
||||
'../constants/colors': {},
|
||||
'../core/draw/baseDraw': {},
|
||||
'../utils/downloadPrint': {
|
||||
downloadPrint: async (_canvas, options) => downloads.push(options),
|
||||
},
|
||||
'../utils/shareGuide': {
|
||||
shouldUnlockWithAd: () => false,
|
||||
shouldShowShareGuide: () => false,
|
||||
},
|
||||
'../utils/tracker': { default: new Proxy({}, { get: () => () => {} }) },
|
||||
'../config/config': { defaultShareConfig },
|
||||
'../utils/debugPublish': {
|
||||
...debugPublish,
|
||||
isDebugPublishEnabled: () =>
|
||||
wx.getAccountInfoSync().miniProgram.envVersion === 'develop',
|
||||
},
|
||||
});
|
||||
let options;
|
||||
load('../miniprogram/papersPages/paperSheet/paperSheet.ts', {
|
||||
'./draw/paperDrawService': {},
|
||||
'./paperSheet.config': config,
|
||||
'../../base/pageMixin': {
|
||||
...mixin,
|
||||
createPage(value, settings) {
|
||||
options = mixin.applyPageMixin(value, settings);
|
||||
},
|
||||
},
|
||||
'../../config/config': { defaultShareConfig },
|
||||
'../../services/api-request': { ApiRequestError },
|
||||
'../../services/content-api': {
|
||||
getWorksheet:
|
||||
getWorksheet ||
|
||||
(async (id) => ({
|
||||
id,
|
||||
rendering: {
|
||||
rendererKey: 'paper-sheet',
|
||||
paperType: config.resolvePaperEntry({ id }).paperType,
|
||||
},
|
||||
})),
|
||||
},
|
||||
'../../utils/favorites': {
|
||||
addFavorite: async (id) => {
|
||||
favorites.push(['add', id]);
|
||||
return favorite(id);
|
||||
},
|
||||
removeFavorite: async (id) => {
|
||||
favorites.push(['remove', id]);
|
||||
return favorite(id);
|
||||
},
|
||||
batchCheckFavorited: check,
|
||||
},
|
||||
});
|
||||
const page = {
|
||||
...options,
|
||||
route,
|
||||
data: structuredClone(options.data),
|
||||
setData(value, callback) {
|
||||
Object.assign(this.data, value);
|
||||
callback?.();
|
||||
},
|
||||
drawService: { async draw() {} },
|
||||
canvas: {},
|
||||
};
|
||||
return { page, toasts, downloads, favorites, wx };
|
||||
}
|
||||
|
||||
test('十种独立条目复用绘制器,旧入口/type 分享兼容,独立 ID 不受额外 type 干扰', () => {
|
||||
assert.equal(PAPER_SHEET_WORKSHEET_DEFINITIONS.length, 11);
|
||||
for (const paper of config.PAPER_TYPE_OPTIONS) {
|
||||
const id = config.getWorksheetIdForPaperType(paper.id);
|
||||
const { entry, paperType } = config.resolvePaperEntry({
|
||||
id,
|
||||
type: 'unknown',
|
||||
});
|
||||
assert.equal(paperType, paper.id);
|
||||
assert.equal(entry.rendererKey, 'paper-sheet');
|
||||
assert.equal(
|
||||
config.getPublishMetaByMode(id).path,
|
||||
`/${route}?id=${id}`,
|
||||
);
|
||||
}
|
||||
assert.equal(config.resolvePaperEntry({}).entry.id, 'paper-sheet');
|
||||
assert.equal(
|
||||
config.resolvePaperEntry({ id: 'paper-sheet', type: 'mi' }).paperType,
|
||||
'mi',
|
||||
);
|
||||
assert.equal(
|
||||
config.resolvePaperEntry({ id: 'paper-sheet', mode: 'vertical' })
|
||||
.paperType,
|
||||
'vertical',
|
||||
);
|
||||
assert.throws(() => config.resolvePaperEntry({ id: 'paper-unknown' }));
|
||||
});
|
||||
|
||||
test('切换后页面身份、收藏、分享、发布和实际导出统计均属于当前条目', async () => {
|
||||
const { page, downloads, favorites } = createPage();
|
||||
await page.onLoad({ id: 'paper-mi' });
|
||||
assert.equal(page.data.pageTitle, '米字格');
|
||||
assert.equal(page.data.functionId, 'paper-mi');
|
||||
assert.equal(page.data.selectedPaperType, 'mi');
|
||||
assert.equal(page.onShareAppMessage().path, `/${route}?id=paper-mi`);
|
||||
await page.onPreviewFavorite();
|
||||
assert.deepEqual(favorites, [['add', 'paper-mi']]);
|
||||
await page.exportToPrint();
|
||||
assert.equal(downloads[0].worksheetId, 'paper-mi');
|
||||
page.setData({
|
||||
debugPublishVisible: true,
|
||||
debugPublishMeta: page.getPublishMeta(),
|
||||
});
|
||||
await page.onSelectPaperType({
|
||||
currentTarget: { dataset: { id: 'tian' } },
|
||||
});
|
||||
assert.equal(page.data.debugPublishVisible, false);
|
||||
assert.equal(page.data.debugPublishMeta, null);
|
||||
assert.equal(page.data.functionId, 'paper-tian');
|
||||
assert.equal(page.data.isPreviewFavorite, false);
|
||||
assert.equal(page.getPublishMeta().id, 'paper-tian');
|
||||
assert.equal(page.onShareTimeline().query, 'id=paper-tian');
|
||||
await page.onSelectPaperType({ currentTarget: { dataset: { id: 'mi' } } });
|
||||
assert.equal(page.data.isPreviewFavorite, true);
|
||||
await page.onPreviewFavorite();
|
||||
assert.deepEqual(favorites[1], ['remove', 'paper-mi']);
|
||||
await page.onLoad({ id: 'paper-sheet', type: 'vertical' });
|
||||
assert.equal(
|
||||
page.onShareAppMessage().path,
|
||||
`/${route}?id=paper-sheet&mode=vertical`,
|
||||
);
|
||||
});
|
||||
|
||||
test('快速切换丢弃旧响应,加载期间禁止导出,未知条目/下架/配置错误不生成其他纸张', async () => {
|
||||
const old = deferred();
|
||||
const { page, downloads, toasts } = createPage({
|
||||
getWorksheet: (id) =>
|
||||
id === 'paper-mi'
|
||||
? old.promise
|
||||
: Promise.resolve({
|
||||
rendering: {
|
||||
rendererKey: 'paper-sheet',
|
||||
paperType: 'tian',
|
||||
},
|
||||
}),
|
||||
});
|
||||
const first = page.onLoad({ id: 'paper-mi' });
|
||||
await page.exportToPrint();
|
||||
assert.equal(downloads.length, 0);
|
||||
await page.onSelectPaperType({
|
||||
currentTarget: { dataset: { id: 'tian' } },
|
||||
});
|
||||
old.resolve({ rendering: { rendererKey: 'paper-sheet', paperType: 'mi' } });
|
||||
await first;
|
||||
assert.equal(page.data.worksheetId, 'paper-tian');
|
||||
await page.openEntry({ id: 'paper-unknown' });
|
||||
assert.equal(page.data.hasContent, false);
|
||||
assert.ok(toasts.length);
|
||||
for (const getWorksheet of [
|
||||
async () => {
|
||||
throw new ApiRequestError('已下架', 404);
|
||||
},
|
||||
async () => ({
|
||||
rendering: { rendererKey: 'paper-sheet', paperType: 'tian' },
|
||||
}),
|
||||
]) {
|
||||
const blocked = createPage({ getWorksheet }).page;
|
||||
await blocked.onLoad({ id: 'paper-mi' });
|
||||
assert.equal(blocked.data.hasContent, false);
|
||||
}
|
||||
});
|
||||
|
||||
test('收藏失败不报成功,慢速查询不能覆盖新收藏,切换时收藏响应不写错条目', async () => {
|
||||
const pending = deferred();
|
||||
const { page, toasts } = createPage({ favorite: async () => false });
|
||||
await page.onLoad({ id: 'paper-mi' });
|
||||
await page.onPreviewFavorite();
|
||||
assert.equal(page.data.isPreviewFavorite, false);
|
||||
assert.ok(toasts.some((title) => title.includes('失败')));
|
||||
const active = createPage({ check: () => pending.promise }).page;
|
||||
await active.onLoad({ id: 'paper-mi' });
|
||||
await active.onPreviewFavorite();
|
||||
pending.resolve({ 'paper-mi': false });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(active.data.isPreviewFavorite, true);
|
||||
const write = deferred();
|
||||
const switching = createPage({ favorite: () => write.promise }).page;
|
||||
await switching.onLoad({ id: 'paper-mi' });
|
||||
const saving = switching.onPreviewFavorite();
|
||||
await switching.openEntry({ id: 'paper-tian' });
|
||||
write.resolve(true);
|
||||
await saving;
|
||||
assert.equal(switching.data.isPreviewFavorite, false);
|
||||
assert.equal(switching._favoritedMap['paper-mi'], true);
|
||||
});
|
||||
|
||||
test('开发版离线预览十种已注册纸张,切换不请求公开详情,未知 ID 仍不可用', async () => {
|
||||
let requests = 0;
|
||||
const { page, wx, toasts } = createPage({
|
||||
getWorksheet: async () => {
|
||||
requests++;
|
||||
throw new Error('开发预览不应请求公开详情');
|
||||
},
|
||||
});
|
||||
wx.getAccountInfoSync = () => ({ miniProgram: { envVersion: 'develop' } });
|
||||
await page.onLoad({ id: 'paper-sheet' });
|
||||
for (const paper of config.PAPER_TYPE_OPTIONS) {
|
||||
await page.onSelectPaperType({
|
||||
currentTarget: { dataset: { id: paper.id } },
|
||||
});
|
||||
assert.equal(page.data.hasContent, true);
|
||||
assert.equal(page.data.selectedPaperType, paper.id);
|
||||
assert.equal(
|
||||
page.getPublishMeta().id,
|
||||
config.getWorksheetIdForPaperType(paper.id),
|
||||
);
|
||||
}
|
||||
assert.equal(requests, 0);
|
||||
assert.equal(toasts.length, 0);
|
||||
await page.openEntry({ id: 'paper-unknown' });
|
||||
assert.equal(page.data.hasContent, false);
|
||||
assert.equal(requests, 0);
|
||||
assert.equal(toasts.length, 1);
|
||||
});
|
||||
|
||||
test('体验版与正式版均校验公开详情,404 和网络故障不能降级成本地预览', async () => {
|
||||
for (const version of ['trial', 'release']) {
|
||||
for (const failure of [
|
||||
new ApiRequestError('未发布', 404),
|
||||
new ApiRequestError('网络故障', 0),
|
||||
]) {
|
||||
let requests = 0;
|
||||
const { page, wx } = createPage({
|
||||
getWorksheet: async () => {
|
||||
requests++;
|
||||
throw failure;
|
||||
},
|
||||
});
|
||||
wx.getAccountInfoSync = () => ({
|
||||
miniProgram: { envVersion: version },
|
||||
});
|
||||
await page.onLoad({ id: 'paper-square' });
|
||||
assert.equal(requests, 1);
|
||||
assert.equal(page.data.hasContent, false);
|
||||
assert.equal(page.data.isWorksheetReady, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
import vm from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
import {
|
||||
assetConfig,
|
||||
resolvePreviewUrl,
|
||||
normalizePreviewImages,
|
||||
} from '../miniprogram/config/assets.ts';
|
||||
|
||||
const legacyRoot =
|
||||
'cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/';
|
||||
const preview = 'assets/previews/math/number-find.jpg';
|
||||
const expected = `https://cdn.joeyone.cn/doodle-assets/${preview}`;
|
||||
|
||||
test('预览图:旧 fileID 和云存储临时链接映射到统一七牛配置', () => {
|
||||
assert.equal(resolvePreviewUrl(`${legacyRoot}${preview}`), expected);
|
||||
assert.equal(
|
||||
resolvePreviewUrl(
|
||||
`https://636c-cloud1-9gifs7a2756e2c87-1351593184.tcb.qcloud.la/${preview}?sign=old&t=123`,
|
||||
),
|
||||
expected,
|
||||
);
|
||||
assert.equal(resolvePreviewUrl(expected), expected);
|
||||
const previous = { ...assetConfig };
|
||||
try {
|
||||
assetConfig.publicDomain = 'https://assets.example.test';
|
||||
assetConfig.keyPrefix = 'next/';
|
||||
assert.equal(
|
||||
resolvePreviewUrl(`${legacyRoot}${preview}`),
|
||||
`https://assets.example.test/next/${preview}`,
|
||||
);
|
||||
} finally {
|
||||
Object.assign(assetConfig, previous);
|
||||
}
|
||||
});
|
||||
|
||||
test('预览图:外部地址、本地图片和头像不替换,畸形预览不产生 CDN 请求', () => {
|
||||
for (const raw of [
|
||||
'/assets/local.png',
|
||||
'https://elsewhere.test/assets/previews/a.jpg',
|
||||
'https://636c-cloud1-9gifs7a2756e2c87-1351593184.tcb.qcloud.la.evil.test/assets/previews/a.jpg',
|
||||
`${legacyRoot}user-avatars/u.png`,
|
||||
'cloud://another-env/assets/previews/a.jpg',
|
||||
'',
|
||||
])
|
||||
assert.equal(resolvePreviewUrl(raw), raw);
|
||||
for (const raw of [
|
||||
`${legacyRoot}assets/previews/../private.jpg`,
|
||||
`${legacyRoot}assets/previews/%2e%2e/private.jpg`,
|
||||
`${legacyRoot}assets/previews/%broken.jpg`,
|
||||
])
|
||||
assert.equal(resolvePreviewUrl(raw), '');
|
||||
assert.equal(resolvePreviewUrl(null), '');
|
||||
});
|
||||
|
||||
test('预览图:嵌套首页分区、收藏及下载记录转换且不修改原数据', () => {
|
||||
const raw = {
|
||||
home: {
|
||||
featured: [{ previewImg: `${legacyRoot}${preview}` }],
|
||||
sections: [{ items: [{ previewImg: `${legacyRoot}${preview}` }] }],
|
||||
},
|
||||
records: [{ worksheet: { previewImg: `${legacyRoot}${preview}` } }],
|
||||
avatarUrl: `${legacyRoot}user-avatars/u.png`,
|
||||
};
|
||||
const converted = normalizePreviewImages(raw);
|
||||
assert.equal(converted.home.featured[0].previewImg, expected);
|
||||
assert.equal(converted.home.sections[0].items[0].previewImg, expected);
|
||||
assert.equal(converted.records[0].worksheet.previewImg, expected);
|
||||
assert.equal(raw.home.featured[0].previewImg, `${legacyRoot}${preview}`);
|
||||
assert.equal(converted.avatarUrl, raw.avatarUrl);
|
||||
});
|
||||
|
||||
test('首页配置:预拉取、旧 storage 缓存及新后端兜底均写回七牛预览图', async () => {
|
||||
const source = readFileSync(
|
||||
new URL('../miniprogram/app.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const code = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
},
|
||||
}).outputText;
|
||||
for (const entry of ['prefetch', 'storage', 'backend']) {
|
||||
const old = {
|
||||
home: { featured: [{ previewImg: `${legacyRoot}${preview}` }] },
|
||||
};
|
||||
let app;
|
||||
let stored;
|
||||
const sandbox = {
|
||||
exports: {},
|
||||
getCurrentPages: () => [],
|
||||
require(name) {
|
||||
if (name === './services/site-api')
|
||||
return { fetchPageConfig: async () => old };
|
||||
if (name === './config/api')
|
||||
return {
|
||||
getApiConfig: () => ({ version: 'release' }),
|
||||
apiContextKey: () => 'test-production',
|
||||
isCurrentApiConfig: () => true,
|
||||
};
|
||||
if (name === './config/assets')
|
||||
return { normalizePreviewImages };
|
||||
return {};
|
||||
},
|
||||
App(definition) {
|
||||
app = definition;
|
||||
},
|
||||
wx: {
|
||||
getBackgroundFetchData({ success, fail }) {
|
||||
if (entry === 'prefetch')
|
||||
success({ fetchedData: JSON.stringify({ data: old }) });
|
||||
else fail(new Error('not available'));
|
||||
},
|
||||
getStorageSync() {
|
||||
return entry === 'storage' ? old : null;
|
||||
},
|
||||
setStorageSync(key, value) {
|
||||
assert.equal(key, 'doodle_page_config_v2:test-production');
|
||||
stored = value;
|
||||
},
|
||||
},
|
||||
};
|
||||
vm.runInNewContext(code, sandbox);
|
||||
const waiting = app.getPageConfig();
|
||||
await app.loadPageConfig();
|
||||
assert.equal((await waiting).home.featured[0].previewImg, expected);
|
||||
assert.equal(
|
||||
app.globalData.pageConfig.home.featured[0].previewImg,
|
||||
expected,
|
||||
);
|
||||
assert.equal(stored.home.featured[0].previewImg, expected);
|
||||
}
|
||||
});
|
||||
Vendored
+6
-1
@@ -13,7 +13,7 @@ interface PageConfigData {
|
||||
}>;
|
||||
};
|
||||
age?: Record<string, any>;
|
||||
version?: number;
|
||||
version?: number | string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
@@ -31,5 +31,10 @@ interface IAppOption {
|
||||
};
|
||||
userInfoReadyCallback?: WechatMiniprogram.GetUserInfoSuccessCallback;
|
||||
getPageConfig(): Promise<PageConfigData | null>;
|
||||
/** 切换接口环境时主动重置、初始化 App;页面重建不重新运行 onLaunch。 */
|
||||
resetApiState(): void;
|
||||
warmAuthentication(): void;
|
||||
loadPageConfig(forceRefresh?: boolean): Promise<boolean>;
|
||||
refreshPageConfig(): Promise<void>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user