feat: 3.5版本规划
This commit is contained in:
@@ -31,7 +31,7 @@ function toHomeDisplayItem(ws) {
|
|||||||
ageBand: ageBand(ws.ageMin || 0, ws.ageMax || 0),
|
ageBand: ageBand(ws.ageMin || 0, ws.ageMax || 0),
|
||||||
difficulty: DIFFICULTY_LABELS[ws.difficulty] || '基础',
|
difficulty: DIFFICULTY_LABELS[ws.difficulty] || '基础',
|
||||||
icon: '',
|
icon: '',
|
||||||
img: ws.previewImg || '',
|
previewImg: ws.previewImg || '',
|
||||||
badge: ws.isNew ? 'NEW' : ws.isHot ? 'HOT' : '',
|
badge: ws.isNew ? 'NEW' : ws.isHot ? 'HOT' : '',
|
||||||
path: ws.path || '',
|
path: ws.path || '',
|
||||||
available: true,
|
available: true,
|
||||||
|
|||||||
@@ -36,13 +36,26 @@ exports.main = async (event) => {
|
|||||||
|
|
||||||
async function handleAdd(userId, event) {
|
async function handleAdd(userId, event) {
|
||||||
const { worksheetId } = event;
|
const { worksheetId } = event;
|
||||||
|
const exportId = String(event.exportId || '').trim();
|
||||||
if (!worksheetId) return { code: -1, message: 'worksheetId 不能为空' };
|
if (!worksheetId) return { code: -1, message: 'worksheetId 不能为空' };
|
||||||
|
|
||||||
const collection = db.collection('download_logs');
|
const collection = db.collection('download_logs');
|
||||||
|
|
||||||
|
// 同一次成功导出重试时只能计一次,避免网络重试抬高下载量。
|
||||||
|
if (exportId) {
|
||||||
|
const { data: existing } = await collection
|
||||||
|
.where({ userId, exportId })
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
if (existing.length > 0) {
|
||||||
|
return { code: 0, data: { ...existing[0], duplicate: true } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const record = {
|
const record = {
|
||||||
userId,
|
userId,
|
||||||
worksheetId,
|
worksheetId,
|
||||||
|
...(exportId && { exportId }),
|
||||||
createdAt: db.serverDate(),
|
createdAt: db.serverDate(),
|
||||||
};
|
};
|
||||||
const { _id } = await collection.add({ data: record });
|
const { _id } = await collection.add({ data: record });
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Doodle Mini v3.5 规划索引
|
||||||
|
|
||||||
|
> 状态:待评审
|
||||||
|
>
|
||||||
|
> 更新日期:2026-09-24
|
||||||
|
|
||||||
|
v3.5 的目标不是继续堆叠题型,而是把「生成一张练习纸」升级为家长可持续使用的家庭打印学习服务,并为未来 Web 内容平台建立服务端生成能力。
|
||||||
|
|
||||||
|
## 本阶段的三项决策
|
||||||
|
|
||||||
|
1. **小程序仍是当前主产品。** 优先改善家长首次找到内容、完成打印、再次回来使用的体验。
|
||||||
|
2. **创意涂色纸是下一项内容型 MVP。** 它覆盖低龄儿童、视觉传播力强,也最适合作为服务端生成的首个试点。
|
||||||
|
3. **现在开始建设 Web 底座,不启动完整 Web 平台。** 新增高价值题型优先接入服务端生成;既有小程序题型不做一次性迁移。
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
| 文档 | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| [产品路线图](./产品路线图.md) | 用户问题、功能优先级、阶段目标与验收指标 |
|
||||||
|
| [待办与里程碑](./待办与里程碑.md) | 可执行的产品、设计、研发待办与依赖关系 |
|
||||||
|
| [Web 与服务端生成策略](./Web与服务端生成策略.md) | Web 试点范围、服务端渲染边界及技术演进决策 |
|
||||||
|
|
||||||
|
## 不在 v3.5 范围内
|
||||||
|
|
||||||
|
- 完整的 Kiddoleap 风格 Web 资源平台、全量 SEO 内容站。
|
||||||
|
- 一次性迁移所有云数据库、云函数和既有 Canvas 题型到独立服务器。
|
||||||
|
- 社区晒图、公开儿童信息、复杂学习报告。
|
||||||
|
- 会员支付、PDF 多页合并等商业化闭环;仅预留数据与服务端能力。
|
||||||
|
|
||||||
|
这些项目需要在 v3.5 的内容需求、留存和服务端生成成本被验证后再启动。
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# Doodle Mini v3.5 Web 与服务端生成策略
|
||||||
|
|
||||||
|
> 状态:架构决策草案
|
||||||
|
>
|
||||||
|
> 更新日期:2026-09-24
|
||||||
|
|
||||||
|
## 1. 决策结论
|
||||||
|
|
||||||
|
### 1.1 Web 的定位
|
||||||
|
|
||||||
|
Web 是长期的搜索获客、内容沉淀、电脑打印和教师/机构扩展渠道;小程序仍是当前的微信内打开、分享、每日推荐和家庭留存主产品。
|
||||||
|
|
||||||
|
因此,v3.5 采用:
|
||||||
|
|
||||||
|
> **70% 投入小程序核心体验,30% 投入 Web 与服务端生成试点。**
|
||||||
|
|
||||||
|
不等待小程序“达到很大用户量”才准备 Web,因为搜索内容、服务端渲染和内容生产机制都需要时间积累;但也不在内容库、内容生产和核心留存未验证时建设完整 Web 平台。
|
||||||
|
|
||||||
|
### 1.2 生成位置
|
||||||
|
|
||||||
|
未来 Web 的高价值题型采用**服务端生成与渲染优先**。浏览器负责选择参数、展示预览、发起下载和账户交互;不得承载完整题目生成规则、排版参数与私有素材。
|
||||||
|
|
||||||
|
小程序既有题型暂可保留客户端 Canvas 生成,以避免高风险的一次性重写。创意涂色纸及后续主题包、批量 PDF 等新能力必须按服务端模式实现。
|
||||||
|
|
||||||
|
## 2. 为什么不把核心生成放在 Web 客户端
|
||||||
|
|
||||||
|
Web 客户端代码、网络请求和静态资源可被用户查看、保存和分析。即使经过压缩或混淆,也只能提高复制成本,无法提供真正的保护。
|
||||||
|
|
||||||
|
服务端模式保护的是:
|
||||||
|
|
||||||
|
- 题目随机规则、难度控制、答案逻辑。
|
||||||
|
- 模板坐标、排版参数和批量内容生产能力。
|
||||||
|
- 原始 SVG/高清线稿、字体、商用素材以及素材关联关系。
|
||||||
|
- 未发布题型、主题包和会员规则。
|
||||||
|
|
||||||
|
服务端无法阻止他人观察最终 PNG/PDF 并模仿视觉效果;产品壁垒仍来自稳定的内容供给、素材版权、生成质量、运营数据和用户体验。
|
||||||
|
|
||||||
|
## 3. 目标架构
|
||||||
|
|
||||||
|
```text
|
||||||
|
小程序 / Web
|
||||||
|
└─ 仅提交受控参数:worksheetId、年龄、难度、主题、孩子姓名
|
||||||
|
↓
|
||||||
|
统一业务 API
|
||||||
|
↓
|
||||||
|
Render API(鉴权、配额、限流、参数校验)
|
||||||
|
↓
|
||||||
|
私有题型配置 + 私有素材 + 生成器 + 渲染器
|
||||||
|
↓
|
||||||
|
生成 PNG / PDF,写入对象存储
|
||||||
|
↓
|
||||||
|
返回短期签名预览/下载 URL 或异步任务状态
|
||||||
|
```
|
||||||
|
|
||||||
|
示例请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"worksheetId": "coloring-animal-01",
|
||||||
|
"variant": "easy",
|
||||||
|
"childProfileId": "child_123",
|
||||||
|
"output": "png"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
服务端根据 `worksheetId` 查询内部配置,生成随机种子和题目内容;客户端不能传素材 URL、任意渲染器名称、自由模板参数或数据库字段。
|
||||||
|
|
||||||
|
## 4. 数据与接口边界
|
||||||
|
|
||||||
|
### 4.1 平台无关的 worksheet 模型
|
||||||
|
|
||||||
|
当前内容数据中存在小程序专用 `path`,例如 `/mathPages/mathDraw/mathDraw?id=number-find`。v3.5 起应建立平台无关的核心字段:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type Worksheet = {
|
||||||
|
id: string;
|
||||||
|
category: string;
|
||||||
|
title: string;
|
||||||
|
ageMin: number;
|
||||||
|
ageMax: number;
|
||||||
|
difficulty: 1 | 2 | 3 | 4;
|
||||||
|
tags: string[];
|
||||||
|
rendererKey: string; // 仅服务端可解释
|
||||||
|
generatorKey: string; // 仅服务端可解释
|
||||||
|
assetRefs: string[]; // 私有资源引用
|
||||||
|
status: 'draft' | 'active' | 'hidden';
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
小程序路由与 Web URL 是展示层映射,不能作为内容本体。旧字段可在迁移期保留为 `miniProgramPath`,但新服务端生成不依赖它。
|
||||||
|
|
||||||
|
### 4.2 业务服务接口
|
||||||
|
|
||||||
|
页面不得再散落调用 `wx.cloud.callFunction`。逐步收口为业务接口:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
worksheetService.list(filters)
|
||||||
|
worksheetService.getDetail(id)
|
||||||
|
renderService.createJob(request)
|
||||||
|
renderService.getJob(jobId)
|
||||||
|
favoriteService.toggle(worksheetId)
|
||||||
|
historyService.list()
|
||||||
|
```
|
||||||
|
|
||||||
|
小程序适配器可以暂时使用云函数,Web 使用 HTTP;上层页面不感知具体实现。
|
||||||
|
|
||||||
|
## 5. 后端演进路径
|
||||||
|
|
||||||
|
### 阶段 A:现在至创意涂色 MVP
|
||||||
|
|
||||||
|
- 保留 CloudBase 数据库、存储和既有云函数。
|
||||||
|
- 新建可通过 HTTP 调用的 Render API;可先使用 CloudBase 云托管容器,无需立即维护独立云服务器。
|
||||||
|
- 私有素材存放在不可公开枚举的对象存储路径;最终文件通过短期 URL 交付。
|
||||||
|
- 只迁移创意涂色纸,验证延迟、成本和保护边界。
|
||||||
|
|
||||||
|
### 阶段 B:Web 小规模试点
|
||||||
|
|
||||||
|
- Web 调用统一 HTTP API;小程序继续调用业务服务层。
|
||||||
|
- 新题型优先服务端生成;旧题型按商业价值与迁移成本逐步处理。
|
||||||
|
- 增加异步渲染任务、缓存、批量导出和基础监控。
|
||||||
|
|
||||||
|
### 阶段 C:扩张决策
|
||||||
|
|
||||||
|
当 Web 需要 SEO、会员支付、复杂搜索、教师版、批量 PDF、AI/异步任务,或 CloudBase 成本与能力成为瓶颈时,再迁核心 API 到 NestJS + MySQL/对象存储。
|
||||||
|
|
||||||
|
迁移的是业务 API 和数据,不是让前端重新实现一套生成逻辑。
|
||||||
|
|
||||||
|
## 6. 安全、成本与体验基线
|
||||||
|
|
||||||
|
| 维度 | v3.5 最低要求 |
|
||||||
|
| --- | --- |
|
||||||
|
| 素材保护 | 原始素材不公开;仅输出最终位图/PDF;下载链接短时有效 |
|
||||||
|
| 接口控制 | 登录/游客策略、参数白名单、按用户/IP 限流、每日生成配额 |
|
||||||
|
| 缓存 | 以题型、变体、随机种子、个性化字段计算缓存键;个性化内容谨慎缓存 |
|
||||||
|
| 任务模式 | 单张同步生成;多页学习包/PDF 使用异步任务与状态查询 |
|
||||||
|
| 可观测性 | 记录生成耗时、失败原因、缓存命中、文件大小、单位成本 |
|
||||||
|
| 体验目标 | 单张预览 P95 小于 3 秒;异步任务需有明确进度和失败重试提示 |
|
||||||
|
|
||||||
|
## 7. Web MVP 范围与升级门槛
|
||||||
|
|
||||||
|
Web MVP 仅包含:首页、10–20 个高意图主题/详情页、2–3 类生成器、预览与打印/PDF 下载。首选试点为创意涂色纸。
|
||||||
|
|
||||||
|
满足以下条件后,才扩张为完整 Kiddoleap 风格的资源平台:
|
||||||
|
|
||||||
|
- 可稳定按周上新内容,并有明确的素材/审核/发布流程。
|
||||||
|
- Web 已获得自然搜索访问、有效下载与回访,不只是小程序导流。
|
||||||
|
- 生成服务的时延、成功率和成本达到产品目标。
|
||||||
|
- 主题包或批量导出表现出明确的付费/订阅意愿。
|
||||||
|
|
||||||
|
## 8. 与既有文档的关系
|
||||||
|
|
||||||
|
本策略更新了既有架构文档中“前端为主、核心生成保持客户端执行”的长期方向:该原则仍适用于存量小程序题型的成本与离线体验,但**不适用于未来 Web 的高价值题型**。
|
||||||
|
|
||||||
|
在 v3.5 评审通过后,应同步更新《技术架构设计文档》《PC-Web端技术预案》《小程序云开发方案和数据库设计》中的阶段说明,避免文档之间出现相反结论。
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Doodle Mini v3.5 产品路线图
|
||||||
|
|
||||||
|
> 状态:待评审
|
||||||
|
>
|
||||||
|
> 更新日期:2026-09-24
|
||||||
|
|
||||||
|
## 1. 产品判断
|
||||||
|
|
||||||
|
涂鸦丫的核心用户不是“想要一道题”的孩子,而是希望快速安排孩子在家学习、又不愿每天筛选和购买练习册的家长。
|
||||||
|
|
||||||
|
当前产品已经具备动态生成 A4 练习纸、下载、收藏、分龄和题型分类能力。下一阶段应解决三件事:
|
||||||
|
|
||||||
|
1. **找得到:** 家长能按年龄、能力和主题快速找到需要的内容。
|
||||||
|
2. **用得起来:** 一次打开就能决定今天或本周打印什么,而不是在题型列表中犹豫。
|
||||||
|
3. **愿意回来:** 纸上完成的练习能形成轻量打卡、下次推荐和主题包复用。
|
||||||
|
|
||||||
|
因此,v3.5 的产品定位为:
|
||||||
|
|
||||||
|
> 为 3–8 岁家庭提供“今天就能打印、每周不必费心安排”的早教练习纸服务。
|
||||||
|
|
||||||
|
## 2. 用户问题与机会
|
||||||
|
|
||||||
|
| 用户问题 | 当前表现 | v3.5 机会 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 不知道该给孩子练什么 | 有许多题型,但缺少任务化推荐 | 今日推荐、每周学习包、按年龄目标推荐 |
|
||||||
|
| 找一张特定练习纸费时 | 搜索逻辑存在,但界面未开放;分类偏题型 | 搜索、年龄/能力/主题筛选、主题集合页 |
|
||||||
|
| 低龄儿童可用内容与视觉吸引力不足 | 数学和书写题较多,3–5 岁的自由活动较少 | 创意涂色纸、颜色指令涂色、按数字涂色 |
|
||||||
|
| 打印后没有后续关系 | 仅有下载日志和收藏 | 完成打卡、连续练习、下次推荐 |
|
||||||
|
| 家长希望资料“属于孩子” | 练习纸为通用内容 | 孩子档案、姓名/日期/今日目标页眉 |
|
||||||
|
|
||||||
|
## 3. 功能优先级
|
||||||
|
|
||||||
|
### P0:先让核心体验完整
|
||||||
|
|
||||||
|
| 功能 | 用户价值 | v3.5 最小范围 | 成功指标 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 现有链路修复 | 防止用户打开错题、首页内容缺图 | 修复“识别形状”入口、时钟调试死链、首页推荐封面字段 | 关键入口 100% 可到达正确题型 |
|
||||||
|
| 搜索与筛选 | 快速定位练习纸 | 分类页恢复关键词搜索;增加年龄、能力/分类、主题标签 | 搜索后进入题型率;搜索零结果率 |
|
||||||
|
| 今日推荐 | 降低“今天练什么”的决策成本 | 每日 3 张:主练习、趣味活动、书写/语言;允许换一张 | 首页推荐点击率、首次导出率 |
|
||||||
|
| 创意涂色纸 MVP | 覆盖低龄、强化视觉吸引和传播 | 24–30 张原创/授权线稿;动物、食物水果、交通、恐龙、节日五主题 | 涂色纸导出率、收藏率、分享率 |
|
||||||
|
|
||||||
|
### P1:把单次下载变成每周复用
|
||||||
|
|
||||||
|
| 功能 | 用户价值 | v3.5/后续范围 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 每周学习包 | 一键得到 5–7 天的打印安排 | 选择年龄、目标、每日时长;每天 2–3 张;可替换单张与收藏整包 |
|
||||||
|
| 孩子档案与个性化页眉 | 资料更有归属感、便于多孩家庭 | 孩子昵称、年龄、学习目标;导出时写入姓名、日期和练习主题 |
|
||||||
|
| 轻量完成打卡 | 建立线下练习后的回访理由 | 家长点击“已完成”;显示本周进度、连续天数和奖励贴纸 |
|
||||||
|
| 主题学习包 | 扩大内容复用与节日传播 | 如“恐龙周”“交通工具周”“入园准备包”“春节 7 日包” |
|
||||||
|
|
||||||
|
### P2:在数据验证后再做
|
||||||
|
|
||||||
|
| 功能 | 触发条件 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 家长难度反馈 | 推荐使用稳定后 | 每张练习只反馈“太简单 / 正好 / 有点难”,用于调整推荐 |
|
||||||
|
| 深度自定义 | 高意图用户占比明确 | 数字范围、题量、汉字词库、颜色主题等参数开放 |
|
||||||
|
| 教师/机构模式 | 有明确的机构试用用户 | 班级名单、姓名批量写入、随机变体、答案页 |
|
||||||
|
| 会员与 PDF 学习包 | 批量打印需求、支付意愿得到验证 | 多页 PDF、无广告、主题包、更多导出额度 |
|
||||||
|
|
||||||
|
## 4. 创意涂色纸 MVP
|
||||||
|
|
||||||
|
### 4.1 为什么优先做
|
||||||
|
|
||||||
|
- 面向 3–5 岁孩子,弥补“不会算、不愿写”的低龄使用场景。
|
||||||
|
- 首页预览和分享效果强,适合作为拉新入口。
|
||||||
|
- 与“打印后离开屏幕”的产品定位天然一致。
|
||||||
|
- 可作为 Web 服务端生成与私有素材管理的第一个试点。
|
||||||
|
|
||||||
|
### 4.2 第一版内容结构
|
||||||
|
|
||||||
|
| 类型 | 第一版内容 | 适龄 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 自由创意涂色 | 动物、食物水果、交通、恐龙、节日五主题 | 3–6 岁 |
|
||||||
|
| 颜色指令涂色 | “把苹果涂红、鱼涂蓝”等识色/听指令任务 | 3–5 岁 |
|
||||||
|
| 按数字涂色 | 1–5 对应颜色,完成后呈现图案 | 4–6 岁 |
|
||||||
|
|
||||||
|
首发应准备 24–30 张高质量线稿,每主题 4–6 张。每张分为低龄版(轮廓大、元素少、留白多)与进阶版(背景、颜色任务或规律任务)。
|
||||||
|
|
||||||
|
### 4.3 非功能约束
|
||||||
|
|
||||||
|
- 仅使用原创、已购买商用授权,或经人工审核且可商用的素材;不得使用知名卡通 IP。
|
||||||
|
- 原始线稿、SVG 源文件和排版配置不得暴露给 Web 客户端。
|
||||||
|
- 预览可使用带水印低清图;下载生成清晰 A4 PNG/PDF。
|
||||||
|
- 每张内容具备主题、年龄、难度、关键词、版权来源和素材版本元数据。
|
||||||
|
|
||||||
|
## 5. 关键用户路径
|
||||||
|
|
||||||
|
```text
|
||||||
|
选择孩子/年龄
|
||||||
|
→ 首页看到“今天练什么”或主题包
|
||||||
|
→ 预览、替换或加入本周包
|
||||||
|
→ 生成并保存练习纸
|
||||||
|
→ 打印、纸上完成
|
||||||
|
→ 家长勾选已完成
|
||||||
|
→ 下次按年龄、目标与完成情况推荐
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 验收指标
|
||||||
|
|
||||||
|
v3.5 不以“新增多少题型”验收,而以以下漏斗衡量:
|
||||||
|
|
||||||
|
| 指标 | 定义 | 初始目标 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 首次导出转化率 | 新用户首次会话中成功保存练习纸的比例 | 持续提升;先建立基线 |
|
||||||
|
| 首次决策时长 | 打开首页至第一次点击“生成/导出”的时间 | 中位数低于 60 秒 |
|
||||||
|
| 7 日复用率 | 首次生成后 7 日内再次生成或下载的用户比例 | 持续提升;先建立基线 |
|
||||||
|
| 涂色内容表现 | 涂色纸的预览→导出、收藏、分享转化 | 高于当前题型中位数 |
|
||||||
|
| 学习包完成率 | 创建包后至少导出其中 3 张的比例 | 用于判断学习包是否成立 |
|
||||||
|
|
||||||
|
## 7. 明确暂不做
|
||||||
|
|
||||||
|
- 为了“看起来丰富”而新增大量同质数学题。
|
||||||
|
- 依赖儿童上传照片、公开晒图的社区功能。
|
||||||
|
- 没有验证批量使用需求前的多页 PDF 付费功能。
|
||||||
|
- 没有内容生产能力支撑前的完整 Web 分类站。
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Doodle Mini 埋点事件字典
|
||||||
|
|
||||||
|
> 更新日期:2026-09-24。事件名和字段名须先在微信小程序后台注册,再随版本发布。
|
||||||
|
|
||||||
|
## 通用字段
|
||||||
|
|
||||||
|
所有事件自动携带 `count`、`date_time`、`uuid`、`page_name`。内容事件使用稳定的 `worksheet_id`,而非可变的展示标题;可选字段包括 `worksheet_title`、`mode`、`source`、`category_id`、`export_id`、`duration_ms`、`fail_reason`。
|
||||||
|
|
||||||
|
| 事件 | 含义 | 触发时机 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `page_view` | 页面浏览 | 首页、分类页首次进入 |
|
||||||
|
| `worksheet_click` | 点击某张练习纸 | 首页或分类列表点击有效内容 |
|
||||||
|
| `preview_view` | 打开或切换练习纸预览 | 练习纸页面初始化或切换题型 |
|
||||||
|
| `generate_start` | 开始生成 | 初始随机生成或点击“换一张” |
|
||||||
|
| `generate_success` / `generate_fail` | 生成结果 | Canvas 绘制结束;包含耗时和失败原因 |
|
||||||
|
| `export_click` | 点击保存 | 调用相册保存前 |
|
||||||
|
| `export_success` / `export_fail` | 导出结果 | 仅以相册保存 API 的结果为准 |
|
||||||
|
| `favorite_add` / `favorite_remove` | 收藏变化 | 云端收藏操作成功后 |
|
||||||
|
| `share_click` | 发起分享 | 调起小程序分享时 |
|
||||||
|
|
||||||
|
## 导出判定
|
||||||
|
|
||||||
|
`export_click` 不能作为下载量或转化。只有 `wx.saveImageToPhotosAlbum` 返回成功时才触发 `export_success`,并在同一时机递增本地下载配额、写入下载历史和更新内容下载量。
|
||||||
|
|
||||||
|
每次导出会生成 `export_id`。云端以 `userId + exportId` 去重,因此同一次保存的网络重试不会重复写下载历史或重复增加 `worksheets.downloads`。
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# Doodle Mini v3.5 待办与里程碑
|
||||||
|
|
||||||
|
> 状态:待排期
|
||||||
|
>
|
||||||
|
> 更新日期:2026-09-24
|
||||||
|
|
||||||
|
## 0. 排期原则
|
||||||
|
|
||||||
|
按依赖关系推进:**先修复与可发现性 → 再验证涂色内容 → 再形成学习计划 → 同时建设小规模 Web/服务端样板。**
|
||||||
|
|
||||||
|
以下周期为相对迭代,不绑定具体人天;每个里程碑完成后应先检查数据,再决定是否扩大范围。
|
||||||
|
|
||||||
|
## M0:体验与数据基线(第 1 个迭代)
|
||||||
|
|
||||||
|
### 产品与研发
|
||||||
|
|
||||||
|
- [x] 兼容 `shape-recognition` 的旧通用页路径,并统一本地/旧入口至独立“识别形状”页面,避免回退到错误题型。
|
||||||
|
- [x] 修复未上线调试页中时钟连线的过期路由。
|
||||||
|
- [x] 统一首页推荐数据字段,确保自动刷新结果使用 `previewImg`。
|
||||||
|
- [x] 为首页、分类、预览、生成、导出、收藏、分享补齐统一埋点口径。
|
||||||
|
- [x] 明确“成功导出”的唯一判定:实际保存成功后才记录下载、计数和转化。
|
||||||
|
|
||||||
|
### 产品验收
|
||||||
|
|
||||||
|
- [ ] 核对所有分类入口、首页推荐、分龄推荐和收藏历史中的跳转路径。
|
||||||
|
- [ ] 建立当前题型的预览→生成→保存漏斗基线。
|
||||||
|
- [ ] 建立按年龄段、分类和题型的下载/收藏基线。
|
||||||
|
|
||||||
|
### 完成标准
|
||||||
|
|
||||||
|
- 所有正式入口可打开预期题型;无静默回退到其他题型。
|
||||||
|
- 首页定时/手动推荐均能展示预览图。
|
||||||
|
- 可以从统计中区分“点击生成”和“实际成功保存”。
|
||||||
|
|
||||||
|
## M1:发现与选择(第 2 个迭代)
|
||||||
|
|
||||||
|
### 搜索与筛选
|
||||||
|
|
||||||
|
- [ ] 恢复分类页关键词搜索 UI,复用已有标题/副标题过滤能力。
|
||||||
|
- [ ] 增加年龄筛选:3–4、4–5、5–6、6–7、7–8 岁。
|
||||||
|
- [ ] 增加主题/标签筛选,优先支持动物、食物水果、交通、恐龙、节日等。
|
||||||
|
- [ ] 设计无结果状态和“推荐相近内容”兜底。
|
||||||
|
- [ ] 首页搜索入口只在具备完整结果页后开放,避免假搜索框。
|
||||||
|
|
||||||
|
### 今日推荐
|
||||||
|
|
||||||
|
- [ ] 定义推荐位:主练习、趣味练习、语言/书写练习各一张。
|
||||||
|
- [ ] 为推荐增加替换动作和推荐原因,如“适合 4–5 岁”“本周热门”。
|
||||||
|
- [ ] 明确手工运营、定时热度榜与分龄规则的优先级。
|
||||||
|
|
||||||
|
### 完成标准
|
||||||
|
|
||||||
|
- 家长能从首页或分类页在一分钟内筛出一个适龄题型。
|
||||||
|
- 推荐位可跳转、可更换、可追踪点击与导出。
|
||||||
|
|
||||||
|
## M2:创意涂色纸 MVP(第 3–4 个迭代)
|
||||||
|
|
||||||
|
### 内容与设计
|
||||||
|
|
||||||
|
- [ ] 制定线稿素材规范:A4 留白、安全线宽、低龄/进阶分级、预览图规格。
|
||||||
|
- [ ] 建立素材授权登记表:来源、授权类型、作者、可用范围、版本、审核人。
|
||||||
|
- [ ] 完成五主题 24–30 张首发线稿及预览图。
|
||||||
|
- [ ] 设计涂色纸题型卡、主题入口、预览页和“换一张”交互。
|
||||||
|
|
||||||
|
### 研发
|
||||||
|
|
||||||
|
- [ ] 增加 `coloring`/`craft` 业务分类及 worksheet 元数据。
|
||||||
|
- [ ] 建立涂色纸服务端生成样板,详见《Web 与服务端生成策略》。
|
||||||
|
- [ ] 支持低龄自由涂色与一类结构化玩法(颜色指令或按数字涂色)。
|
||||||
|
- [ ] 实现预览图、收藏、导出日志、下载统计和首页推荐接入。
|
||||||
|
- [ ] 对生成接口进行鉴权、限流、参数白名单和短期下载地址控制。
|
||||||
|
|
||||||
|
### 完成标准
|
||||||
|
|
||||||
|
- 首发素材均可追溯商用权利。
|
||||||
|
- 用户可完成“主题 → 预览 → 生成 → 保存”的闭环。
|
||||||
|
- 原始素材与生成规则不向 Web 客户端返回。
|
||||||
|
|
||||||
|
## M3:从单张到每周学习(第 5 个迭代起)
|
||||||
|
|
||||||
|
- [ ] 定义孩子档案:昵称、年龄、学习目标;多孩支持作为后续增强。
|
||||||
|
- [ ] 设计学习包:年龄、目标、每日时长、天数、每天的练习组合。
|
||||||
|
- [ ] 实现单张替换、整包收藏、导出进度与“已完成”打卡。
|
||||||
|
- [ ] 定义推荐去重和难度分配规则,避免连续出现相同题型。
|
||||||
|
- [ ] 以轻量周进度和连续天数做回访,不做高负担的学习报告。
|
||||||
|
|
||||||
|
### 完成标准
|
||||||
|
|
||||||
|
- 用户可创建一套至少 5 天的练习安排。
|
||||||
|
- 创建后可完成至少“替换一张、导出一张、标记完成”三项动作。
|
||||||
|
- 能统计创建包后导出 3 张以上的比例。
|
||||||
|
|
||||||
|
## M4:Web 试点(与 M2 并行,受服务端样板依赖)
|
||||||
|
|
||||||
|
- [ ] 完成服务端 Render API 的认证、任务与文件交付能力。
|
||||||
|
- [ ] 搭建一个小型 Web 站点:首页、主题聚合页、详情/生成页、打印/PDF 下载页。
|
||||||
|
- [ ] 只上线 10–20 个高意图内容页和 2–3 类服务端生成器。
|
||||||
|
- [ ] 建立 Web 独立埋点:搜索来源、关键词、预览、生成、下载、回访。
|
||||||
|
- [ ] 制定 SEO 内容模板与发布流程,确保每新增内容具有独立标题、描述、主题和相关推荐。
|
||||||
|
|
||||||
|
### 完成标准
|
||||||
|
|
||||||
|
- Web 能完成一次独立于小程序的访问、生成和下载。
|
||||||
|
- 浏览器不包含核心生成规则、完整模板坐标或原始线稿素材。
|
||||||
|
- 先获得自然搜索和下载数据,再决定是否扩成完整资源平台。
|
||||||
|
|
||||||
|
## 决策检查点
|
||||||
|
|
||||||
|
| 检查点 | 决策问题 | 继续投入条件 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 涂色 MVP 上线后 | 是否扩大内容库和主题包? | 预览→导出、收藏或分享表现显著高于现有题型中位数 |
|
||||||
|
| 学习包试点后 | 是否做 PDF/会员? | 用户确实会连续导出多张,且有明确的批量打印需求 |
|
||||||
|
| Web 试点后 | 是否建设完整 Web 平台? | 自然流量、下载和回访有增长;内容可稳定周更 |
|
||||||
|
| 服务端渲染试点后 | 是否迁移更多旧题型? | P95 生成时延、成功率和单位生成成本满足目标 |
|
||||||
|
|
||||||
|
## 风险清单
|
||||||
|
|
||||||
|
| 风险 | 应对 |
|
||||||
|
| --- | --- |
|
||||||
|
| 涂色素材版权不清晰 | 建立授权台账;不使用知名 IP;发布前人工审核 |
|
||||||
|
| 服务端生成成本失控 | 参数哈希缓存、热门主题预生成、限额与异步任务 |
|
||||||
|
| Web 内容薄、没有搜索价值 | 先做少量高意图页面,不以“站点页面数”作为目标 |
|
||||||
|
| 小程序和 Web 各自维护内容 | 以 `worksheetId` 和平台无关元数据作为单一内容源 |
|
||||||
|
| 过早会员化影响首次体验 | 先验证学习包和批量下载需求;单张基础内容保持可试用 |
|
||||||
@@ -85,6 +85,7 @@ type SharePathData = {
|
|||||||
functionId?: string;
|
functionId?: string;
|
||||||
worksheetId?: string;
|
worksheetId?: string;
|
||||||
currentMode?: string;
|
currentMode?: string;
|
||||||
|
pageTitle?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SharePathPage = {
|
type SharePathPage = {
|
||||||
@@ -143,9 +144,13 @@ export function getAppShareAppMessage(
|
|||||||
trackerLabel?: string,
|
trackerLabel?: string,
|
||||||
page?: SharePathPage,
|
page?: SharePathPage,
|
||||||
) {
|
) {
|
||||||
if (trackerLabel) {
|
tracker.reportShare(page?.route || trackerLabel || '', {
|
||||||
tracker.reportShare(trackerLabel);
|
...(trackerLabel && { worksheet_title: trackerLabel }),
|
||||||
}
|
...(page?.data?.functionId || page?.data?.worksheetId
|
||||||
|
? { worksheet_id: page.data.functionId || page.data.worksheetId }
|
||||||
|
: {}),
|
||||||
|
...(page?.data?.currentMode ? { mode: page.data.currentMode } : {}),
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
title: defaultShareConfig.title,
|
title: defaultShareConfig.title,
|
||||||
path: buildPageSharePath(page),
|
path: buildPageSharePath(page),
|
||||||
@@ -158,9 +163,13 @@ export function getAppShareTimeline(
|
|||||||
trackerLabel?: string,
|
trackerLabel?: string,
|
||||||
page?: SharePathPage,
|
page?: SharePathPage,
|
||||||
) {
|
) {
|
||||||
if (trackerLabel) {
|
tracker.reportShare(page?.route || trackerLabel || '', {
|
||||||
tracker.reportShare(trackerLabel);
|
...(trackerLabel && { worksheet_title: trackerLabel }),
|
||||||
}
|
...(page?.data?.functionId || page?.data?.worksheetId
|
||||||
|
? { worksheet_id: page.data.functionId || page.data.worksheetId }
|
||||||
|
: {}),
|
||||||
|
...(page?.data?.currentMode ? { mode: page.data.currentMode } : {}),
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
title: defaultShareConfig.title,
|
title: defaultShareConfig.title,
|
||||||
query: buildPageShareTimelineQuery(page),
|
query: buildPageShareTimelineQuery(page),
|
||||||
@@ -171,10 +180,10 @@ export function getAppShareTimeline(
|
|||||||
/** 供 Page({}) 直接展开:统一转发 / 分享到朋友圈(path 为当前页) */
|
/** 供 Page({}) 直接展开:统一转发 / 分享到朋友圈(path 为当前页) */
|
||||||
export const appSharePageMethods = {
|
export const appSharePageMethods = {
|
||||||
onShareAppMessage(this: SharePathPage) {
|
onShareAppMessage(this: SharePathPage) {
|
||||||
return getAppShareAppMessage(undefined, this);
|
return getAppShareAppMessage(this.data?.pageTitle, this);
|
||||||
},
|
},
|
||||||
onShareTimeline(this: SharePathPage) {
|
onShareTimeline(this: SharePathPage) {
|
||||||
return getAppShareTimeline(undefined, this);
|
return getAppShareTimeline(this.data?.pageTitle, this);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -331,6 +340,7 @@ export function getPageCommonMethods(config: PageCommonMethodsConfig = {}) {
|
|||||||
errorToast: '请先生成内容',
|
errorToast: '请先生成内容',
|
||||||
trackerName: this.data.pageTitle,
|
trackerName: this.data.pageTitle,
|
||||||
trackerMode: this.data.currentMode,
|
trackerMode: this.data.currentMode,
|
||||||
|
trackerPage: this.route,
|
||||||
worksheetId,
|
worksheetId,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -394,6 +404,7 @@ export function getPageCommonMethods(config: PageCommonMethodsConfig = {}) {
|
|||||||
errorToast: '请先生成内容',
|
errorToast: '请先生成内容',
|
||||||
trackerName: this.data.pageTitle,
|
trackerName: this.data.pageTitle,
|
||||||
trackerMode: this.data.currentMode,
|
trackerMode: this.data.currentMode,
|
||||||
|
trackerPage: this.route,
|
||||||
worksheetId,
|
worksheetId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -591,6 +602,11 @@ export function getPageCommonMethods(config: PageCommonMethodsConfig = {}) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
wx.setNavigationBarTitle({ title });
|
wx.setNavigationBarTitle({ title });
|
||||||
|
tracker.reportPreviewView(this.route || '', {
|
||||||
|
worksheet_id: functionId,
|
||||||
|
worksheet_title: title,
|
||||||
|
...(this.data.currentMode ? { mode: this.data.currentMode } : {}),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -607,6 +623,90 @@ export function applyPageMixin(
|
|||||||
config: PageCommonMethodsConfig = {},
|
config: PageCommonMethodsConfig = {},
|
||||||
) {
|
) {
|
||||||
const commonMethods = getPageCommonMethods(config);
|
const commonMethods = getPageCommonMethods(config);
|
||||||
|
const trackedOptions = { ...pageOptions };
|
||||||
|
const generationStartKey = '__generationTrackingStartedAt';
|
||||||
|
|
||||||
|
const beginGenerationTracking = function (this: PageCanvasInstance) {
|
||||||
|
if ((this as any)[generationStartKey]) return;
|
||||||
|
const worksheetId =
|
||||||
|
typeof this.getWorksheetStatsId === 'function'
|
||||||
|
? this.getWorksheetStatsId()
|
||||||
|
: this.data.functionId;
|
||||||
|
(this as any)[generationStartKey] = Date.now();
|
||||||
|
tracker.reportGenerateStart(this.route || '', {
|
||||||
|
worksheet_id: worksheetId || '',
|
||||||
|
...(this.data.currentMode ? { mode: this.data.currentMode } : {}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalOnRandom = pageOptions.onRandom;
|
||||||
|
if (typeof originalOnRandom === 'function') {
|
||||||
|
trackedOptions.onRandom = function (
|
||||||
|
this: PageCanvasInstance,
|
||||||
|
...args: any[]
|
||||||
|
) {
|
||||||
|
beginGenerationTracking.call(this);
|
||||||
|
return originalOnRandom.apply(this, args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalOnPreviewRefresh = pageOptions.onPreviewRefresh;
|
||||||
|
if (typeof originalOnPreviewRefresh === 'function') {
|
||||||
|
trackedOptions.onPreviewRefresh = function (
|
||||||
|
this: PageCanvasInstance,
|
||||||
|
...args: any[]
|
||||||
|
) {
|
||||||
|
beginGenerationTracking.call(this);
|
||||||
|
return originalOnPreviewRefresh.apply(this, args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalDrawCanvas = pageOptions.drawCanvas;
|
||||||
|
if (typeof originalDrawCanvas === 'function') {
|
||||||
|
trackedOptions.drawCanvas = async function (
|
||||||
|
this: PageCanvasInstance,
|
||||||
|
...args: any[]
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const result = await originalDrawCanvas.apply(this, args);
|
||||||
|
const startedAt = (this as any)[generationStartKey] as
|
||||||
|
| number
|
||||||
|
| undefined;
|
||||||
|
if (startedAt) {
|
||||||
|
delete (this as any)[generationStartKey];
|
||||||
|
const worksheetId =
|
||||||
|
typeof this.getWorksheetStatsId === 'function'
|
||||||
|
? this.getWorksheetStatsId()
|
||||||
|
: this.data.functionId;
|
||||||
|
tracker.reportGenerateResult(
|
||||||
|
this.route || '',
|
||||||
|
this.data.hasContent !== false,
|
||||||
|
{
|
||||||
|
worksheet_id: worksheetId || '',
|
||||||
|
duration_ms: Date.now() - startedAt,
|
||||||
|
...(this.data.currentMode
|
||||||
|
? { mode: this.data.currentMode }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const startedAt = (this as any)[generationStartKey] as
|
||||||
|
| number
|
||||||
|
| undefined;
|
||||||
|
if (startedAt) {
|
||||||
|
delete (this as any)[generationStartKey];
|
||||||
|
tracker.reportGenerateResult(this.route || '', false, {
|
||||||
|
worksheet_id: this.data.functionId || '',
|
||||||
|
duration_ms: Date.now() - startedAt,
|
||||||
|
fail_reason: 'draw_exception',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// 提取 pageOptions 中的 data(如果有)
|
// 提取 pageOptions 中的 data(如果有)
|
||||||
const pageData = pageOptions.data || {};
|
const pageData = pageOptions.data || {};
|
||||||
@@ -615,7 +715,7 @@ export function applyPageMixin(
|
|||||||
// 注意:pageOptions 放在后面,这样页面可以覆盖公共方法
|
// 注意:pageOptions 放在后面,这样页面可以覆盖公共方法
|
||||||
const mergedOptions: any = {
|
const mergedOptions: any = {
|
||||||
...commonMethods,
|
...commonMethods,
|
||||||
...pageOptions,
|
...trackedOptions,
|
||||||
// 处理 data 的合并(需要特殊处理,避免覆盖)
|
// 处理 data 的合并(需要特殊处理,避免覆盖)
|
||||||
data: {
|
data: {
|
||||||
...pageData,
|
...pageData,
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ createFocusPage({
|
|||||||
|
|
||||||
onLoad(options: { id?: string; mode?: string }) {
|
onLoad(options: { id?: string; mode?: string }) {
|
||||||
const routeId = options.id || FOCUS_TYPE_CONFIGS[0].id;
|
const routeId = options.id || FOCUS_TYPE_CONFIGS[0].id;
|
||||||
|
|
||||||
|
// “识别形状”仍由独立页面承载。兼容已发布的旧路径和本地兜底
|
||||||
|
// /focusPages/focusDraw/focusDraw?id=shape-recognition,避免通用注册表
|
||||||
|
// 找不到该 id 时静默回退为第一个题型。
|
||||||
|
if (routeId === 'shape-recognition') {
|
||||||
|
wx.redirectTo({ url: '/focusPages/shape/shape' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = findTypeByRouteId(routeId);
|
const result = findTypeByRouteId(routeId);
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
shouldShowShareGuide,
|
shouldShowShareGuide,
|
||||||
shouldUnlockWithAd,
|
shouldUnlockWithAd,
|
||||||
} from '../../utils/shareGuide';
|
} from '../../utils/shareGuide';
|
||||||
|
import tracker from '../../utils/tracker';
|
||||||
|
|
||||||
Page({
|
Page({
|
||||||
canvas: null as Canvas | null,
|
canvas: null as Canvas | null,
|
||||||
@@ -20,6 +21,8 @@ Page({
|
|||||||
timer: null as any,
|
timer: null as any,
|
||||||
|
|
||||||
data: {
|
data: {
|
||||||
|
worksheetId: 'shape-recognition',
|
||||||
|
pageTitle: '识别图形,涂一涂',
|
||||||
shapeList: [] as ShapeCard[],
|
shapeList: [] as ShapeCard[],
|
||||||
showSelectShapePopup: false,
|
showSelectShapePopup: false,
|
||||||
selectedShapeIndex: -1,
|
selectedShapeIndex: -1,
|
||||||
@@ -31,6 +34,10 @@ Page({
|
|||||||
},
|
},
|
||||||
|
|
||||||
onLoad() {
|
onLoad() {
|
||||||
|
tracker.reportPreviewView('focusPages/shape/shape', {
|
||||||
|
worksheet_id: 'shape-recognition',
|
||||||
|
worksheet_title: '识别图形,涂一涂',
|
||||||
|
});
|
||||||
// 判断是否在PC端开发工具上运行
|
// 判断是否在PC端开发工具上运行
|
||||||
// const systemInfo = wx.getDeviceInfo();
|
// const systemInfo = wx.getDeviceInfo();
|
||||||
// if (systemInfo.platform === 'devtools') {
|
// if (systemInfo.platform === 'devtools') {
|
||||||
@@ -213,6 +220,10 @@ Page({
|
|||||||
* 随机生成6个图形
|
* 随机生成6个图形
|
||||||
*/
|
*/
|
||||||
refreshShapeCard() {
|
refreshShapeCard() {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
tracker.reportGenerateStart('focusPages/shape/shape', {
|
||||||
|
worksheet_id: 'shape-recognition',
|
||||||
|
});
|
||||||
// 随机取6个图形
|
// 随机取6个图形
|
||||||
const shapesCopy = [...SHAPES];
|
const shapesCopy = [...SHAPES];
|
||||||
for (let i = shapesCopy.length - 1; i > 0; i--) {
|
for (let i = shapesCopy.length - 1; i > 0; i--) {
|
||||||
@@ -244,6 +255,10 @@ Page({
|
|||||||
() => {
|
() => {
|
||||||
// 绘制canvas
|
// 绘制canvas
|
||||||
this.drawCanvas();
|
this.drawCanvas();
|
||||||
|
tracker.reportGenerateResult('focusPages/shape/shape', true, {
|
||||||
|
worksheet_id: 'shape-recognition',
|
||||||
|
duration_ms: Date.now() - startedAt,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -312,6 +327,8 @@ Page({
|
|||||||
await downloadPrint(this.canvas, {
|
await downloadPrint(this.canvas, {
|
||||||
errorToast: '请先生成图形',
|
errorToast: '请先生成图形',
|
||||||
trackerName: '图形涂色',
|
trackerName: '图形涂色',
|
||||||
|
trackerPage: 'focusPages/shape/shape',
|
||||||
|
worksheetId: 'shape-recognition',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -327,6 +344,8 @@ Page({
|
|||||||
await downloadPrint(this.canvas, {
|
await downloadPrint(this.canvas, {
|
||||||
errorToast: '请先生成图形',
|
errorToast: '请先生成图形',
|
||||||
trackerName: '图形涂色',
|
trackerName: '图形涂色',
|
||||||
|
trackerPage: 'focusPages/shape/shape',
|
||||||
|
worksheetId: 'shape-recognition',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -443,7 +443,7 @@ const CATEGORY_ITEMS_BY_ID: Record<string, CategoryItem[]> = {
|
|||||||
previewImg: '/assets/entrancePicture/focus/shape-recognition.png',
|
previewImg: '/assets/entrancePicture/focus/shape-recognition.png',
|
||||||
ageBand: age(3, 5),
|
ageBand: age(3, 5),
|
||||||
difficulty: 1,
|
difficulty: 1,
|
||||||
path: '/focusPages/focusDraw/focusDraw?id=shape-recognition',
|
path: '/focusPages/shape/shape',
|
||||||
available: true,
|
available: true,
|
||||||
}),
|
}),
|
||||||
item({
|
item({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { appSharePageMethods } from '../../base/pageMixin';
|
import { appSharePageMethods } from '../../base/pageMixin';
|
||||||
import { CATEGORY_LIST_WITH_ALL } from '../../core/data/categories';
|
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 { CATEGORY_DATA, type CategoryItem } from './category.data';
|
import { CATEGORY_DATA, type CategoryItem } from './category.data';
|
||||||
|
|
||||||
type CategoryTab = {
|
type CategoryTab = {
|
||||||
@@ -224,6 +225,7 @@ Page({
|
|||||||
_defaultCategoryId: 'all',
|
_defaultCategoryId: 'all',
|
||||||
|
|
||||||
onLoad(options: Record<string, string>) {
|
onLoad(options: Record<string, string>) {
|
||||||
|
tracker.reportPageView('pages/category/category');
|
||||||
if (options.id && options.id !== 'all') {
|
if (options.id && options.id !== 'all') {
|
||||||
this._defaultCategoryId = options.id;
|
this._defaultCategoryId = options.id;
|
||||||
this.setData({ activeCategoryId: options.id });
|
this.setData({ activeCategoryId: options.id });
|
||||||
@@ -285,6 +287,9 @@ Page({
|
|||||||
displayItems: filtered,
|
displayItems: filtered,
|
||||||
scrollIntoViewId: '',
|
scrollIntoViewId: '',
|
||||||
});
|
});
|
||||||
|
tracker.report('category_select', 'pages/category/category', {
|
||||||
|
category_id: id,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
onSearchInput(e: WechatMiniprogram.Input) {
|
onSearchInput(e: WechatMiniprogram.Input) {
|
||||||
@@ -319,6 +324,13 @@ Page({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)) {
|
if (TAB_BAR_PATHS.has(path)) {
|
||||||
wx.switchTab({ url: path });
|
wx.switchTab({ url: path });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
parseMiniProgramUrl,
|
parseMiniProgramUrl,
|
||||||
stashPendingCategoryTabId,
|
stashPendingCategoryTabId,
|
||||||
} from '../../utils/index';
|
} from '../../utils/index';
|
||||||
|
import tracker from '../../utils/tracker';
|
||||||
import {
|
import {
|
||||||
HOME_DAILY_CHECKIN_ITEMS,
|
HOME_DAILY_CHECKIN_ITEMS,
|
||||||
HOME_NOTICES,
|
HOME_NOTICES,
|
||||||
@@ -124,6 +125,23 @@ function navigateByPath(path?: string, fallbackTitle?: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function worksheetIdFromPath(path?: string): string {
|
||||||
|
if (!path) return '';
|
||||||
|
return String(parseMiniProgramUrl(path).query.id || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackWorksheetClick(
|
||||||
|
path: string | undefined,
|
||||||
|
title: string | undefined,
|
||||||
|
source: string,
|
||||||
|
) {
|
||||||
|
tracker.reportWorksheetClick('pages/home/home', {
|
||||||
|
worksheet_id: worksheetIdFromPath(path),
|
||||||
|
worksheet_title: title || '',
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Skeleton data ──
|
// ── Skeleton data ──
|
||||||
|
|
||||||
function skeletonItem(id: string): HomeDisplayItemView {
|
function skeletonItem(id: string): HomeDisplayItemView {
|
||||||
@@ -196,6 +214,7 @@ Page({
|
|||||||
},
|
},
|
||||||
|
|
||||||
onLoad() {
|
onLoad() {
|
||||||
|
tracker.reportPageView('pages/home/home');
|
||||||
void this.initData();
|
void this.initData();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -274,12 +293,14 @@ Page({
|
|||||||
onTapFeatured(e: WechatMiniprogram.TouchEvent) {
|
onTapFeatured(e: WechatMiniprogram.TouchEvent) {
|
||||||
const path = e.currentTarget.dataset.path as string | undefined;
|
const path = e.currentTarget.dataset.path as string | undefined;
|
||||||
const title = e.currentTarget.dataset.title as string | undefined;
|
const title = e.currentTarget.dataset.title as string | undefined;
|
||||||
|
trackWorksheetClick(path, title, 'home_featured');
|
||||||
navigateByPath(path, title);
|
navigateByPath(path, title);
|
||||||
},
|
},
|
||||||
|
|
||||||
onTapHotRecommend(e: WechatMiniprogram.TouchEvent) {
|
onTapHotRecommend(e: WechatMiniprogram.TouchEvent) {
|
||||||
const path = e.currentTarget.dataset.path as string | undefined;
|
const path = e.currentTarget.dataset.path as string | undefined;
|
||||||
const title = e.currentTarget.dataset.title as string | undefined;
|
const title = e.currentTarget.dataset.title as string | undefined;
|
||||||
|
trackWorksheetClick(path, title, 'home_hot');
|
||||||
navigateByPath(path, title);
|
navigateByPath(path, title);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -310,6 +331,7 @@ Page({
|
|||||||
const title =
|
const title =
|
||||||
detail.title ||
|
detail.title ||
|
||||||
(e.currentTarget.dataset.title as string | undefined);
|
(e.currentTarget.dataset.title as string | undefined);
|
||||||
|
trackWorksheetClick(path, title, 'home_section');
|
||||||
navigateByPath(path, title);
|
navigateByPath(path, title);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,12 @@ Page({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 统一跳转到 focusDraw 页面
|
// “识别形状”仍使用独立页面;其余题型统一跳转到 focusDraw 页面
|
||||||
if (functionItem.page) {
|
if (functionItem.page) {
|
||||||
|
if (functionId === 'shape-recognition') {
|
||||||
|
wx.navigateTo({ url: '/focusPages/shape/shape' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
let url = `/focusPages/focusDraw/focusDraw?id=${functionId}`;
|
let url = `/focusPages/focusDraw/focusDraw?id=${functionId}`;
|
||||||
if (functionItem.mode) {
|
if (functionItem.mode) {
|
||||||
url += `&mode=${functionItem.mode}`;
|
url += `&mode=${functionItem.mode}`;
|
||||||
|
|||||||
@@ -23,6 +23,6 @@ export const UNRELEASED_PAGE_ENTRIES: UnreleasedPageEntry[] = [
|
|||||||
id: 'clock-connect',
|
id: 'clock-connect',
|
||||||
title: '时钟连线',
|
title: '时钟连线',
|
||||||
subtitle: '看钟表连对应时间,认识时刻',
|
subtitle: '看钟表连对应时间,认识时刻',
|
||||||
path: '/mathPages/clockConnect/clockConnect',
|
path: '/mathPages/clockReading/clockReading?id=clock-connect',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface DownloadLogRecord {
|
|||||||
_id: string;
|
_id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
worksheetId: string;
|
worksheetId: string;
|
||||||
|
exportId?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
worksheet?: {
|
worksheet?: {
|
||||||
_id: string;
|
_id: string;
|
||||||
@@ -22,13 +23,16 @@ export interface DownloadLogRecord {
|
|||||||
/**
|
/**
|
||||||
* 记录下载日志
|
* 记录下载日志
|
||||||
*/
|
*/
|
||||||
export async function addDownloadLog(worksheetId: string): Promise<boolean> {
|
export async function addDownloadLog(
|
||||||
|
worksheetId: string,
|
||||||
|
exportId?: string,
|
||||||
|
): Promise<boolean> {
|
||||||
if (!worksheetId) return false;
|
if (!worksheetId) return false;
|
||||||
try {
|
try {
|
||||||
await getUser();
|
await getUser();
|
||||||
const res = await wx.cloud.callFunction({
|
const res = await wx.cloud.callFunction({
|
||||||
name: 'userDownloadLogs',
|
name: 'userDownloadLogs',
|
||||||
data: { action: 'add', worksheetId },
|
data: { action: 'add', worksheetId, exportId },
|
||||||
});
|
});
|
||||||
const result = (res?.result as any) || {};
|
const result = (res?.result as any) || {};
|
||||||
return result.code === 0;
|
return result.code === 0;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { checkAndSaveImage } from './saveImage';
|
import { checkAndSaveImage } from './saveImage';
|
||||||
import tracker from './tracker';
|
import tracker from './tracker';
|
||||||
import { addDownloadLog } from './downloadLogs';
|
import { addDownloadLog } from './downloadLogs';
|
||||||
|
import { generateUUID } from './uuid';
|
||||||
import {
|
import {
|
||||||
getUnlockMethod,
|
getUnlockMethod,
|
||||||
isDownloadLimitEnabled,
|
isDownloadLimitEnabled,
|
||||||
@@ -131,23 +132,32 @@ function doDownload(
|
|||||||
errorToast?: string;
|
errorToast?: string;
|
||||||
trackerName?: string;
|
trackerName?: string;
|
||||||
trackerMode?: string;
|
trackerMode?: string;
|
||||||
|
trackerPage?: string;
|
||||||
worksheetId?: string;
|
worksheetId?: string;
|
||||||
},
|
},
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (options.trackerName) {
|
const exportId = generateUUID();
|
||||||
if (options.trackerMode) {
|
const trackerPage = options.trackerPage || options.trackerName || '';
|
||||||
tracker.reportDownload(options.trackerName, options.trackerMode);
|
const trackerParams = {
|
||||||
} else {
|
export_id: exportId,
|
||||||
tracker.reportDownload(options.trackerName);
|
...(options.worksheetId && { worksheet_id: options.worksheetId }),
|
||||||
}
|
...(options.trackerName && { worksheet_title: options.trackerName }),
|
||||||
}
|
...(options.trackerMode && { mode: options.trackerMode }),
|
||||||
|
};
|
||||||
|
|
||||||
return checkAndSaveImage(canvas).then((saved) => {
|
// 用户点击保存不等于保存成功,二者必须分开统计。
|
||||||
|
tracker.reportExportClick(trackerPage, trackerParams);
|
||||||
|
|
||||||
|
return checkAndSaveImage(canvas).then(async (saved) => {
|
||||||
if (saved) {
|
if (saved) {
|
||||||
|
// 只在 wx.saveImageToPhotosAlbum 的 success 回调后进入此分支。
|
||||||
recordDownloadSuccess();
|
recordDownloadSuccess();
|
||||||
|
tracker.reportExportResult(trackerPage, true, trackerParams);
|
||||||
if (options.worksheetId) {
|
if (options.worksheetId) {
|
||||||
addDownloadLog(options.worksheetId);
|
await addDownloadLog(options.worksheetId, exportId);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
tracker.reportExportResult(trackerPage, false, trackerParams);
|
||||||
}
|
}
|
||||||
return saved;
|
return saved;
|
||||||
});
|
});
|
||||||
@@ -163,6 +173,7 @@ export async function downloadPrint(
|
|||||||
errorToast?: string;
|
errorToast?: string;
|
||||||
trackerName?: string;
|
trackerName?: string;
|
||||||
trackerMode?: string;
|
trackerMode?: string;
|
||||||
|
trackerPage?: string;
|
||||||
worksheetId?: string;
|
worksheetId?: string;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { getUser } from './auth';
|
import { getUser } from './auth';
|
||||||
|
import tracker from './tracker';
|
||||||
|
|
||||||
|
function getCurrentPageRoute(): string {
|
||||||
|
const pages = getCurrentPages();
|
||||||
|
return pages[pages.length - 1]?.route || '';
|
||||||
|
}
|
||||||
|
|
||||||
export interface FavoriteRecord {
|
export interface FavoriteRecord {
|
||||||
_id: string;
|
_id: string;
|
||||||
@@ -31,7 +37,9 @@ export async function addFavorite(worksheetId: string): Promise<boolean> {
|
|||||||
data: { action: 'add', worksheetId },
|
data: { action: 'add', worksheetId },
|
||||||
});
|
});
|
||||||
const result = (res?.result as any) || {};
|
const result = (res?.result as any) || {};
|
||||||
return result.code === 0;
|
const success = result.code === 0;
|
||||||
|
if (success) tracker.reportFavorite(getCurrentPageRoute(), worksheetId, true);
|
||||||
|
return success;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('addFavorite failed', e);
|
console.error('addFavorite failed', e);
|
||||||
return false;
|
return false;
|
||||||
@@ -49,7 +57,11 @@ export async function removeFavorite(worksheetId: string): Promise<boolean> {
|
|||||||
data: { action: 'remove', worksheetId },
|
data: { action: 'remove', worksheetId },
|
||||||
});
|
});
|
||||||
const result = (res?.result as any) || {};
|
const result = (res?.result as any) || {};
|
||||||
return result.code === 0;
|
const success = result.code === 0;
|
||||||
|
if (success) {
|
||||||
|
tracker.reportFavorite(getCurrentPageRoute(), worksheetId, false);
|
||||||
|
}
|
||||||
|
return success;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('removeFavorite failed', e);
|
console.error('removeFavorite failed', e);
|
||||||
return false;
|
return false;
|
||||||
@@ -133,4 +145,3 @@ export async function batchCheckFavorited(
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -115,18 +115,96 @@ class Tracker {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一业务埋点入口。pageName 使用页面 route,内容使用稳定的 worksheet_id。
|
||||||
|
*/
|
||||||
|
report(
|
||||||
|
eventName: string,
|
||||||
|
pageName: string,
|
||||||
|
extraParams?: Record<string, any>,
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
this.sendEvent(
|
||||||
|
eventName,
|
||||||
|
this.getCommonEventParams(eventName, pageName, extraParams),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`上报 ${eventName} 事件失败:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reportPageView(pageName: string): void {
|
||||||
|
this.report('page_view', pageName);
|
||||||
|
}
|
||||||
|
|
||||||
|
reportWorksheetClick(
|
||||||
|
pageName: string,
|
||||||
|
extraParams: Record<string, any>,
|
||||||
|
): void {
|
||||||
|
this.report('worksheet_click', pageName, extraParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
reportPreviewView(
|
||||||
|
pageName: string,
|
||||||
|
extraParams: Record<string, any>,
|
||||||
|
): void {
|
||||||
|
this.report('preview_view', pageName, extraParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
reportGenerateStart(
|
||||||
|
pageName: string,
|
||||||
|
extraParams: Record<string, any>,
|
||||||
|
): void {
|
||||||
|
this.report('generate_start', pageName, extraParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
reportGenerateResult(
|
||||||
|
pageName: string,
|
||||||
|
success: boolean,
|
||||||
|
extraParams: Record<string, any>,
|
||||||
|
): void {
|
||||||
|
this.report(
|
||||||
|
success ? 'generate_success' : 'generate_fail',
|
||||||
|
pageName,
|
||||||
|
extraParams,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
reportExportClick(
|
||||||
|
pageName: string,
|
||||||
|
extraParams: Record<string, any>,
|
||||||
|
): void {
|
||||||
|
this.report('export_click', pageName, extraParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
reportExportResult(
|
||||||
|
pageName: string,
|
||||||
|
success: boolean,
|
||||||
|
extraParams: Record<string, any>,
|
||||||
|
): void {
|
||||||
|
this.report(
|
||||||
|
success ? 'export_success' : 'export_fail',
|
||||||
|
pageName,
|
||||||
|
extraParams,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
reportFavorite(
|
||||||
|
pageName: string,
|
||||||
|
worksheetId: string,
|
||||||
|
favorited: boolean,
|
||||||
|
): void {
|
||||||
|
this.report(favorited ? 'favorite_add' : 'favorite_remove', pageName, {
|
||||||
|
worksheet_id: worksheetId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 上报分享点击事件
|
* 上报分享点击事件
|
||||||
* @param pageName 页面名称
|
* @param pageName 页面名称
|
||||||
*/
|
*/
|
||||||
reportShare(pageName: string): void {
|
reportShare(pageName: string, extraParams?: Record<string, any>): void {
|
||||||
try {
|
this.report('share_click', pageName, extraParams);
|
||||||
const params = this.getCommonEventParams('share_click', pageName);
|
|
||||||
|
|
||||||
this.sendEvent('share_click', params);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('上报分享事件失败:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -135,15 +213,8 @@ class Tracker {
|
|||||||
* @param mode 模式(可选)
|
* @param mode 模式(可选)
|
||||||
*/
|
*/
|
||||||
reportDownload(pageName: string, mode?: string): void {
|
reportDownload(pageName: string, mode?: string): void {
|
||||||
try {
|
// 兼容旧调用;新代码请使用 reportExportClick / reportExportResult。
|
||||||
const params = this.getCommonEventParams('download', pageName, {
|
this.reportExportClick(pageName, { ...(mode && { mode }) });
|
||||||
...(mode && { mode }),
|
|
||||||
});
|
|
||||||
|
|
||||||
this.sendEvent('download', params);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('上报下载事件失败:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user