Compare commits
2 Commits
08d9a2792a
...
8de06ce66b
| Author | SHA1 | Date | |
|---|---|---|---|
| 8de06ce66b | |||
| eddd7d75e2 |
@@ -1,5 +1,7 @@
|
|||||||
# Doodle / 涂鸦丫 - PC Web 端技术预案
|
# Doodle / 涂鸦丫 - PC Web 端技术预案
|
||||||
|
|
||||||
|
> **2026-10-12 决策更新**:本文保留为历史预案,DAU 启动门槛和浏览器承载完整生成器不作为当前决策。现有后端已决定迁移到自有服务器,见 [迁移方案(doodle-store)](../../doodle-store/docs/云服务端迁移方案.md);Web 范围和私有服务端生成见 [v3.5 策略](./v3.5/Web与服务端生成策略.md)。
|
||||||
|
|
||||||
> 状态:**远期规划**(当前聚焦小程序,暂不启动 Web 开发)
|
> 状态:**远期规划**(当前聚焦小程序,暂不启动 Web 开发)
|
||||||
> 最后更新:2026-03-24
|
> 最后更新:2026-03-24
|
||||||
> 配套文档:[技术架构设计文档](./技术架构设计文档.md) | [产品设计文档](./产品设计文档.md)
|
> 配套文档:[技术架构设计文档](./技术架构设计文档.md) | [产品设计文档](./产品设计文档.md)
|
||||||
|
|||||||
+7
-4
@@ -1,16 +1,17 @@
|
|||||||
# Doodle Mini v3.5 规划索引
|
# Doodle Mini v3.5 规划索引
|
||||||
|
|
||||||
> 状态:待评审
|
> 状态:产品规划待评审;云服务端迁移方向已确认,待实施
|
||||||
>
|
>
|
||||||
> 更新日期:2026-09-24
|
> 更新日期:2026-10-12
|
||||||
|
|
||||||
v3.5 的目标不是继续堆叠题型,而是把「生成一张练习纸」升级为家长可持续使用的家庭打印学习服务,并为未来 Web 内容平台建立服务端生成能力。
|
v3.5 的目标不是继续堆叠题型,而是把「生成一张练习纸」升级为家长可持续使用的家庭打印学习服务,并为未来 Web 内容平台建立服务端生成能力。
|
||||||
|
|
||||||
## 本阶段的三项决策
|
## 本阶段的四项决策
|
||||||
|
|
||||||
1. **小程序仍是当前主产品。** 优先改善家长首次找到内容、完成打印、再次回来使用的体验。
|
1. **小程序仍是当前主产品。** 优先改善家长首次找到内容、完成打印、再次回来使用的体验。
|
||||||
2. **创意涂色纸是下一项内容型 MVP。** 它覆盖低龄儿童、视觉传播力强,也最适合作为服务端生成的首个试点。
|
2. **创意涂色纸是下一项内容型 MVP。** 它覆盖低龄儿童、视觉传播力强,也最适合作为服务端生成的首个试点。
|
||||||
3. **现在开始建设 Web 底座,不启动完整 Web 平台。** 新增高价值题型优先接入服务端生成;既有小程序题型不做一次性迁移。
|
3. **现在开始建设 Web 底座,不启动完整 Web 平台。** 新增高价值题型优先接入服务端生成;既有小程序题型不做一次性迁移。
|
||||||
|
4. **现有业务后端迁移到自有云服务器。** 采用统一 API、MySQL 和七牛文件存储;CloudBase 只在切换期承担旧版兼容。业务后端迁移与存量 Canvas 服务端重写分别实施。
|
||||||
|
|
||||||
## 文档
|
## 文档
|
||||||
|
|
||||||
@@ -19,11 +20,13 @@ v3.5 的目标不是继续堆叠题型,而是把「生成一张练习纸」升
|
|||||||
| [产品路线图](./产品路线图.md) | 用户问题、功能优先级、阶段目标与验收指标 |
|
| [产品路线图](./产品路线图.md) | 用户问题、功能优先级、阶段目标与验收指标 |
|
||||||
| [待办与里程碑](./待办与里程碑.md) | 可执行的产品、设计、研发待办与依赖关系 |
|
| [待办与里程碑](./待办与里程碑.md) | 可执行的产品、设计、研发待办与依赖关系 |
|
||||||
| [Web 与服务端生成策略](./Web与服务端生成策略.md) | Web 试点范围、服务端渲染边界及技术演进决策 |
|
| [Web 与服务端生成策略](./Web与服务端生成策略.md) | Web 试点范围、服务端渲染边界及技术演进决策 |
|
||||||
|
| [云服务端迁移方案(doodle-store)](../../../doodle-store/docs/云服务端迁移方案.md) | 在新项目统一维护:现有功能/API 映射、数据与文件迁移、实施待办、切换回滚、验收及 AI 辅助工时预算 |
|
||||||
|
| [埋点事件字典](./埋点事件字典.md) | 现有事件和成功保存口径;后端迁移继续沿用 |
|
||||||
|
|
||||||
## 不在 v3.5 范围内
|
## 不在 v3.5 范围内
|
||||||
|
|
||||||
- 完整的 Kiddoleap 风格 Web 资源平台、全量 SEO 内容站。
|
- 完整的 Kiddoleap 风格 Web 资源平台、全量 SEO 内容站。
|
||||||
- 一次性迁移所有云数据库、云函数和既有 Canvas 题型到独立服务器。
|
- 在业务后端迁移中同时重写全部既有 Canvas 题型。
|
||||||
- 社区晒图、公开儿童信息、复杂学习报告。
|
- 社区晒图、公开儿童信息、复杂学习报告。
|
||||||
- 会员支付、PDF 多页合并等商业化闭环;仅预留数据与服务端能力。
|
- 会员支付、PDF 多页合并等商业化闭环;仅预留数据与服务端能力。
|
||||||
|
|
||||||
|
|||||||
+18
-19
@@ -1,8 +1,10 @@
|
|||||||
# Doodle Mini v3.5 Web 与服务端生成策略
|
# Doodle Mini v3.5 Web 与服务端生成策略
|
||||||
|
|
||||||
> 状态:架构决策草案
|
> 状态:迁移到自有云服务器的方向已确认;Web 与绘图试点待实施
|
||||||
>
|
>
|
||||||
> 更新日期:2026-09-24
|
> 更新日期:2026-10-12
|
||||||
|
|
||||||
|
> 现有业务后端的迁移范围、切换顺序和验收以[云服务端迁移方案(doodle-store)](../../../doodle-store/docs/云服务端迁移方案.md)为准。本文说明迁移后 Web 与服务端生成的演进,不再以继续保留 CloudBase 业务后端为目标。
|
||||||
|
|
||||||
## 1. 决策结论
|
## 1. 决策结论
|
||||||
|
|
||||||
@@ -10,9 +12,7 @@
|
|||||||
|
|
||||||
Web 是长期的搜索获客、内容沉淀、电脑打印和教师/机构扩展渠道;小程序仍是当前的微信内打开、分享、每日推荐和家庭留存主产品。
|
Web 是长期的搜索获客、内容沉淀、电脑打印和教师/机构扩展渠道;小程序仍是当前的微信内打开、分享、每日推荐和家庭留存主产品。
|
||||||
|
|
||||||
因此,v3.5 采用:
|
v3.5 将现有业务后端迁移作为独立实施任务,小程序体验和小规模 Web/服务端生成按里程碑推进,不预设固定投入比例。
|
||||||
|
|
||||||
> **70% 投入小程序核心体验,30% 投入 Web 与服务端生成试点。**
|
|
||||||
|
|
||||||
不等待小程序“达到很大用户量”才准备 Web,因为搜索内容、服务端渲染和内容生产机制都需要时间积累;但也不在内容库、内容生产和核心留存未验证时建设完整 Web 平台。
|
不等待小程序“达到很大用户量”才准备 Web,因为搜索内容、服务端渲染和内容生产机制都需要时间积累;但也不在内容库、内容生产和核心留存未验证时建设完整 Web 平台。
|
||||||
|
|
||||||
@@ -102,28 +102,27 @@ favoriteService.toggle(worksheetId)
|
|||||||
historyService.list()
|
historyService.list()
|
||||||
```
|
```
|
||||||
|
|
||||||
小程序适配器可以暂时使用云函数,Web 使用 HTTP;上层页面不感知具体实现。
|
新版小程序与后续 Web 统一使用 HTTP 业务 API;旧版云函数只在过渡期转发同一业务实现。上层页面不感知数据库与文件存储实现。
|
||||||
|
|
||||||
## 5. 后端演进路径
|
## 5. 后端演进路径
|
||||||
|
|
||||||
### 阶段 A:现在至创意涂色 MVP
|
### 阶段 A:现有业务后端迁移
|
||||||
|
|
||||||
- 保留 CloudBase 数据库、存储和既有云函数。
|
- 按专项方案迁移现有云函数、数据库、文件和定时任务到自有腾讯云服务器 + MySQL + 七牛。
|
||||||
- 新建可通过 HTTP 调用的 Render API;可先使用 CloudBase 云托管容器,无需立即维护独立云服务器。
|
- 新版小程序经业务服务层调用统一 HTTP API;短期兼容桥照顾旧正式版本,只有一个业务写入源。
|
||||||
- 私有素材存放在不可公开枚举的对象存储路径;最终文件通过短期 URL 交付。
|
- 先保持现有生成与保存行为,再验收数据关联、文件、计数和切换;不同时重写所有绘图代码。
|
||||||
- 只迁移创意涂色纸,验证延迟、成本和保护边界。
|
|
||||||
|
|
||||||
### 阶段 B:Web 小规模试点
|
### 阶段 B:服务端生成样板
|
||||||
|
|
||||||
- Web 调用统一 HTTP API;小程序继续调用业务服务层。
|
- 在同一后端增加受控 Render API,以创意涂色或一个简单现有题型验证生成、字体、延迟和文件交付。
|
||||||
- 新题型优先服务端生成;旧题型按商业价值与迁移成本逐步处理。
|
- 原始素材和模板采用私有存储;最终文件通过受控地址交付,不依赖隐藏对象路径保护素材。
|
||||||
- 增加异步渲染任务、缓存、批量导出和基础监控。
|
- 根据实际任务量增加缓存/异步处理,不预先搭建独立队列平台。
|
||||||
|
|
||||||
### 阶段 C:扩张决策
|
### 阶段 C:Web 小规模试点与扩张
|
||||||
|
|
||||||
当 Web 需要 SEO、会员支付、复杂搜索、教师版、批量 PDF、AI/异步任务,或 CloudBase 成本与能力成为瓶颈时,再迁核心 API 到 NestJS + MySQL/对象存储。
|
- Web 复用统一 API 和内容源,内容页面使用适当的预生成或服务端渲染,生成规则留在服务器。
|
||||||
|
- 只建设下节规定的 MVP;旧题型按价值与实际适配成本逐步接入,批量 PDF/会员另行评估。
|
||||||
迁移的是业务 API 和数据,不是让前端重新实现一套生成逻辑。
|
- 私有生成进程可按资源需求拆分,但不另建用户、收藏、内容数据库或第二套业务规则。
|
||||||
|
|
||||||
## 6. 安全、成本与体验基线
|
## 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. 排期原则
|
## 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 个迭代)
|
## 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 + 自有云服务器)
|
# Doodle Mini — 后端部署方案(NestJS + 自有云服务器)
|
||||||
|
|
||||||
|
> **2026-10-12 决策更新**:已确认迁移到自有腾讯云服务器。当前实施请使用 [云服务端迁移方案(doodle-store)](../../doodle-store/docs/云服务端迁移方案.md)。本文保留为历史技术预案,其中版本、数据模型与本地文件方案不可直接当作当前实施规格;“零增量成本/零成本 CDN”不作为成本事实。
|
||||||
|
|
||||||
> 版本:v1.0
|
> 版本:v1.0
|
||||||
> 最后更新:2026-03-27
|
> 最后更新:2026-03-27
|
||||||
> **定位**:**远期 / 阶段到期后的迁移方案**(自有服务器 + NestJS)。
|
> **定位**:**远期 / 阶段到期后的迁移方案**(自有服务器 + NestJS)。
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Doodle Mini — 小程序云开发方案
|
# Doodle Mini — 小程序云开发方案
|
||||||
|
|
||||||
|
> **2026-10-12 决策更新**:本文用于了解现网 CloudBase 和历史数据设计。已确认迁移到自有云服务器,实施以 [云服务端迁移方案(doodle-store)](../../doodle-store/docs/云服务端迁移方案.md) 为准。迁移集合和字段以实际代码及线上盘点为准,不直接按本文的设计集合导入。
|
||||||
|
|
||||||
> 版本:v1.1
|
> 版本:v1.1
|
||||||
> 最后更新:2026-03-27
|
> 最后更新:2026-03-27
|
||||||
> **适用范围**:与 [技术架构设计文档](./技术架构设计文档.md) 配套;**当前阶段**采用本文档所述微信云开发环境实现后端能力。
|
> **适用范围**:与 [技术架构设计文档](./技术架构设计文档.md) 配套;**当前阶段**采用本文档所述微信云开发环境实现后端能力。
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
> 最后更新:2026-03-27
|
> 最后更新:2026-03-27
|
||||||
> 配套文档:[现有功能清单](./现有功能清单.md) | [产品设计文档](./产品设计文档.md) | [小程序云开发方案](./小程序云开发方案.md) | [后端部署方案](./后端部署方案.md) | [PC-Web端技术预案](./PC-Web端技术预案.md)
|
> 配套文档:[现有功能清单](./现有功能清单.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 绘制与本次业务后端迁移分别实施。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
export type ApiEnvironment = 'local' | 'online';
|
||||||
|
type MiniProgramVersion = 'develop' | 'trial' | 'release';
|
||||||
|
|
||||||
|
/** 统一接口入口;本地真机调试时将 localBaseUrl 的 IP 改为电脑局域网 IP。 */
|
||||||
|
export const apiConfig = {
|
||||||
|
developmentEnvironment: 'local' as ApiEnvironment,
|
||||||
|
localBaseUrl: 'http://127.0.0.1:8800/api/v1',
|
||||||
|
// 线上域名尚未确定:填写完整 HTTPS 地址,例如 https://你的域名/api/v1。
|
||||||
|
onlineBaseUrl: '',
|
||||||
|
timeout: 10000,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** develop 可切换本地/线上;体验版、正式版和未知运行环境均只允许线上入口。 */
|
||||||
|
export function resolveApiConfig(version: MiniProgramVersion | string) {
|
||||||
|
const environment: ApiEnvironment =
|
||||||
|
version === 'develop' ? apiConfig.developmentEnvironment : 'online';
|
||||||
|
const baseUrl = (
|
||||||
|
environment === 'local'
|
||||||
|
? apiConfig.localBaseUrl
|
||||||
|
: apiConfig.onlineBaseUrl
|
||||||
|
)
|
||||||
|
.trim()
|
||||||
|
.replace(/\/+$/, '');
|
||||||
|
if (!baseUrl) throw new Error('请先配置线上接口地址');
|
||||||
|
if (
|
||||||
|
!/^https?:\/\/[^/?#\s]+\/api\/v1$/.test(baseUrl) ||
|
||||||
|
(environment === 'online' && !baseUrl.startsWith('https://'))
|
||||||
|
) {
|
||||||
|
throw new Error('接口地址配置不正确');
|
||||||
|
}
|
||||||
|
return { environment, baseUrl, timeout: apiConfig.timeout };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 请求时读取微信包版本,避免依赖 App 启动顺序,也不接受本地缓存覆盖正式版环境。 */
|
||||||
|
export function getApiConfig() {
|
||||||
|
return resolveApiConfig(wx.getAccountInfoSync().miniProgram.envVersion);
|
||||||
|
}
|
||||||
@@ -288,10 +288,12 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(135deg,
|
background: linear-gradient(
|
||||||
transparent 25%,
|
135deg,
|
||||||
rgba(255, 255, 255, 0.7) 50%,
|
transparent 25%,
|
||||||
transparent 75%);
|
rgba(255, 255, 255, 0.7) 50%,
|
||||||
|
transparent 75%
|
||||||
|
);
|
||||||
animation: skeleton-shimmer 1.2s linear infinite;
|
animation: skeleton-shimmer 1.2s linear infinite;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
@@ -316,4 +318,18 @@
|
|||||||
width: 80%;
|
width: 80%;
|
||||||
height: 24rpx;
|
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,150 @@
|
|||||||
|
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: 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 { appSharePageMethods } from '../../base/pageMixin';
|
||||||
import { CATEGORY_LIST_WITH_ALL } from '../../core/data/categories';
|
import { takePendingCategoryTabId } from '../../utils/index';
|
||||||
import { parseMiniProgramUrl, takePendingCategoryTabId } from '../../utils/index';
|
|
||||||
import tracker from '../../utils/tracker';
|
import tracker from '../../utils/tracker';
|
||||||
import { CATEGORY_DATA, type CategoryItem } from './category.data';
|
import { ApiRequestError } from '../../services/api-request';
|
||||||
|
import {
|
||||||
type CategoryTab = {
|
getWorksheet,
|
||||||
id: string;
|
listAllWorksheets,
|
||||||
name: string;
|
listCategories,
|
||||||
icon: string;
|
} from '../../services/content-api';
|
||||||
};
|
import {
|
||||||
|
BASE_TABS,
|
||||||
type CategoryDataset = {
|
buildCategoryDataset,
|
||||||
searchPlaceholder: string;
|
getCategoryItems,
|
||||||
categories: Array<{
|
type CategoryDataset,
|
||||||
id: string;
|
type CategoryItem,
|
||||||
name: string;
|
} from './category.model';
|
||||||
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 }),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
const TAB_BAR_PATHS = new Set([
|
const TAB_BAR_PATHS = new Set([
|
||||||
'/pages/home/home',
|
'/pages/home/home',
|
||||||
@@ -47,182 +22,42 @@ const TAB_BAR_PATHS = new Set([
|
|||||||
'/pages/favorites/favorites',
|
'/pages/favorites/favorites',
|
||||||
'/pages/profile/profile',
|
'/pages/profile/profile',
|
||||||
]);
|
]);
|
||||||
|
const SKELETON_ITEMS: CategoryItem[] = Array.from(
|
||||||
// Page-level mutable state (shared across methods)
|
{ length: 6 },
|
||||||
let _categoryData: CategoryDataset | null = null;
|
(_, index) => ({
|
||||||
|
id: `skeleton-${index}`,
|
||||||
// --- 以下仅为本地调试:拉取分类数据后查看各 worksheet 的 downloads / likes,非业务逻辑 ---
|
title: '\u00a0',
|
||||||
|
subtitle: '\u00a0',
|
||||||
function debugLogWorksheetStatsAfterCategoryLoad(
|
icon: '',
|
||||||
source:
|
previewImg: '',
|
||||||
| { kind: 'cloud'; rows: Record<string, any>[] }
|
ageBand: '',
|
||||||
| { kind: 'static'; data: CategoryDataset },
|
difficultyLabel: '',
|
||||||
) {
|
path: '',
|
||||||
let map: Record<string, { downloads: number; likes: number }> = {};
|
available: false,
|
||||||
if (source.kind === 'cloud') {
|
likes: '',
|
||||||
for (const raw of source.rows) {
|
downloads: '',
|
||||||
const id = String(raw._id || '').trim();
|
date: '',
|
||||||
if (!id) continue;
|
updatedAt: '',
|
||||||
map[id] = {
|
skeleton: true,
|
||||||
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,
|
|
||||||
}));
|
|
||||||
|
|
||||||
Page({
|
Page({
|
||||||
data: {
|
data: {
|
||||||
searchPlaceholder: '搜索练习纸...',
|
searchPlaceholder: '搜索练习纸...',
|
||||||
searchKeyword: '',
|
searchKeyword: '',
|
||||||
sidebarTabs: SIDEBAR_TABS,
|
sidebarTabs: BASE_TABS,
|
||||||
activeCategoryId: 'all',
|
activeCategoryId: 'all',
|
||||||
displayItems: SKELETON_ITEMS as any[],
|
displayItems: SKELETON_ITEMS,
|
||||||
scrollIntoViewId: '',
|
scrollIntoViewId: '',
|
||||||
loading: true,
|
loading: true,
|
||||||
|
errorMessage: '',
|
||||||
},
|
},
|
||||||
|
|
||||||
_defaultCategoryId: 'all',
|
_defaultCategoryId: 'all',
|
||||||
|
_categoryData: null as CategoryDataset | null,
|
||||||
|
_loadVersion: 0,
|
||||||
|
_opening: false,
|
||||||
|
|
||||||
onLoad(options: Record<string, string>) {
|
onLoad(options: Record<string, string>) {
|
||||||
tracker.reportPageView('pages/category/category');
|
tracker.reportPageView('pages/category/category');
|
||||||
@@ -233,58 +68,92 @@ Page({
|
|||||||
},
|
},
|
||||||
|
|
||||||
onShow() {
|
onShow() {
|
||||||
_categoryData = null;
|
|
||||||
this.setData({ displayItems: SKELETON_ITEMS, loading: true });
|
|
||||||
void this.loadWorksheets(takePendingCategoryTabId() ?? undefined);
|
void this.loadWorksheets(takePendingCategoryTabId() ?? undefined);
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadWorksheets(overrideCategoryId?: string | null) {
|
onHide() {
|
||||||
try {
|
this._loadVersion++;
|
||||||
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 || '查询失败');
|
|
||||||
|
|
||||||
const rows = result.data || [];
|
onUnload() {
|
||||||
console.log('rows', rows);
|
this._loadVersion++;
|
||||||
_categoryData = buildCategoryDataFromCloud(rows);
|
},
|
||||||
debugLogWorksheetStatsAfterCategoryLoad({ kind: 'cloud', rows });
|
|
||||||
} catch {
|
/** 每次进入重新读取公开目录与完整分页;失败清空内容,不回退静态数据或旧云服务。 */
|
||||||
_categoryData = CATEGORY_DATA;
|
async loadWorksheets(overrideCategoryId?: string) {
|
||||||
debugLogWorksheetStatsAfterCategoryLoad({
|
const version = ++this._loadVersion;
|
||||||
kind: 'static',
|
this._categoryData = null;
|
||||||
data: CATEGORY_DATA,
|
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 =
|
onRetry() {
|
||||||
overrideCategoryId != null &&
|
void this.loadWorksheets();
|
||||||
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,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
onTapCategory(e: WechatMiniprogram.TouchEvent) {
|
onTapCategory(e: WechatMiniprogram.TouchEvent) {
|
||||||
const id = e.currentTarget.dataset.id as string;
|
const id = e.currentTarget.dataset.id as string;
|
||||||
if (!id || id === this.data.activeCategoryId) return;
|
if (
|
||||||
|
!this._categoryData ||
|
||||||
const items = getItemsByCategory(id);
|
this.data.loading ||
|
||||||
const filtered = filterByKeyword(items, this.data.searchKeyword);
|
!id ||
|
||||||
|
id === this.data.activeCategoryId ||
|
||||||
|
!this._categoryData.sidebarTabs.some((tab) => tab.id === id)
|
||||||
|
)
|
||||||
|
return;
|
||||||
this.setData({
|
this.setData({
|
||||||
activeCategoryId: id,
|
activeCategoryId: id,
|
||||||
displayItems: filtered,
|
displayItems: getCategoryItems(
|
||||||
|
this._categoryData,
|
||||||
|
id,
|
||||||
|
this.data.searchKeyword,
|
||||||
|
),
|
||||||
scrollIntoViewId: '',
|
scrollIntoViewId: '',
|
||||||
});
|
});
|
||||||
tracker.report('category_select', 'pages/category/category', {
|
tracker.report('category_select', 'pages/category/category', {
|
||||||
@@ -294,49 +163,69 @@ Page({
|
|||||||
|
|
||||||
onSearchInput(e: WechatMiniprogram.Input) {
|
onSearchInput(e: WechatMiniprogram.Input) {
|
||||||
const keyword = (e.detail.value ?? '').trim();
|
const keyword = (e.detail.value ?? '').trim();
|
||||||
const items = getItemsByCategory(this.data.activeCategoryId);
|
|
||||||
const filtered = filterByKeyword(items, keyword);
|
|
||||||
|
|
||||||
this.setData({
|
this.setData({
|
||||||
searchKeyword: keyword,
|
searchKeyword: keyword,
|
||||||
displayItems: filtered,
|
displayItems: getCategoryItems(
|
||||||
|
this._categoryData,
|
||||||
|
this.data.activeCategoryId,
|
||||||
|
keyword,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
onClearSearch() {
|
onClearSearch() {
|
||||||
const items = getItemsByCategory(this.data.activeCategoryId);
|
|
||||||
this.setData({
|
this.setData({
|
||||||
searchKeyword: '',
|
searchKeyword: '',
|
||||||
displayItems: items,
|
displayItems: getCategoryItems(
|
||||||
|
this._categoryData,
|
||||||
|
this.data.activeCategoryId,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
onTapItem(e: WechatMiniprogram.TouchEvent) {
|
/** 点击时重新检查详情,已下架或网络不可用时不进入内置生成页。 */
|
||||||
const path = e.currentTarget.dataset.path as string;
|
async onTapItem(e: WechatMiniprogram.TouchEvent) {
|
||||||
const title = e.currentTarget.dataset.title as string;
|
const id = e.currentTarget.dataset.id as string;
|
||||||
const available = e.currentTarget.dataset.available;
|
if (
|
||||||
|
this._opening ||
|
||||||
if (!available || !path) {
|
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({
|
wx.showToast({
|
||||||
title: title ? `${title} 即将上线` : '即将上线',
|
title:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: '暂时无法打开,请稍后重试',
|
||||||
icon: 'none',
|
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,
|
...appSharePageMethods,
|
||||||
|
|||||||
@@ -55,9 +55,7 @@
|
|||||||
wx:for="{{displayItems}}"
|
wx:for="{{displayItems}}"
|
||||||
wx:key="id"
|
wx:key="id"
|
||||||
class="cat-card {{item.skeleton ? 'cat-card--skeleton' : ''}}"
|
class="cat-card {{item.skeleton ? 'cat-card--skeleton' : ''}}"
|
||||||
data-path="{{item.path}}"
|
data-id="{{item.id}}"
|
||||||
data-title="{{item.title}}"
|
|
||||||
data-available="{{item.available}}"
|
|
||||||
bindtap="{{item.skeleton ? '' : 'onTapItem'}}">
|
bindtap="{{item.skeleton ? '' : 'onTapItem'}}">
|
||||||
<view class="cat-card__thumb">
|
<view class="cat-card__thumb">
|
||||||
<image
|
<image
|
||||||
@@ -117,7 +115,17 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<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">
|
class="cat-empty">
|
||||||
<text class="cat-empty__icon">🔍</text>
|
<text class="cat-empty__icon">🔍</text>
|
||||||
<text class="cat-empty__text">没有找到匹配的内容</text>
|
<text class="cat-empty__text">没有找到匹配的内容</text>
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { getApiConfig } from '../config/api';
|
||||||
|
|
||||||
|
interface ApiEnvelope<T> {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: T;
|
||||||
|
requestId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保留有限的状态用于页面反馈,不把服务器原始响应或凭证输出到界面。 */
|
||||||
|
export class ApiRequestError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly statusCode: number = 0,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiRequestError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新后端统一请求层:校验 HTTP 与业务状态,旧云函数/CDN 请求保持独立。 */
|
||||||
|
export async function apiRequest<T>(
|
||||||
|
path: string,
|
||||||
|
options: {
|
||||||
|
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
header?: Record<string, string>;
|
||||||
|
} = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const config = getApiConfig();
|
||||||
|
if (!path.startsWith('/') || path.startsWith('//'))
|
||||||
|
throw new Error('接口路径不正确');
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
wx.request<ApiEnvelope<T>>({
|
||||||
|
url: `${config.baseUrl}${path}`,
|
||||||
|
method: options.method || 'GET',
|
||||||
|
data: options.data,
|
||||||
|
header: { 'Content-Type': 'application/json', ...options.header },
|
||||||
|
timeout: config.timeout,
|
||||||
|
success(res) {
|
||||||
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||||
|
reject(
|
||||||
|
new ApiRequestError(
|
||||||
|
res.statusCode === 404
|
||||||
|
? '内容暂不可用'
|
||||||
|
: '接口请求失败,请稍后重试',
|
||||||
|
res.statusCode,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!res.data ||
|
||||||
|
res.data.code !== 0 ||
|
||||||
|
!Object.prototype.hasOwnProperty.call(res.data, 'data')
|
||||||
|
) {
|
||||||
|
reject(
|
||||||
|
new ApiRequestError(
|
||||||
|
'接口返回异常,请稍后重试',
|
||||||
|
res.statusCode,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(res.data.data);
|
||||||
|
},
|
||||||
|
fail() {
|
||||||
|
reject(
|
||||||
|
new ApiRequestError('连接失败,请确认后端已启动及网络配置'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { apiRequest } from './api-request';
|
||||||
|
|
||||||
|
/** 小程序使用的公开接口字段,不复用旧云集合的 _id 和管理配置。 */
|
||||||
|
export interface ApiCategory {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
parentId: string | null;
|
||||||
|
status: 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiWorksheet {
|
||||||
|
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');
|
||||||
|
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', {
|
||||||
|
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)}`,
|
||||||
|
);
|
||||||
|
validateWorksheet(row);
|
||||||
|
if (row.id !== id) throw new Error('题型数据异常,请稍后重试');
|
||||||
|
return row;
|
||||||
|
}
|
||||||
+4
-2
@@ -7,7 +7,8 @@
|
|||||||
"worksheet:patch:ages": "tsx scripts/worksheet-sync-cloud-payload.ts --ages",
|
"worksheet:patch:ages": "tsx scripts/worksheet-sync-cloud-payload.ts --ages",
|
||||||
"worksheet:patch:tags": "tsx scripts/worksheet-sync-cloud-payload.ts --tags",
|
"worksheet:patch:tags": "tsx scripts/worksheet-sync-cloud-payload.ts --tags",
|
||||||
"worksheet:patch:all": "tsx scripts/worksheet-sync-cloud-payload.ts --all",
|
"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"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
"globals": "^15.14.0",
|
"globals": "^15.14.0",
|
||||||
"miniprogram-api-typings": "^4.0.5",
|
"miniprogram-api-typings": "^4.0.5",
|
||||||
"prettier": "3.4.2",
|
"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",
|
"projectname": "doodle-mini",
|
||||||
"setting": {
|
"setting": {
|
||||||
"compileHotReLoad": true,
|
"compileHotReLoad": true,
|
||||||
"urlCheck": true,
|
"urlCheck": false,
|
||||||
"coverView": false,
|
"coverView": false,
|
||||||
"lazyloadPlaceholderEnable": false,
|
"lazyloadPlaceholderEnable": false,
|
||||||
"skylineRenderEnable": true,
|
"skylineRenderEnable": true,
|
||||||
|
|||||||
@@ -0,0 +1,368 @@
|
|||||||
|
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.onlineBaseUrl = '';
|
||||||
|
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('环境配置:开发版默认本地,正式/体验/未知环境不回退本地,线上要求 HTTPS', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveApiConfig('develop').baseUrl,
|
||||||
|
'http://127.0.0.1:8800/api/v1',
|
||||||
|
);
|
||||||
|
for (const version of ['release', 'trial', 'unknown'])
|
||||||
|
assert.throws(() => resolveApiConfig(version), /线上/);
|
||||||
|
apiConfig.onlineBaseUrl = 'https://api.example.test/api/v1/';
|
||||||
|
for (const version of ['release', 'trial'])
|
||||||
|
assert.equal(
|
||||||
|
resolveApiConfig(version).baseUrl,
|
||||||
|
'https://api.example.test/api/v1',
|
||||||
|
);
|
||||||
|
apiConfig.developmentEnvironment = 'online';
|
||||||
|
assert.equal(resolveApiConfig('develop').environment, 'online');
|
||||||
|
apiConfig.onlineBaseUrl = 'http://api.example.test/api/v1';
|
||||||
|
assert.throws(() => resolveApiConfig('release'), /配置/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('请求层:HTTP、业务错误、畸形响应、网络错误均拒绝,不当作空成功', async () => {
|
||||||
|
useRows([]);
|
||||||
|
assert.deepEqual(await apiRequest('/categories'), 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'), ApiRequestError);
|
||||||
|
}
|
||||||
|
handler = () => {
|
||||||
|
throw new Error('offline');
|
||||||
|
};
|
||||||
|
await assert.rejects(apiRequest('/categories'), /连接失败/);
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user