Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eddd7d75e2 | |||
| 08d9a2792a |
@@ -31,7 +31,7 @@ function toHomeDisplayItem(ws) {
|
||||
ageBand: ageBand(ws.ageMin || 0, ws.ageMax || 0),
|
||||
difficulty: DIFFICULTY_LABELS[ws.difficulty] || '基础',
|
||||
icon: '',
|
||||
img: ws.previewImg || '',
|
||||
previewImg: ws.previewImg || '',
|
||||
badge: ws.isNew ? 'NEW' : ws.isHot ? 'HOT' : '',
|
||||
path: ws.path || '',
|
||||
available: true,
|
||||
|
||||
@@ -36,13 +36,26 @@ exports.main = async (event) => {
|
||||
|
||||
async function handleAdd(userId, event) {
|
||||
const { worksheetId } = event;
|
||||
const exportId = String(event.exportId || '').trim();
|
||||
if (!worksheetId) return { code: -1, message: 'worksheetId 不能为空' };
|
||||
|
||||
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 = {
|
||||
userId,
|
||||
worksheetId,
|
||||
...(exportId && { exportId }),
|
||||
createdAt: db.serverDate(),
|
||||
};
|
||||
const { _id } = await collection.add({ data: record });
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Doodle / 涂鸦丫 - PC Web 端技术预案
|
||||
|
||||
> **2026-10-12 决策更新**:本文保留为历史预案,DAU 启动门槛和浏览器承载完整生成器不作为当前决策。现有后端已决定迁移到自有服务器,见 [迁移方案(doodle-store)](../../doodle-store/docs/云服务端迁移方案.md);Web 范围和私有服务端生成见 [v3.5 策略](./v3.5/Web与服务端生成策略.md)。
|
||||
|
||||
> 状态:**远期规划**(当前聚焦小程序,暂不启动 Web 开发)
|
||||
> 最后更新:2026-03-24
|
||||
> 配套文档:[技术架构设计文档](./技术架构设计文档.md) | [产品设计文档](./产品设计文档.md)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Doodle Mini v3.5 规划索引
|
||||
|
||||
> 状态:产品规划待评审;云服务端迁移方向已确认,待实施
|
||||
>
|
||||
> 更新日期:2026-10-12
|
||||
|
||||
v3.5 的目标不是继续堆叠题型,而是把「生成一张练习纸」升级为家长可持续使用的家庭打印学习服务,并为未来 Web 内容平台建立服务端生成能力。
|
||||
|
||||
## 本阶段的四项决策
|
||||
|
||||
1. **小程序仍是当前主产品。** 优先改善家长首次找到内容、完成打印、再次回来使用的体验。
|
||||
2. **创意涂色纸是下一项内容型 MVP。** 它覆盖低龄儿童、视觉传播力强,也最适合作为服务端生成的首个试点。
|
||||
3. **现在开始建设 Web 底座,不启动完整 Web 平台。** 新增高价值题型优先接入服务端生成;既有小程序题型不做一次性迁移。
|
||||
4. **现有业务后端迁移到自有云服务器。** 采用统一 API、MySQL 和七牛文件存储;CloudBase 只在切换期承担旧版兼容。业务后端迁移与存量 Canvas 服务端重写分别实施。
|
||||
|
||||
## 文档
|
||||
|
||||
| 文档 | 用途 |
|
||||
| --- | --- |
|
||||
| [产品路线图](./产品路线图.md) | 用户问题、功能优先级、阶段目标与验收指标 |
|
||||
| [待办与里程碑](./待办与里程碑.md) | 可执行的产品、设计、研发待办与依赖关系 |
|
||||
| [Web 与服务端生成策略](./Web与服务端生成策略.md) | Web 试点范围、服务端渲染边界及技术演进决策 |
|
||||
| [云服务端迁移方案(doodle-store)](../../../doodle-store/docs/云服务端迁移方案.md) | 在新项目统一维护:现有功能/API 映射、数据与文件迁移、实施待办、切换回滚、验收及 AI 辅助工时预算 |
|
||||
| [埋点事件字典](./埋点事件字典.md) | 现有事件和成功保存口径;后端迁移继续沿用 |
|
||||
|
||||
## 不在 v3.5 范围内
|
||||
|
||||
- 完整的 Kiddoleap 风格 Web 资源平台、全量 SEO 内容站。
|
||||
- 在业务后端迁移中同时重写全部既有 Canvas 题型。
|
||||
- 社区晒图、公开儿童信息、复杂学习报告。
|
||||
- 会员支付、PDF 多页合并等商业化闭环;仅预留数据与服务端能力。
|
||||
|
||||
这些项目需要在 v3.5 的内容需求、留存和服务端生成成本被验证后再启动。
|
||||
@@ -0,0 +1,153 @@
|
||||
# Doodle Mini v3.5 Web 与服务端生成策略
|
||||
|
||||
> 状态:迁移到自有云服务器的方向已确认;Web 与绘图试点待实施
|
||||
>
|
||||
> 更新日期:2026-10-12
|
||||
|
||||
> 现有业务后端的迁移范围、切换顺序和验收以[云服务端迁移方案(doodle-store)](../../../doodle-store/docs/云服务端迁移方案.md)为准。本文说明迁移后 Web 与服务端生成的演进,不再以继续保留 CloudBase 业务后端为目标。
|
||||
|
||||
## 1. 决策结论
|
||||
|
||||
### 1.1 Web 的定位
|
||||
|
||||
Web 是长期的搜索获客、内容沉淀、电脑打印和教师/机构扩展渠道;小程序仍是当前的微信内打开、分享、每日推荐和家庭留存主产品。
|
||||
|
||||
v3.5 将现有业务后端迁移作为独立实施任务,小程序体验和小规模 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 业务 API;旧版云函数只在过渡期转发同一业务实现。上层页面不感知数据库与文件存储实现。
|
||||
|
||||
## 5. 后端演进路径
|
||||
|
||||
### 阶段 A:现有业务后端迁移
|
||||
|
||||
- 按专项方案迁移现有云函数、数据库、文件和定时任务到自有腾讯云服务器 + MySQL + 七牛。
|
||||
- 新版小程序经业务服务层调用统一 HTTP API;短期兼容桥照顾旧正式版本,只有一个业务写入源。
|
||||
- 先保持现有生成与保存行为,再验收数据关联、文件、计数和切换;不同时重写所有绘图代码。
|
||||
|
||||
### 阶段 B:服务端生成样板
|
||||
|
||||
- 在同一后端增加受控 Render API,以创意涂色或一个简单现有题型验证生成、字体、延迟和文件交付。
|
||||
- 原始素材和模板采用私有存储;最终文件通过受控地址交付,不依赖隐藏对象路径保护素材。
|
||||
- 根据实际任务量增加缓存/异步处理,不预先搭建独立队列平台。
|
||||
|
||||
### 阶段 C:Web 小规模试点与扩张
|
||||
|
||||
- Web 复用统一 API 和内容源,内容页面使用适当的预生成或服务端渲染,生成规则留在服务器。
|
||||
- 只建设下节规定的 MVP;旧题型按价值与实际适配成本逐步接入,批量 PDF/会员另行评估。
|
||||
- 私有生成进程可按资源需求拆分,但不另建用户、收藏、内容数据库或第二套业务规则。
|
||||
|
||||
## 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,137 @@
|
||||
# Doodle Mini v3.5 待办与里程碑
|
||||
|
||||
> 状态:待排期
|
||||
>
|
||||
> 更新日期:2026-10-12
|
||||
|
||||
## 0. 排期原则
|
||||
|
||||
按依赖关系推进:**先修复与可发现性 → 再验证涂色内容 → 再形成学习计划 → 同时建设小规模 Web/服务端样板。**
|
||||
|
||||
以下周期为相对迭代,不绑定具体人天;每个里程碑完成后应先检查数据,再决定是否扩大范围。
|
||||
|
||||
现有业务后端已决定迁移到自有云服务器。该专项按下列 S0–S5 执行,业务后端迁移与存量 Canvas 重写分别排期。详细清单、工时假设及切换步骤见[云服务端迁移方案(doodle-store)](../../../doodle-store/docs/云服务端迁移方案.md)。
|
||||
|
||||
## 服务端迁移专项(S0–S5)
|
||||
|
||||
- [ ] S0:盘点实际线上集合、云文件、服务器、域名和权限,保留原始备份及功能样本。
|
||||
- [ ] S1:完成统一 API/数据库骨架、微信登录、题型读取、收藏/下载幂等事务。
|
||||
- [ ] S2:补齐现有管理接口、首页配置/推荐、七牛文件上传与定时任务。
|
||||
- [ ] S3:完成小程序 HTTP 接入、试迁审计、旧版兼容桥、恢复演练和体验版真机验收。
|
||||
- [ ] S4:冻结旧写入,最终导入后切换唯一写入源,再发布新版并核对线上行为。
|
||||
- [ ] S5:旧版及旧文件依赖达到退场条件后,归档并完全退出 CloudBase。
|
||||
|
||||
服务端生成样板在 S3 验收基础上单独实施;完整 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` 和平台无关元数据作为单一内容源 |
|
||||
| 过早会员化影响首次体验 | 先验证学习包和批量下载需求;单张基础内容保持可试用 |
|
||||
@@ -1,5 +1,7 @@
|
||||
# Doodle Mini — 后端部署方案(NestJS + 自有云服务器)
|
||||
|
||||
> **2026-10-12 决策更新**:已确认迁移到自有腾讯云服务器。当前实施请使用 [云服务端迁移方案(doodle-store)](../../doodle-store/docs/云服务端迁移方案.md)。本文保留为历史技术预案,其中版本、数据模型与本地文件方案不可直接当作当前实施规格;“零增量成本/零成本 CDN”不作为成本事实。
|
||||
|
||||
> 版本:v1.0
|
||||
> 最后更新:2026-03-27
|
||||
> **定位**:**远期 / 阶段到期后的迁移方案**(自有服务器 + NestJS)。
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Doodle Mini — 小程序云开发方案
|
||||
|
||||
> **2026-10-12 决策更新**:本文用于了解现网 CloudBase 和历史数据设计。已确认迁移到自有云服务器,实施以 [云服务端迁移方案(doodle-store)](../../doodle-store/docs/云服务端迁移方案.md) 为准。迁移集合和字段以实际代码及线上盘点为准,不直接按本文的设计集合导入。
|
||||
|
||||
> 版本:v1.1
|
||||
> 最后更新:2026-03-27
|
||||
> **适用范围**:与 [技术架构设计文档](./技术架构设计文档.md) 配套;**当前阶段**采用本文档所述微信云开发环境实现后端能力。
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
> 最后更新:2026-03-27
|
||||
> 配套文档:[现有功能清单](./现有功能清单.md) | [产品设计文档](./产品设计文档.md) | [小程序云开发方案](./小程序云开发方案.md) | [后端部署方案](./后端部署方案.md) | [PC-Web端技术预案](./PC-Web端技术预案.md)
|
||||
|
||||
**后端阶段说明(重要)**:**现阶段**后端能力采用**微信小程序云开发**(云数据库、云存储、云函数),与 [小程序云开发方案](./小程序云开发方案.md) 一致。**当前阶段有效期至 2026 年 9 月 15 日**(到期前需结合用量、成本与产品路线,决定续用云开发、升级套餐或迁移至 [后端部署方案](./后端部署方案.md) 所述 NestJS + 自有服务器方案)。前端仍通过 `platform/cloud-adapter` 对接 `wx.cloud`,迁移时再抽象为 HTTP 适配层。
|
||||
**后端阶段说明(2026-10-12 更新)**:现网仍使用微信云开发,已确认迁移到自有腾讯云服务器 + 统一 API + MySQL + 七牛。实施、兼容与退场以 [云服务端迁移方案(doodle-store)](../../doodle-store/docs/云服务端迁移方案.md) 为准;下文旧阶段、免费额度及未来方案描述作为历史设计参考。当前云调用散落在多个工具和页面,不能仅修改 `platform/cloud-adapter` 完成迁移。存量小程序 Canvas 绘制与本次业务后端迁移分别实施。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ type SharePathData = {
|
||||
functionId?: string;
|
||||
worksheetId?: string;
|
||||
currentMode?: string;
|
||||
pageTitle?: string;
|
||||
};
|
||||
|
||||
type SharePathPage = {
|
||||
@@ -143,9 +144,13 @@ export function getAppShareAppMessage(
|
||||
trackerLabel?: string,
|
||||
page?: SharePathPage,
|
||||
) {
|
||||
if (trackerLabel) {
|
||||
tracker.reportShare(trackerLabel);
|
||||
}
|
||||
tracker.reportShare(page?.route || 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 {
|
||||
title: defaultShareConfig.title,
|
||||
path: buildPageSharePath(page),
|
||||
@@ -158,9 +163,13 @@ export function getAppShareTimeline(
|
||||
trackerLabel?: string,
|
||||
page?: SharePathPage,
|
||||
) {
|
||||
if (trackerLabel) {
|
||||
tracker.reportShare(trackerLabel);
|
||||
}
|
||||
tracker.reportShare(page?.route || 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 {
|
||||
title: defaultShareConfig.title,
|
||||
query: buildPageShareTimelineQuery(page),
|
||||
@@ -171,10 +180,10 @@ export function getAppShareTimeline(
|
||||
/** 供 Page({}) 直接展开:统一转发 / 分享到朋友圈(path 为当前页) */
|
||||
export const appSharePageMethods = {
|
||||
onShareAppMessage(this: SharePathPage) {
|
||||
return getAppShareAppMessage(undefined, this);
|
||||
return getAppShareAppMessage(this.data?.pageTitle, this);
|
||||
},
|
||||
onShareTimeline(this: SharePathPage) {
|
||||
return getAppShareTimeline(undefined, this);
|
||||
return getAppShareTimeline(this.data?.pageTitle, this);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -331,6 +340,7 @@ export function getPageCommonMethods(config: PageCommonMethodsConfig = {}) {
|
||||
errorToast: '请先生成内容',
|
||||
trackerName: this.data.pageTitle,
|
||||
trackerMode: this.data.currentMode,
|
||||
trackerPage: this.route,
|
||||
worksheetId,
|
||||
};
|
||||
|
||||
@@ -394,6 +404,7 @@ export function getPageCommonMethods(config: PageCommonMethodsConfig = {}) {
|
||||
errorToast: '请先生成内容',
|
||||
trackerName: this.data.pageTitle,
|
||||
trackerMode: this.data.currentMode,
|
||||
trackerPage: this.route,
|
||||
worksheetId,
|
||||
});
|
||||
}
|
||||
@@ -591,6 +602,11 @@ export function getPageCommonMethods(config: PageCommonMethodsConfig = {}) {
|
||||
});
|
||||
|
||||
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 = {},
|
||||
) {
|
||||
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(如果有)
|
||||
const pageData = pageOptions.data || {};
|
||||
@@ -615,7 +715,7 @@ export function applyPageMixin(
|
||||
// 注意:pageOptions 放在后面,这样页面可以覆盖公共方法
|
||||
const mergedOptions: any = {
|
||||
...commonMethods,
|
||||
...pageOptions,
|
||||
...trackedOptions,
|
||||
// 处理 data 的合并(需要特殊处理,避免覆盖)
|
||||
data: {
|
||||
...pageData,
|
||||
|
||||
@@ -55,6 +55,15 @@ createFocusPage({
|
||||
|
||||
onLoad(options: { id?: string; mode?: string }) {
|
||||
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);
|
||||
if (!result) return;
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
shouldShowShareGuide,
|
||||
shouldUnlockWithAd,
|
||||
} from '../../utils/shareGuide';
|
||||
import tracker from '../../utils/tracker';
|
||||
|
||||
Page({
|
||||
canvas: null as Canvas | null,
|
||||
@@ -20,6 +21,8 @@ Page({
|
||||
timer: null as any,
|
||||
|
||||
data: {
|
||||
worksheetId: 'shape-recognition',
|
||||
pageTitle: '识别图形,涂一涂',
|
||||
shapeList: [] as ShapeCard[],
|
||||
showSelectShapePopup: false,
|
||||
selectedShapeIndex: -1,
|
||||
@@ -31,6 +34,10 @@ Page({
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
tracker.reportPreviewView('focusPages/shape/shape', {
|
||||
worksheet_id: 'shape-recognition',
|
||||
worksheet_title: '识别图形,涂一涂',
|
||||
});
|
||||
// 判断是否在PC端开发工具上运行
|
||||
// const systemInfo = wx.getDeviceInfo();
|
||||
// if (systemInfo.platform === 'devtools') {
|
||||
@@ -213,6 +220,10 @@ Page({
|
||||
* 随机生成6个图形
|
||||
*/
|
||||
refreshShapeCard() {
|
||||
const startedAt = Date.now();
|
||||
tracker.reportGenerateStart('focusPages/shape/shape', {
|
||||
worksheet_id: 'shape-recognition',
|
||||
});
|
||||
// 随机取6个图形
|
||||
const shapesCopy = [...SHAPES];
|
||||
for (let i = shapesCopy.length - 1; i > 0; i--) {
|
||||
@@ -244,6 +255,10 @@ Page({
|
||||
() => {
|
||||
// 绘制canvas
|
||||
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, {
|
||||
errorToast: '请先生成图形',
|
||||
trackerName: '图形涂色',
|
||||
trackerPage: 'focusPages/shape/shape',
|
||||
worksheetId: 'shape-recognition',
|
||||
});
|
||||
},
|
||||
|
||||
@@ -327,6 +344,8 @@ Page({
|
||||
await downloadPrint(this.canvas, {
|
||||
errorToast: '请先生成图形',
|
||||
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',
|
||||
ageBand: age(3, 5),
|
||||
difficulty: 1,
|
||||
path: '/focusPages/focusDraw/focusDraw?id=shape-recognition',
|
||||
path: '/focusPages/shape/shape',
|
||||
available: true,
|
||||
}),
|
||||
item({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { CATEGORY_DATA, type CategoryItem } from './category.data';
|
||||
|
||||
type CategoryTab = {
|
||||
@@ -224,6 +225,7 @@ Page({
|
||||
_defaultCategoryId: 'all',
|
||||
|
||||
onLoad(options: Record<string, string>) {
|
||||
tracker.reportPageView('pages/category/category');
|
||||
if (options.id && options.id !== 'all') {
|
||||
this._defaultCategoryId = options.id;
|
||||
this.setData({ activeCategoryId: options.id });
|
||||
@@ -285,6 +287,9 @@ Page({
|
||||
displayItems: filtered,
|
||||
scrollIntoViewId: '',
|
||||
});
|
||||
tracker.report('category_select', 'pages/category/category', {
|
||||
category_id: id,
|
||||
});
|
||||
},
|
||||
|
||||
onSearchInput(e: WechatMiniprogram.Input) {
|
||||
@@ -319,6 +324,13 @@ Page({
|
||||
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)) {
|
||||
wx.switchTab({ url: path });
|
||||
return;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
parseMiniProgramUrl,
|
||||
stashPendingCategoryTabId,
|
||||
} from '../../utils/index';
|
||||
import tracker from '../../utils/tracker';
|
||||
import {
|
||||
HOME_DAILY_CHECKIN_ITEMS,
|
||||
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 ──
|
||||
|
||||
function skeletonItem(id: string): HomeDisplayItemView {
|
||||
@@ -196,6 +214,7 @@ Page({
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
tracker.reportPageView('pages/home/home');
|
||||
void this.initData();
|
||||
},
|
||||
|
||||
@@ -274,12 +293,14 @@ Page({
|
||||
onTapFeatured(e: WechatMiniprogram.TouchEvent) {
|
||||
const path = e.currentTarget.dataset.path as string | undefined;
|
||||
const title = e.currentTarget.dataset.title as string | undefined;
|
||||
trackWorksheetClick(path, title, 'home_featured');
|
||||
navigateByPath(path, title);
|
||||
},
|
||||
|
||||
onTapHotRecommend(e: WechatMiniprogram.TouchEvent) {
|
||||
const path = e.currentTarget.dataset.path as string | undefined;
|
||||
const title = e.currentTarget.dataset.title as string | undefined;
|
||||
trackWorksheetClick(path, title, 'home_hot');
|
||||
navigateByPath(path, title);
|
||||
},
|
||||
|
||||
@@ -310,6 +331,7 @@ Page({
|
||||
const title =
|
||||
detail.title ||
|
||||
(e.currentTarget.dataset.title as string | undefined);
|
||||
trackWorksheetClick(path, title, 'home_section');
|
||||
navigateByPath(path, title);
|
||||
},
|
||||
|
||||
|
||||
@@ -23,8 +23,12 @@ Page({
|
||||
return;
|
||||
}
|
||||
|
||||
// 统一跳转到 focusDraw 页面
|
||||
// “识别形状”仍使用独立页面;其余题型统一跳转到 focusDraw 页面
|
||||
if (functionItem.page) {
|
||||
if (functionId === 'shape-recognition') {
|
||||
wx.navigateTo({ url: '/focusPages/shape/shape' });
|
||||
return;
|
||||
}
|
||||
let url = `/focusPages/focusDraw/focusDraw?id=${functionId}`;
|
||||
if (functionItem.mode) {
|
||||
url += `&mode=${functionItem.mode}`;
|
||||
|
||||
@@ -23,6 +23,6 @@ export const UNRELEASED_PAGE_ENTRIES: UnreleasedPageEntry[] = [
|
||||
id: 'clock-connect',
|
||||
title: '时钟连线',
|
||||
subtitle: '看钟表连对应时间,认识时刻',
|
||||
path: '/mathPages/clockConnect/clockConnect',
|
||||
path: '/mathPages/clockReading/clockReading?id=clock-connect',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface DownloadLogRecord {
|
||||
_id: string;
|
||||
userId: string;
|
||||
worksheetId: string;
|
||||
exportId?: string;
|
||||
createdAt: string;
|
||||
worksheet?: {
|
||||
_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;
|
||||
try {
|
||||
await getUser();
|
||||
const res = await wx.cloud.callFunction({
|
||||
name: 'userDownloadLogs',
|
||||
data: { action: 'add', worksheetId },
|
||||
data: { action: 'add', worksheetId, exportId },
|
||||
});
|
||||
const result = (res?.result as any) || {};
|
||||
return result.code === 0;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { checkAndSaveImage } from './saveImage';
|
||||
import tracker from './tracker';
|
||||
import { addDownloadLog } from './downloadLogs';
|
||||
import { generateUUID } from './uuid';
|
||||
import {
|
||||
getUnlockMethod,
|
||||
isDownloadLimitEnabled,
|
||||
@@ -131,23 +132,32 @@ function doDownload(
|
||||
errorToast?: string;
|
||||
trackerName?: string;
|
||||
trackerMode?: string;
|
||||
trackerPage?: string;
|
||||
worksheetId?: string;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
if (options.trackerName) {
|
||||
if (options.trackerMode) {
|
||||
tracker.reportDownload(options.trackerName, options.trackerMode);
|
||||
} else {
|
||||
tracker.reportDownload(options.trackerName);
|
||||
}
|
||||
}
|
||||
const exportId = generateUUID();
|
||||
const trackerPage = options.trackerPage || options.trackerName || '';
|
||||
const trackerParams = {
|
||||
export_id: exportId,
|
||||
...(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) {
|
||||
// 只在 wx.saveImageToPhotosAlbum 的 success 回调后进入此分支。
|
||||
recordDownloadSuccess();
|
||||
tracker.reportExportResult(trackerPage, true, trackerParams);
|
||||
if (options.worksheetId) {
|
||||
addDownloadLog(options.worksheetId);
|
||||
await addDownloadLog(options.worksheetId, exportId);
|
||||
}
|
||||
} else {
|
||||
tracker.reportExportResult(trackerPage, false, trackerParams);
|
||||
}
|
||||
return saved;
|
||||
});
|
||||
@@ -163,6 +173,7 @@ export async function downloadPrint(
|
||||
errorToast?: string;
|
||||
trackerName?: string;
|
||||
trackerMode?: string;
|
||||
trackerPage?: string;
|
||||
worksheetId?: string;
|
||||
} = {},
|
||||
): Promise<boolean> {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { getUser } from './auth';
|
||||
import tracker from './tracker';
|
||||
|
||||
function getCurrentPageRoute(): string {
|
||||
const pages = getCurrentPages();
|
||||
return pages[pages.length - 1]?.route || '';
|
||||
}
|
||||
|
||||
export interface FavoriteRecord {
|
||||
_id: string;
|
||||
@@ -31,7 +37,9 @@ export async function addFavorite(worksheetId: string): Promise<boolean> {
|
||||
data: { action: 'add', worksheetId },
|
||||
});
|
||||
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) {
|
||||
console.error('addFavorite failed', e);
|
||||
return false;
|
||||
@@ -49,7 +57,11 @@ export async function removeFavorite(worksheetId: string): Promise<boolean> {
|
||||
data: { action: 'remove', worksheetId },
|
||||
});
|
||||
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) {
|
||||
console.error('removeFavorite failed', e);
|
||||
return false;
|
||||
@@ -133,4 +145,3 @@ export async function batchCheckFavorited(
|
||||
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 页面名称
|
||||
*/
|
||||
reportShare(pageName: string): void {
|
||||
try {
|
||||
const params = this.getCommonEventParams('share_click', pageName);
|
||||
|
||||
this.sendEvent('share_click', params);
|
||||
} catch (error) {
|
||||
console.error('上报分享事件失败:', error);
|
||||
}
|
||||
reportShare(pageName: string, extraParams?: Record<string, any>): void {
|
||||
this.report('share_click', pageName, extraParams);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,15 +213,8 @@ class Tracker {
|
||||
* @param mode 模式(可选)
|
||||
*/
|
||||
reportDownload(pageName: string, mode?: string): void {
|
||||
try {
|
||||
const params = this.getCommonEventParams('download', pageName, {
|
||||
...(mode && { mode }),
|
||||
});
|
||||
|
||||
this.sendEvent('download', params);
|
||||
} catch (error) {
|
||||
console.error('上报下载事件失败:', error);
|
||||
}
|
||||
// 兼容旧调用;新代码请使用 reportExportClick / reportExportResult。
|
||||
this.reportExportClick(pageName, { ...(mode && { mode }) });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user