feat: 首页、分类页加载数据逻辑重构
|
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
@@ -45,7 +45,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,
|
||||||
@@ -141,7 +141,9 @@ async function buildCategoryData(db) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function buildHomeData(db, event) {
|
async function buildHomeData(db, event) {
|
||||||
const featuredIds = Array.isArray(event.featuredIds) ? event.featuredIds : [];
|
const featuredIds = Array.isArray(event.featuredIds)
|
||||||
|
? event.featuredIds
|
||||||
|
: [];
|
||||||
const hotIds = Array.isArray(event.hotIds) ? event.hotIds : [];
|
const hotIds = Array.isArray(event.hotIds) ? event.hotIds : [];
|
||||||
const sectionConfigs = Array.isArray(event.sections) ? event.sections : [];
|
const sectionConfigs = Array.isArray(event.sections) ? event.sections : [];
|
||||||
|
|
||||||
@@ -264,9 +266,7 @@ exports.main = async (event) => {
|
|||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message:
|
message:
|
||||||
error instanceof Error
|
error instanceof Error ? error.message : '生成分类页数据失败',
|
||||||
? error.message
|
|
||||||
: '生成分类页数据失败',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 页面内容管理方案
|
# 页面内容管理方案
|
||||||
|
|
||||||
> 版本:v1.0 | 最后更新:2026-04-29
|
> 版本:v2.0 | 最后更新:2026-04-30
|
||||||
> 配套文档:[Worksheet 发布方案](./Worksheet发布方案.md) | [小程序云开发方案](./小程序云开发方案.md)
|
> 配套文档:[Worksheet 发布方案](./Worksheet发布方案.md) | [小程序云开发方案](./小程序云开发方案.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -9,36 +9,68 @@
|
|||||||
|
|
||||||
worksheet 已可通过 Debug 发布进入云数据库,但三个展示页仍使用本地静态数据:
|
worksheet 已可通过 Debug 发布进入云数据库,但三个展示页仍使用本地静态数据:
|
||||||
|
|
||||||
|
|
||||||
| 页面 | 当前数据来源 | 问题 |
|
| 页面 | 当前数据来源 | 问题 |
|
||||||
| --- | --------------------------------- | ---------------------- |
|
| --- | --------------------------------- | ---------------------- |
|
||||||
| 首页 | `pages/home/home.data.ts` | featured、hot、分区靠代码手动维护 |
|
| 首页 | `pages/home/home.data.ts` | featured、hot、分区靠代码手动维护 |
|
||||||
| 分类页 | `pages/category/category.data.ts` | 新 worksheet 发布后不会自动出现 |
|
| 分类页 | `pages/category/category.data.ts` | 新 worksheet 发布后不会自动出现 |
|
||||||
| 分龄页 | `pages/age/age.ts` 内 Mock | 推荐内容和周路线无法运营配置 |
|
| 分龄页 | `pages/age/age.ts` 内 Mock | 推荐内容和周路线无法运营配置 |
|
||||||
|
|
||||||
|
**目标:** 通过 Debug 管理页配置三个页面的内容,生成统一配置写入云数据库并同步至云存储,小程序启动时通过**数据预拉取**获取并渲染。
|
||||||
**目标:** 通过 Debug 管理页配置三个页面的内容,生成统一 JSON 上传云存储,小程序启动时通过**数据预拉取**获取并渲染。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 二、整体架构
|
## 二、整体架构
|
||||||
|
|
||||||
### 2.1 数据流
|
### 2.1 数据存储与同步
|
||||||
|
|
||||||
|
**数据源(Source of Truth):** 云数据库 `page_configs` 集合,文档 `_id = 'current'`
|
||||||
|
|
||||||
|
**数据交付(Delivery):** 云存储 `content/page-config.json`(供小程序预拉取使用)
|
||||||
|
|
||||||
|
每次更新时,云函数先写数据库,再同步上传至云存储,保证两者一致。各管理页更新时只修改配置中自己负责的字段,不影响其他页面数据。
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// page_configs/current 文档结构
|
||||||
|
type PageConfig = {
|
||||||
|
home: HomePageData; // 首页配置
|
||||||
|
category: CategoryPageData; // 分类页配置
|
||||||
|
age: AgePageData; // 分龄页配置
|
||||||
|
version: number; // 版本号,每次更新递增
|
||||||
|
updatedAt: string; // 最后更新时间
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
读写流程:
|
||||||
|
|
||||||
|
```
|
||||||
|
readCurrentConfig() → 从 page_configs/current 读取当前完整配置
|
||||||
|
config[page] = data → 只覆盖对应页面字段(home / category / age)
|
||||||
|
saveConfig(config) → 写回数据库 → 上传云存储 content/page-config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 数据流
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
│ Debug 内容管理页(三个独立 tab) │
|
│ Debug 内容管理页(独立页面) │
|
||||||
│ │
|
│ │
|
||||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||||
│ │ 首页管理 │ │ 分类页管理 │ │ 分龄页管理 │ │
|
│ │ 首页内容管理 │ │ 分类页内容管理 │ │ 分龄页内容管理 │ │
|
||||||
│ └────┬────┘ └────┬────┘ └────┬────┘ │
|
│ │ homeContent │ │ categoryContent│ │ ageContent │ │
|
||||||
│ │ │ │ │
|
│ │ Manage │ │ Manage │ │ Manage │ │
|
||||||
│ └──────┬──────┘──────┬──────┘ │
|
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
|
||||||
│ ▼ ▼ │
|
│ │ │ │ │
|
||||||
│ 点击「更新」按钮 → 调用云函数 pageContentUpdate │
|
│ └────────┬─────────┘─────────┬─────────┘ │
|
||||||
│ │ │
|
│ ▼ ▼ │
|
||||||
│ ▼ │
|
│ 点击「更新」按钮 → 调用云函数 pageContentBuild │
|
||||||
│ 更新云存储 content/page-config.json 中对应页面的配置 │
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌─────────────────────────────────────────┐ │
|
||||||
|
│ │ 1. 从 page_configs/current 读取当前配置 │ │
|
||||||
|
│ │ 2. 只更新对应页面字段(home/category/age) │ │
|
||||||
|
│ │ 3. version++ → 写回数据库 │ │
|
||||||
|
│ │ 4. 同步上传至云存储 content/page-config.json│ │
|
||||||
|
│ └─────────────────────────────────────────┘ │
|
||||||
└──────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
▼
|
▼
|
||||||
@@ -47,122 +79,194 @@ worksheet 已可通过 Debug 发布进入云数据库,但三个展示页仍使
|
|||||||
│ 小程序启动 │
|
│ 小程序启动 │
|
||||||
│ │
|
│ │
|
||||||
│ app.onLaunch │
|
│ app.onLaunch │
|
||||||
│ └─ 数据预拉取 → 获取 content/page-config.json │
|
│ └─ wx.getBackgroundFetchData('pre') 获取预拉取结果 │
|
||||||
|
│ ├─ 成功 → 存入 globalData + storage 缓存 │
|
||||||
|
│ └─ 失败 → 读取 storage 缓存 │
|
||||||
|
│ ├─ 命中 → 存入 globalData │
|
||||||
|
│ └─ 未命中 → 调用 pageConfigFetch 云函数兜底 │
|
||||||
|
│ └─ 成功 → 存入 globalData + storage │
|
||||||
│ │
|
│ │
|
||||||
│ 页面 onLoad │
|
│ TabBar 页面 onLoad(首页 / 分类页 / 分龄页) │
|
||||||
│ ├─ 1. 使用本地 *.data.ts / age.config.ts 渲染兜底首屏 │
|
│ ├─ 配置已就绪 → 从 globalData.pageConfig 读取对应字段 → 渲染 │
|
||||||
│ ├─ 2. 读取预拉取结果中对应页面的配置 │
|
│ └─ 配置未就绪 → 显示加载态 → 监听配置就绪回调 → 渲染 │
|
||||||
│ └─ 3. 成功则 setData 更新页面 │
|
│ │
|
||||||
|
│ TabBar 页面 onShow(后续切换) │
|
||||||
|
│ └─ 直接从 globalData.pageConfig 读取,零延迟 │
|
||||||
└──────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.2 统一 JSON 格式
|
### 2.3 数据预拉取
|
||||||
|
|
||||||
云存储路径:`content/page-config.json`
|
小程序数据预拉取通过云函数 `pageConfigFetch` 实现:
|
||||||
|
|
||||||
```ts
|
- 在 `app.json` 中配置 `fetchDataUrl` 指向 `pageConfigFetch` 云函数
|
||||||
// content/page-config.json 结构
|
- 小程序冷启动时由微信客户端自动调用,不占用页面加载时间
|
||||||
type PageConfig = {
|
- 云函数从 `page_configs/current` 读取完整配置并返回
|
||||||
home: HomePageData; // 首页配置
|
|
||||||
category: CategoryPageData; // 分类页配置
|
```js
|
||||||
age: AgePageData; // 分龄页配置
|
// cloudfunctions/pageConfigFetch/index.js
|
||||||
version: number; // 版本号,每次更新递增
|
exports.main = async () => {
|
||||||
updatedAt: string; // 最后更新时间
|
const db = cloud.database();
|
||||||
|
const { data } = await db.collection('page_configs').doc('current').get();
|
||||||
|
return { success: true, data };
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
每个管理页更新时,只修改 JSON 中自己负责的字段,通过云函数读取当前 JSON → 合并更新 → 写回云存储。
|
### 2.4 页面加载策略
|
||||||
|
|
||||||
### 2.3 页面加载策略
|
三个展示页(首页、分类页、分龄页)均为 TabBar 页面,不再使用本地 `.data.ts` 静态数据渲染,改为**统一从内存读取预拉取配置**,并通过 storage 缓存保证离线可用:
|
||||||
|
|
||||||
三个页面统一采用**数据预拉取 + 本地兜底**的加载策略:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
页面 onLoad
|
app.onLaunch
|
||||||
│
|
│
|
||||||
├─ Step 1: 使用本地静态数据渲染首屏(零延迟)
|
├─ Step 1: wx.getBackgroundFetchData('pre') 获取预拉取结果
|
||||||
│ 首页 → home.data.ts
|
│ 成功 → 存入 globalData.pageConfig
|
||||||
│ 分类页 → category.data.ts
|
│ → wx.setStorageSync('pageConfig', data) 写入缓存
|
||||||
│ 分龄页 → age.config.ts
|
│ → 通知页面就绪
|
||||||
│
|
│
|
||||||
├─ Step 2: 读取数据预拉取结果
|
├─ Step 2: 预拉取失败 → wx.getStorageSync('pageConfig') 读取缓存
|
||||||
│ wx.getBackgroundFetchData('pre') 获取 page-config.json
|
│ 命中 → 存入 globalData.pageConfig → 通知页面就绪
|
||||||
│ 取出当前页面对应的配置(home / category / age)
|
|
||||||
│
|
│
|
||||||
└─ Step 3: 成功 → setData 更新页面内容
|
└─ Step 3: 缓存也为空 → 调用 pageConfigFetch 云函数兜底
|
||||||
失败 → 保持本地兜底数据,用户无感知
|
成功 → 存入 globalData.pageConfig
|
||||||
|
→ wx.setStorageSync('pageConfig', data) 写入缓存
|
||||||
|
→ 通知页面就绪
|
||||||
|
|
||||||
|
TabBar 页面 onLoad(仅首次进入触发)
|
||||||
|
│
|
||||||
|
├─ globalData.pageConfig 已就绪
|
||||||
|
│ → 读取对应字段(home / category / age)→ setData 渲染
|
||||||
|
│
|
||||||
|
└─ globalData.pageConfig 未就绪
|
||||||
|
→ 显示加载态(骨架屏 / loading)
|
||||||
|
→ 注册回调,配置就绪后自动渲染
|
||||||
|
|
||||||
|
TabBar 页面 onShow(后续每次切换触发)
|
||||||
|
│
|
||||||
|
└─ 直接从 globalData.pageConfig 读取,零延迟,无网络请求
|
||||||
```
|
```
|
||||||
|
|
||||||
> **数据预拉取**在 `app.json` 中配置 `fetchDataUrl`,小程序冷启动时由微信客户端自动发起,不占用页面加载时间。预拉取的数据指向云存储中的 `content/page-config.json`。
|
**三级降级策略:** 预拉取 → storage 缓存 → 云函数调用
|
||||||
|
|
||||||
### 2.4 云函数设计
|
| 层级 | 数据来源 | 耗时 | 适用场景 |
|
||||||
|
| --- | ----------------------------------- | ------- | -------------------- |
|
||||||
|
| L1 | `wx.getBackgroundFetchData('pre')` | 0ms | 正常冷启动,预拉取已完成 |
|
||||||
|
| L2 | `wx.getStorageSync('pageConfig')` | ~1ms | 预拉取失败,但之前成功过 |
|
||||||
|
| L3 | `pageConfigFetch` 云函数 | 200-500ms | 首次使用或缓存被清理 |
|
||||||
|
|
||||||
新增一个统一云函数 `pageContentUpdate`:
|
**设计要点:**
|
||||||
|
|
||||||
```ts
|
- 配置在 `app.onLaunch` 时加载一次,存入 `globalData.pageConfig`,三个页面共享同一份内存数据
|
||||||
// cloudfunctions/pageContentUpdate/index.js
|
- 每次预拉取或云函数获取成功后,同步写入 storage 作为下次启动的缓存兜底
|
||||||
type PageContentUpdatePayload = {
|
- TabBar 页面 `onLoad` 只执行一次,`onShow` 每次切换都触发,天然适合「启动时加载一次,后续复用」的模式
|
||||||
page: 'home' | 'category' | 'age'; // 要更新的页面
|
- 不再维护 `home.data.ts`、`category.data.ts` 等本地静态数据文件,消除双数据源不一致的问题
|
||||||
data: HomePageData | CategoryPageData | AgePageData; // 该页面的完整配置
|
- storage 缓存的 key 为 `pageConfig`,使用 `wx.setStorageSync` / `wx.getStorageSync` 同步读写
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
执行逻辑:
|
### 2.5 云函数总览
|
||||||
|
|
||||||
```
|
| 云函数 | 职责 |
|
||||||
接收参数 { page, data }
|
| ---------------------- | ------------------------------------------------- |
|
||||||
→ 从云存储读取当前 content/page-config.json
|
| `pageContentBuild` | 生成指定页面配置(home / category),写入数据库 + 云存储 |
|
||||||
→ 合并:config[page] = data
|
| `pageConfigFetch` | 数据预拉取接口,从数据库读取完整配置返回给客户端 |
|
||||||
→ config.version++
|
| `homeAutoRefresh` | 定时任务,每日 22:00 自动更新 featured 和 hot |
|
||||||
→ config.updatedAt = new Date()
|
| `homeTimerControl` | 暂停/启动首页定时刷新任务(更新 settings 集合标记位) |
|
||||||
→ 写回云存储 content/page-config.json
|
| `worksheetsQuery` | 查询 worksheet 列表(支持按分类、状态筛选) |
|
||||||
→ 返回 { success, version }
|
| `worksheetsUpdateStatus` | 更新单个 worksheet 的状态 |
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 三、首页内容管理
|
## 三、首页内容管理
|
||||||
|
|
||||||
### 3.1 管理 UI
|
### 3.1 管理页入口
|
||||||
|
|
||||||
首页管理页分为以下区块:
|
`supportPages/homeContentManage/homeContentManage`
|
||||||
|
|
||||||
|
进入页面时并行调用两个云函数加载数据:
|
||||||
|
|
||||||
| 区块 | 对应字段 | 管理方式 |
|
```
|
||||||
|
onLoad
|
||||||
|
→ Promise.all([
|
||||||
|
worksheetsQuery({ status: 'active' }), // 获取所有 active worksheet
|
||||||
|
pageConfigFetch() // 获取当前已保存的配置
|
||||||
|
])
|
||||||
|
→ featured: 始终按 downloads desc 取前 5 自动填充
|
||||||
|
→ hot: 始终按 likes desc 取前 5 自动填充
|
||||||
|
→ sections: 从 config.home.sections 回显已保存的分类分区数据
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 管理 UI
|
||||||
|
|
||||||
|
| 区块 | 对应字段 | 数据来源 |
|
||||||
| ------ | -------------- | ----------------------------------- |
|
| ------ | -------------- | ----------------------------------- |
|
||||||
| 分类 tab | `categoryTabs` | 跟随 `CATEGORY_LIST_WITH_ALL`,一般不单独管理 |
|
| 定时任务卡片 | — | 显示定时任务状态(运行中/已暂停),可切换 |
|
||||||
| 年龄入口 | `ageBands` | 跟随 `AGE_BANDS`,可管理描述文案 |
|
| 今日推荐 | `featured` | 自动:downloads 最多的 5 个 active worksheet |
|
||||||
| 今日推荐 | `featured` | 自动取 downloads 最多的 5 个 active worksheet,支持手动微调 |
|
| 热门推荐 | `hot` | 自动:likes 最多的 5 个 active worksheet |
|
||||||
| 热门推荐 | `hot` | 自动取 likes 最多的 5 个 active worksheet,支持手动微调 |
|
| 分类分区 | `sections` | 从已保存配置回显,支持手动添加/移除 worksheet |
|
||||||
| 分类分区 | `sections` | 每个分类选择若干 active worksheet,支持排序 |
|
|
||||||
|
|
||||||
|
### 3.3 数据回显逻辑
|
||||||
|
|
||||||
### 3.2 自动刷新与定时任务
|
**featured / hot(自动填充,不回显历史配置):**
|
||||||
|
|
||||||
|
每次进入管理页,始终从当前 active worksheets 中实时计算:
|
||||||
|
- featured = 按 `downloads` 降序取前 5
|
||||||
|
- hot = 按 `likes` 降序取前 5
|
||||||
|
|
||||||
|
用户可在自动填充基础上手动添加/移除,点击「更新首页数据」后生效。
|
||||||
|
|
||||||
|
**sections(从配置回显):**
|
||||||
|
|
||||||
|
从 `pageConfigFetch` 返回的 `home.sections` 中恢复已保存的分区数据:
|
||||||
|
|
||||||
|
```
|
||||||
|
CATEGORY_LIST.map(category => {
|
||||||
|
saved = savedSectionMap.get(category.id)
|
||||||
|
items = saved.items → 通过 wsMap 还原为完整 worksheet 对象
|
||||||
|
morePath = category.path // 从 CATEGORY_LIST 获取,如 /pages/category/category?id=math
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- `name` / `icon` 始终从本地 `CATEGORY_LIST` 取(保证最新)
|
||||||
|
- `morePath` 从 `CATEGORY_LIST` 的 `path` 字段获取,指向对应分类页并自动选中该分类
|
||||||
|
- `items` 通过 worksheet id 从 active worksheets 中匹配还原,已下架的 worksheet 自动过滤
|
||||||
|
|
||||||
|
### 3.4 自动刷新与定时任务
|
||||||
|
|
||||||
今日推荐和热门推荐采用**自动化 + 手动微调**的方式:
|
今日推荐和热门推荐采用**自动化 + 手动微调**的方式:
|
||||||
|
|
||||||
- **默认数据**:页面加载时自动查询 active worksheets,按 `downloads desc` 取前 5 填入 featured,按 `likes desc` 取前 5 填入 hot
|
|
||||||
- **定时任务**:云函数 `homeAutoRefresh` 配置定时触发器,每日 22:00 自动执行,查询最新 top5 数据更新 `page-config.json` 中的 `home.featured` 和 `home.hot`
|
- **定时任务**:云函数 `homeAutoRefresh` 配置定时触发器,每日 22:00 自动执行,查询最新 top5 数据更新 `page-config.json` 中的 `home.featured` 和 `home.hot`
|
||||||
- **开关控制**:通过 `settings` 集合中 `homeAutoRefresh` 文档的 `enabled` 字段控制。管理页提供暂停/启动按钮,调用 `homeTimerControl` 云函数切换状态。`homeAutoRefresh` 执行时先检查此标记,`enabled === false` 则跳过
|
- **开关控制**:通过 `settings` 集合中 `homeAutoRefresh` 文档的 `enabled` 字段控制。管理页提供暂停/启动按钮,调用 `homeTimerControl` 云函数切换状态。`homeAutoRefresh` 执行时先检查此标记,`enabled === false` 则跳过
|
||||||
- **手动微调**:用户可在自动填充的基础上手动添加/移除 worksheet,点击「更新首页数据」手动生成
|
- **手动更新**:用户可在管理页手动添加/移除 worksheet,点击「更新首页数据」调用 `pageContentBuild({ page: 'home', ... })` 生成
|
||||||
|
|
||||||
```
|
```
|
||||||
管理页 onLoad
|
|
||||||
→ 查询 active worksheets
|
|
||||||
→ 按 downloads desc 取前 5 → featured
|
|
||||||
→ 按 likes desc 取前 5 → hot
|
|
||||||
→ 用户可手动微调
|
|
||||||
→ 点击「更新首页数据」→ pageContentBuild({ page: 'home', ... })
|
|
||||||
|
|
||||||
定时任务(每日 22:00)
|
定时任务(每日 22:00)
|
||||||
→ homeAutoRefresh 云函数
|
→ homeAutoRefresh 云函数
|
||||||
→ 检查 settings/homeAutoRefresh.enabled
|
→ 检查 settings/homeAutoRefresh.enabled
|
||||||
|
→ enabled === false → 跳过执行
|
||||||
→ 查询 top5 downloads → featured
|
→ 查询 top5 downloads → featured
|
||||||
→ 查询 top5 likes → hot
|
→ 查询 top5 likes → hot
|
||||||
→ 更新 page-config.json 中 home.featured 和 home.hot
|
→ readCurrentConfig() → 只更新 home.featured 和 home.hot → saveConfig()
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.3 数据结构
|
### 3.5 更新首页数据流程
|
||||||
|
|
||||||
|
```
|
||||||
|
点击「更新首页数据」
|
||||||
|
→ pageContentBuild({
|
||||||
|
page: 'home',
|
||||||
|
featuredIds: [...],
|
||||||
|
hotIds: [...],
|
||||||
|
sections: [
|
||||||
|
{ id: 'math', subtitle: '', morePath: '/pages/category/category?id=math', worksheetIds: [...] },
|
||||||
|
{ id: 'english', subtitle: '', morePath: '/pages/category/category?id=english', worksheetIds: [...] },
|
||||||
|
...
|
||||||
|
]
|
||||||
|
})
|
||||||
|
→ 云函数批量查询所有引用的 worksheet
|
||||||
|
→ 构建 home 配置(categoryTabs + ageBands + featured + hot + sections)
|
||||||
|
→ readCurrentConfig() → config.home = homeData → saveConfig()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.6 数据结构
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
type HomePageData = {
|
type HomePageData = {
|
||||||
@@ -173,41 +277,38 @@ type HomePageData = {
|
|||||||
hot: HomeDisplayItem[];
|
hot: HomeDisplayItem[];
|
||||||
sections: HomeDisplaySection[];
|
sections: HomeDisplaySection[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type HomeDisplaySection = {
|
||||||
|
id: string; // 分类 id
|
||||||
|
title: string; // 分类名称
|
||||||
|
subtitle: string;
|
||||||
|
morePath: string; // 跳转路径,如 /pages/category/category?id=math
|
||||||
|
items: HomeDisplayItem[];
|
||||||
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
自动生成规则:
|
| 模块 | 生成规则 |
|
||||||
|
|
||||||
|
|
||||||
| 模块 | 默认规则 |
|
|
||||||
| -------------- | ---------------------------------------------- |
|
| -------------- | ---------------------------------------------- |
|
||||||
| `categoryTabs` | 由 `CATEGORY_LIST_WITH_ALL` 生成 |
|
| `categoryTabs` | 由 `CATEGORY_LIST_WITH_ALL` 生成 |
|
||||||
| `ageBands` | 由 `AGE_BANDS` 生成 |
|
| `ageBands` | 由 `AGE_BANDS` 生成 |
|
||||||
| `featured` | 自动:downloads 最多的 5 个 active worksheet |
|
| `featured` | 自动:downloads 最多的 5 个 active worksheet |
|
||||||
| `hot` | 自动:likes 最多的 5 个 active worksheet |
|
| `hot` | 自动:likes 最多的 5 个 active worksheet |
|
||||||
| `sections` | 使用配置中的 worksheetIds,不足时从该分类 active 内容补齐 |
|
| `sections` | 管理页配置的 worksheetIds,morePath 来自 CATEGORY_LIST |
|
||||||
|
|
||||||
|
|
||||||
### 3.4 相关云函数
|
|
||||||
|
|
||||||
|
|
||||||
| 云函数 | 职责 |
|
|
||||||
| ------------------- | ------------------------------------------- |
|
|
||||||
| `pageContentBuild` | 手动更新首页全量数据(featured + hot + sections) |
|
|
||||||
| `homeAutoRefresh` | 定时任务,每日 22:00 自动更新 featured 和 hot |
|
|
||||||
| `homeTimerControl` | 暂停/启动定时任务(更新 settings 集合标记位) |
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 四、分类页内容管理
|
## 四、分类页内容管理
|
||||||
|
|
||||||
### 4.1 管理 UI
|
### 4.1 管理页入口
|
||||||
|
|
||||||
分类页管理 tab 顶部展示分类选择器(来源 `CATEGORY_LIST`),默认选中 `math`。
|
`supportPages/categoryContentManage/categoryContentManage`
|
||||||
|
|
||||||
|
### 4.2 管理 UI
|
||||||
|
|
||||||
|
分类页管理顶部展示分类选择器(来源 `CATEGORY_LIST`),默认选中 `math`。
|
||||||
|
|
||||||
分类头部统计:
|
分类头部统计:
|
||||||
|
|
||||||
|
|
||||||
| 指标 | 查询规则 |
|
| 指标 | 查询规则 |
|
||||||
| --- | ----------------------------- |
|
| --- | ----------------------------- |
|
||||||
| 总数 | `worksheets.category == 当前分类` |
|
| 总数 | `worksheets.category == 当前分类` |
|
||||||
@@ -215,21 +316,29 @@ type HomePageData = {
|
|||||||
| 线上 | `status == active` |
|
| 线上 | `status == active` |
|
||||||
| 已隐藏 | `status == hidden` |
|
| 已隐藏 | `status == hidden` |
|
||||||
|
|
||||||
|
列表展示当前分类下所有 worksheet(按 `draft → hidden → active` 排序),每项显示预览图、标题、ID、年龄/难度/标签、状态、操作按钮。
|
||||||
列表展示当前分类下所有 worksheet(按 `status` → `sortOrder` → `updatedAt` 排序),每项显示预览图、标题、ID、年龄/难度/标签、状态、排序权重。
|
|
||||||
|
|
||||||
操作按钮:
|
操作按钮:
|
||||||
|
|
||||||
|
| 当前状态 | 按钮样式 | 可执行操作 |
|
||||||
|
| -------- | ------- | --------------- |
|
||||||
|
| `draft` | 绿色按钮 | 激活上线 |
|
||||||
|
| `active` | 灰色按钮 | 下架为 hidden |
|
||||||
|
| `hidden` | 黄色按钮 | 恢复为 draft 或直接激活 |
|
||||||
|
|
||||||
| 当前状态 | 可执行操作 |
|
### 4.3 分类页支持 id 参数
|
||||||
| -------- | --------------- |
|
|
||||||
| `draft` | 激活上线 |
|
|
||||||
| `active` | 下架为 hidden |
|
|
||||||
| `hidden` | 恢复为 draft 或直接激活 |
|
|
||||||
| 任意 | 调整排序、预览跳转、刷新 |
|
|
||||||
|
|
||||||
|
分类页 `/pages/category/category` 支持通过 URL 参数 `id` 指定默认选中的分类:
|
||||||
|
|
||||||
### 4.2 数据结构
|
```
|
||||||
|
/pages/category/category?id=english → 默认选中「英语启蒙」
|
||||||
|
/pages/category/category?id=math → 默认选中「数感启蒙」
|
||||||
|
/pages/category/category → 默认选中「全部」
|
||||||
|
```
|
||||||
|
|
||||||
|
首页 sections 的「查看更多」链接使用此参数跳转到对应分类。
|
||||||
|
|
||||||
|
### 4.4 数据结构
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
type CategoryPageData = {
|
type CategoryPageData = {
|
||||||
@@ -245,14 +354,14 @@ type CategoryPageData = {
|
|||||||
|
|
||||||
生成时从 `worksheets` 集合查询 `status == active` 的内容,按分类分组、按 `sortOrder` 排序,转换为 `CategoryItem`。
|
生成时从 `worksheets` 集合查询 `status == active` 的内容,按分类分组、按 `sortOrder` 排序,转换为 `CategoryItem`。
|
||||||
|
|
||||||
### 4.3 更新流程
|
### 4.5 更新流程
|
||||||
|
|
||||||
```
|
```
|
||||||
分类页管理 tab → 管理 worksheet 状态和排序
|
分类页管理 → 管理 worksheet 状态和排序
|
||||||
→ 点击「更新分类数据」
|
→ 点击「更新分类数据」
|
||||||
→ 查询所有 active worksheets,按 category 分组
|
→ 查询所有 active worksheets,按 category 分组
|
||||||
→ 调用 pageContentUpdate({ page: 'category', data: ... })
|
→ 调用 pageContentBuild({ page: 'category' })
|
||||||
→ 云函数更新 page-config.json 中的 category 字段
|
→ 云函数更新 page_configs/current 中的 category 字段 + 同步云存储
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -264,23 +373,6 @@ type CategoryPageData = {
|
|||||||
在 `miniprogram/pages/age/` 下新增 `age.config.ts`,将年龄段划分、能力目标、默认 worksheet 写死在配置中,作为兜底数据和管理页的基础结构。
|
在 `miniprogram/pages/age/` 下新增 `age.config.ts`,将年龄段划分、能力目标、默认 worksheet 写死在配置中,作为兜底数据和管理页的基础结构。
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// miniprogram/pages/age/age.config.ts
|
|
||||||
|
|
||||||
import { AGE_BANDS, type AgeBandKey } from '../../core/data/difficulty';
|
|
||||||
|
|
||||||
export type AbilityItem = {
|
|
||||||
icon: string;
|
|
||||||
title: string;
|
|
||||||
desc: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WeekPlan = {
|
|
||||||
week: number;
|
|
||||||
theme: string;
|
|
||||||
worksheetIds: string[]; // worksheet 业务 ID
|
|
||||||
worksheetTitles: string[]; // 兜底展示标题
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AgeBandConfig = {
|
export type AgeBandConfig = {
|
||||||
key: AgeBandKey;
|
key: AgeBandKey;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -288,61 +380,13 @@ export type AgeBandConfig = {
|
|||||||
abilities: AbilityItem[];
|
abilities: AbilityItem[];
|
||||||
weeks: WeekPlan[];
|
weeks: WeekPlan[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AGE_CONFIG: AgeBandConfig[] = [
|
|
||||||
{
|
|
||||||
key: '3-4',
|
|
||||||
label: '3-4 岁',
|
|
||||||
subLabel: '启蒙认知',
|
|
||||||
abilities: [
|
|
||||||
{ icon: '🔢', title: '数感', desc: '认读 1-5、点数对应' },
|
|
||||||
{ icon: '✏️', title: '书写', desc: '涂鸦线条、简单描红' },
|
|
||||||
{ icon: '🧩', title: '思维', desc: '找相同、简单配对' },
|
|
||||||
],
|
|
||||||
weeks: [
|
|
||||||
{ week: 1, theme: '数感启蒙',
|
|
||||||
worksheetIds: [], worksheetTitles: ['找数字涂一涂', '5以内加法', '数数连一连'] },
|
|
||||||
{ week: 2, theme: '形状与连线',
|
|
||||||
worksheetIds: [], worksheetTitles: ['识别形状', '线条识别', '数字点连线'] },
|
|
||||||
{ week: 3, theme: '趣味专注',
|
|
||||||
worksheetIds: [], worksheetTitles: ['颜色找规律', '方格推理', '格子仿画'] },
|
|
||||||
{ week: 4, theme: '综合练习',
|
|
||||||
worksheetIds: [], worksheetTitles: ['10以内加减法', '识字卡', '连连看'] },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '4-5',
|
|
||||||
label: '4-5 岁',
|
|
||||||
subLabel: '基础练习',
|
|
||||||
abilities: [
|
|
||||||
{ icon: '🔢', title: '数感', desc: '10 以内数数、比大小' },
|
|
||||||
{ icon: '✏️', title: '书写', desc: '笔画模仿、图形描边' },
|
|
||||||
{ icon: '🧩', title: '思维', desc: '规律排序、图形分类' },
|
|
||||||
],
|
|
||||||
weeks: [
|
|
||||||
{ week: 1, theme: '数感启蒙',
|
|
||||||
worksheetIds: [], worksheetTitles: ['找数字涂一涂', '5以内加法', '数数连一连'] },
|
|
||||||
{ week: 2, theme: '形状与连线',
|
|
||||||
worksheetIds: [], worksheetTitles: ['识别形状', '线条识别', '数字点连线'] },
|
|
||||||
{ week: 3, theme: '趣味专注',
|
|
||||||
worksheetIds: [], worksheetTitles: ['颜色找规律', '方格推理', '格子仿画'] },
|
|
||||||
{ week: 4, theme: '综合练习',
|
|
||||||
worksheetIds: [], worksheetTitles: ['10以内加减法', '识字卡', '连连看'] },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// 5-6、6-7、7-8 结构相同,abilities 和 weeks 内容不同
|
|
||||||
// ... 完整配置见 age.config.ts 文件
|
|
||||||
];
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> `worksheetIds` 初始为空数组,由分龄管理页选择后填入。`worksheetTitles` 作为兜底展示。
|
> `worksheetIds` 初始为空数组,由分龄管理页选择后填入。`worksheetTitles` 作为兜底展示。
|
||||||
|
|
||||||
### 5.2 管理 UI
|
### 5.2 管理 UI
|
||||||
|
|
||||||
分龄管理 tab 按年龄段切换:`3-4 岁 | 4-5 岁 | 5-6 岁 | 6-7 岁 | 7-8 岁`
|
分龄管理按年龄段切换:`3-4 岁 | 4-5 岁 | 5-6 岁 | 6-7 岁 | 7-8 岁`
|
||||||
|
|
||||||
每个年龄段下的管理内容:
|
|
||||||
|
|
||||||
|
|
||||||
| 模块 | 操作 | 说明 |
|
| 模块 | 操作 | 说明 |
|
||||||
| ---- | ------------ | -------------------------------- |
|
| ---- | ------------ | -------------------------------- |
|
||||||
@@ -350,59 +394,14 @@ export const AGE_CONFIG: AgeBandConfig[] = [
|
|||||||
| 四周路线 | 选择 worksheet | 每周选择 4 个 worksheet,主题来自 config |
|
| 四周路线 | 选择 worksheet | 每周选择 4 个 worksheet,主题来自 config |
|
||||||
| 推荐内容 | 选择 worksheet | 动态数据,展示 likes 数量最高的 6 个worksheet |
|
| 推荐内容 | 选择 worksheet | 动态数据,展示 likes 数量最高的 6 个worksheet |
|
||||||
|
|
||||||
|
### 5.3 更新流程
|
||||||
worksheet 选择器默认筛选条件:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
status == active
|
分龄管理 → 选择年龄段 → 为每周选择 worksheet → 选择推荐内容
|
||||||
ageMin <= 当前年龄段 maxAge
|
|
||||||
ageMax >= 当前年龄段 minAge
|
|
||||||
```
|
|
||||||
|
|
||||||
**核心简化:** 分龄管理页不需要配置能力目标和周主题(这些写死在 `age.config.ts` 中),只需要为不同年龄段、不同周选择对应的 worksheet。
|
|
||||||
|
|
||||||
### 5.3 数据结构
|
|
||||||
|
|
||||||
```ts
|
|
||||||
type AgePageData = {
|
|
||||||
ageTabs: Array<{
|
|
||||||
key: AgeBandKey;
|
|
||||||
rangeText: string;
|
|
||||||
subLabel: string;
|
|
||||||
}>;
|
|
||||||
bands: Record<AgeBandKey, {
|
|
||||||
goalTitle: string;
|
|
||||||
abilityItems: AbilityItem[];
|
|
||||||
weekPlans: Array<{
|
|
||||||
week: number;
|
|
||||||
theme: string;
|
|
||||||
exercises: Array<{
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
path: string;
|
|
||||||
previewImg?: string;
|
|
||||||
}>;
|
|
||||||
}>;
|
|
||||||
recommendedItems: Array<{
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
image?: string;
|
|
||||||
path: string;
|
|
||||||
}>;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.4 更新流程
|
|
||||||
|
|
||||||
```
|
|
||||||
分龄管理 tab → 选择年龄段
|
|
||||||
→ 为每周选择 worksheet
|
|
||||||
→ 选择推荐内容
|
|
||||||
→ 点击「更新分龄数据」
|
→ 点击「更新分龄数据」
|
||||||
→ 合并 age.config.ts 的能力目标 + 管理页选择的 worksheet 详情
|
→ 合并 age.config.ts 的能力目标 + 管理页选择的 worksheet 详情
|
||||||
→ 调用 pageContentUpdate({ page: 'age', data: ... })
|
→ 调用 pageContentBuild({ page: 'age', data: ... })
|
||||||
→ 云函数更新 page-config.json 中的 age 字段
|
→ 云函数更新 page_configs/current 中的 age 字段 + 同步云存储
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -411,70 +410,75 @@ type AgePageData = {
|
|||||||
|
|
||||||
```
|
```
|
||||||
supportPages/debug/debug
|
supportPages/debug/debug
|
||||||
├─ 分类基础数据管理 (已有:同步 CATEGORY_LIST → categories)
|
├─ 分类基础数据管理 (已有:同步 CATEGORY_LIST → categories)
|
||||||
├─ Worksheet 内容池 (已有:状态、分类、上下架、预览)
|
├─ Worksheet 内容池 (已有:状态、分类、上下架、预览)
|
||||||
└─ 页面内容管理 (新增)
|
└─ 页面内容管理 (新增)
|
||||||
├─ Tab: 首页管理 → 编排 featured / hot / sections
|
├─ 📋 分类页内容管理 → supportPages/categoryContentManage
|
||||||
├─ Tab: 分类页管理 → 管理 worksheet 状态和排序
|
├─ 🏠 首页内容管理 → supportPages/homeContentManage
|
||||||
└─ Tab: 分龄页管理 → 按年龄段选择周 worksheet
|
└─ 📅 分龄页内容管理 → supportPages/ageContentManage(待开发)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 七、实施计划
|
## 七、实施计划
|
||||||
|
|
||||||
### Phase 1:基础设施
|
### Phase 1:基础设施 ✅
|
||||||
|
|
||||||
1. 配置 `app.json` 数据预拉取,指向 `content/page-config.json`
|
1. 实现 `pageContentBuild` 云函数(读写数据库 + 同步云存储)
|
||||||
2. 实现 `pageContentUpdate` 云函数
|
2. 实现 `pageConfigFetch` 云函数(数据预拉取接口)
|
||||||
3. 上传初始 `page-config.json`(从现有 `*.data.ts` 转换)
|
3. 实现 `worksheetsQuery` / `worksheetsUpdateStatus` 云函数
|
||||||
4. 三个页面统一接入预拉取加载逻辑
|
|
||||||
|
|
||||||
### Phase 2:分类页管理
|
### Phase 2:分类页管理 ✅
|
||||||
|
|
||||||
1. 实现分类页管理 tab(worksheet 列表、状态管理、排序)
|
1. 实现分类页管理页(worksheet 列表、状态管理)
|
||||||
2. 实现「更新分类数据」→ 生成 category 配置 → 调用云函数更新
|
2. 实现「更新分类数据」→ 生成 category 配置
|
||||||
|
3. 分类页支持 `?id=xxx` 参数默认选中分类
|
||||||
|
|
||||||
### Phase 3:首页管理
|
### Phase 3:首页管理 ✅
|
||||||
|
|
||||||
1. 实现首页管理 tab(featured / hot / sections 编排)
|
1. 实现首页管理页(featured / hot 自动填充 + sections 配置回显)
|
||||||
2. 实现 worksheet 选择器弹窗
|
2. 实现 worksheet 选择器弹窗
|
||||||
3. 实现「更新首页数据」→ 生成 home 配置 → 调用云函数更新
|
3. 实现定时任务(homeAutoRefresh + homeTimerControl)
|
||||||
|
4. 实现「更新首页数据」→ 生成 home 配置
|
||||||
|
|
||||||
### Phase 4:分龄页管理
|
### Phase 4:分龄页管理(待开发)
|
||||||
|
|
||||||
1. 新增 `age.config.ts` 配置文件
|
1. 新增 `age.config.ts` 配置文件
|
||||||
2. 实现分龄管理 tab(年龄段切换、周 worksheet 选择)
|
2. 实现分龄管理页(年龄段切换、周 worksheet 选择)
|
||||||
3. 实现「更新分龄数据」→ 合并 config + worksheet 详情 → 调用云函数更新
|
3. 实现「更新分龄数据」→ 合并 config + worksheet 详情
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 八、风险与约束
|
## 八、风险与约束
|
||||||
|
|
||||||
|
|
||||||
| 风险 | 处理方式 |
|
| 风险 | 处理方式 |
|
||||||
| ----------------- | ----------------------------------------- |
|
| ----------------- | ----------------------------------------- |
|
||||||
| 预拉取失败 | 本地 `*.data.ts` / `age.config.ts` 兜底,用户无感知 |
|
| 预拉取失败 | 依次降级:storage 缓存 → pageConfigFetch 云函数兜底 |
|
||||||
| JSON 生成失败 | 云函数返回错误,管理页提示重试,线上数据不受影响 |
|
| 配置生成失败 | 云函数返回错误,管理页提示重试,线上数据不受影响 |
|
||||||
| 首页配置引用了 hidden 内容 | 生成时只允许 active 内容,不足时返回警告 |
|
| 首页配置引用了 hidden 内容 | sections 回显时通过 wsMap 匹配,已下架的自动过滤 |
|
||||||
|
| 更新某页数据清空其他页 | 云函数从数据库读取完整配置,只覆盖对应字段,其他页面数据不受影响 |
|
||||||
| 分龄内容不适龄 | 选择器按年龄段默认过滤 |
|
| 分龄内容不适龄 | 选择器按年龄段默认过滤 |
|
||||||
| 并发更新冲突 | 云函数使用 version 乐观锁,冲突时提示重新加载后重试 |
|
| 并发更新冲突 | 云函数使用 version 乐观锁,冲突时提示重新加载后重试 |
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 九、关键文件索引
|
## 九、关键文件索引
|
||||||
|
|
||||||
|
| 文件 / 模块 | 职责 |
|
||||||
| 文件 / 模块 | 职责 |
|
| -------------------------------------------- | ------------------------------ |
|
||||||
| ----------------------------------- | ------------------------------ |
|
| `supportPages/homeContentManage/` | 首页内容管理页 |
|
||||||
| `supportPages/contentManage/` | 三 tab 内容管理页 |
|
| `supportPages/categoryContentManage/` | 分类页内容管理页 |
|
||||||
| `cloudfunctions/pageContentBuild/` | 生成 `page-config.json` 中指定页面的配置 |
|
| `cloudfunctions/pageContentBuild/` | 生成 page-config 中指定页面的配置 |
|
||||||
| `cloudfunctions/homeAutoRefresh/` | 定时任务,每日自动更新首页推荐位数据 |
|
| `cloudfunctions/pageConfigFetch/` | 数据预拉取接口,返回完整页面配置 |
|
||||||
| `cloudfunctions/homeTimerControl/` | 暂停/启动首页定时刷新任务 |
|
| `cloudfunctions/homeAutoRefresh/` | 定时任务,每日自动更新首页推荐位数据 |
|
||||||
| `pages/age/age.config.ts` | 分龄页兜底配置(年龄段、能力目标、默认 worksheet) |
|
| `cloudfunctions/homeTimerControl/` | 暂停/启动首页定时刷新任务 |
|
||||||
| `pages/home/home.data.ts` | 首页兜底数据 |
|
| `cloudfunctions/worksheetsQuery/` | 查询 worksheet 列表 |
|
||||||
| `pages/category/category.data.ts` | 分类页兜底数据 |
|
| `cloudfunctions/worksheetsUpdateStatus/` | 更新 worksheet 状态 |
|
||||||
| `content/page-config.json`(云存储) | 三个页面的统一配置 JSON |
|
| `pages/category/category.ts` | 分类页,支持 `?id=xxx` 参数选中分类 |
|
||||||
|
| `pages/age/age.config.ts` | 分龄页兜底配置 |
|
||||||
|
| `pages/home/home.data.ts` | 首页兜底数据 |
|
||||||
|
| `pages/category/category.data.ts` | 分类页兜底数据 |
|
||||||
|
| `core/data/categories.ts` | CATEGORY_LIST 分类定义(id/name/icon/path) |
|
||||||
|
| `content/page-config.json`(云存储) | 三个页面的统一配置 JSON |
|
||||||
|
| `page_configs/current`(云数据库) | 配置数据源(Source of Truth) |
|
||||||
|
| `settings/homeAutoRefresh`(云数据库) | 定时任务开关标记 |
|
||||||
|
|||||||
@@ -45,7 +45,11 @@
|
|||||||
"preloadRule": {
|
"preloadRule": {
|
||||||
"pages/home/home": {
|
"pages/home/home": {
|
||||||
"network": "all",
|
"network": "all",
|
||||||
"packages": ["mathPages", "focusPages", "englishPages"]
|
"packages": ["mathPages", "focusPages"]
|
||||||
|
},
|
||||||
|
"pages/category/category": {
|
||||||
|
"network": "all",
|
||||||
|
"packages": ["focusPages", "englishPages"]
|
||||||
},
|
},
|
||||||
"pages/profile/profile": {
|
"pages/profile/profile": {
|
||||||
"network": "all",
|
"network": "all",
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { defaultPrintConfig } from './config/config';
|
import { defaultPrintConfig } from './config/config';
|
||||||
import { generateUUID, getAppUUID, setAppUUID } from './utils/uuid';
|
import { generateUUID, getAppUUID, setAppUUID } from './utils/uuid';
|
||||||
|
|
||||||
|
const STORAGE_KEY_PAGE_CONFIG = 'pageConfig';
|
||||||
|
|
||||||
|
// Pending resolvers for getPageConfig() callers waiting on config
|
||||||
|
let _configResolvers: Array<(config: PageConfigData | null) => void> = [];
|
||||||
|
|
||||||
// app.ts
|
// app.ts
|
||||||
App<IAppOption>({
|
App<IAppOption>({
|
||||||
globalData: {
|
globalData: {
|
||||||
env: 'release',
|
env: 'release',
|
||||||
uuid: '',
|
uuid: '',
|
||||||
printConfig: defaultPrintConfig,
|
printConfig: defaultPrintConfig,
|
||||||
|
pageConfig: undefined,
|
||||||
|
pageConfigReady: false,
|
||||||
},
|
},
|
||||||
onLaunch() {
|
onLaunch() {
|
||||||
wx.cloud.init({
|
wx.cloud.init({
|
||||||
@@ -16,15 +23,8 @@ App<IAppOption>({
|
|||||||
|
|
||||||
const accountInfo = wx.getAccountInfoSync();
|
const accountInfo = wx.getAccountInfoSync();
|
||||||
const env = accountInfo.miniProgram.envVersion || 'release';
|
const env = accountInfo.miniProgram.envVersion || 'release';
|
||||||
/**
|
|
||||||
* 设置环境变量
|
|
||||||
* 开发环境:develop
|
|
||||||
* 体验环境:trial
|
|
||||||
* 正式环境:release
|
|
||||||
*/
|
|
||||||
this.globalData.env = env;
|
this.globalData.env = env;
|
||||||
|
|
||||||
// 从 localStorage 读取 UUID,如果没有则生成并存储
|
|
||||||
let uuid = getAppUUID();
|
let uuid = getAppUUID();
|
||||||
if (!uuid) {
|
if (!uuid) {
|
||||||
uuid = generateUUID();
|
uuid = generateUUID();
|
||||||
@@ -38,15 +38,13 @@ App<IAppOption>({
|
|||||||
wx.getStorageSync('printConfig') || defaultPrintConfig;
|
wx.getStorageSync('printConfig') || defaultPrintConfig;
|
||||||
this.globalData.printConfig = printConfig;
|
this.globalData.printConfig = printConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void this.loadPageConfig();
|
||||||
},
|
},
|
||||||
|
|
||||||
onShow() {
|
onShow() {},
|
||||||
// 小程序显示时的生命周期
|
|
||||||
},
|
|
||||||
|
|
||||||
onHide() {
|
onHide() {},
|
||||||
// 小程序隐藏时的生命周期
|
|
||||||
},
|
|
||||||
|
|
||||||
getPrintConfig(): PrintConfig {
|
getPrintConfig(): PrintConfig {
|
||||||
return this.globalData.printConfig || defaultPrintConfig;
|
return this.globalData.printConfig || defaultPrintConfig;
|
||||||
@@ -56,4 +54,80 @@ App<IAppOption>({
|
|||||||
this.globalData.printConfig = config;
|
this.globalData.printConfig = config;
|
||||||
wx.setStorageSync('printConfig', config);
|
wx.setStorageSync('printConfig', config);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async loadPageConfig() {
|
||||||
|
let config: PageConfigData | null = null;
|
||||||
|
|
||||||
|
// L1: 数据预拉取
|
||||||
|
try {
|
||||||
|
const res = await new Promise<{ fetchedData: string }>(
|
||||||
|
(resolve, reject) => {
|
||||||
|
wx.getBackgroundFetchData({
|
||||||
|
fetchType: 'pre',
|
||||||
|
success: resolve as any,
|
||||||
|
fail: reject,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (res.fetchedData) {
|
||||||
|
const parsed = JSON.parse(res.fetchedData);
|
||||||
|
config = parsed?.data || parsed;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 预拉取失败,继续降级
|
||||||
|
}
|
||||||
|
|
||||||
|
// L2: storage 缓存
|
||||||
|
if (!config) {
|
||||||
|
try {
|
||||||
|
const cached = wx.getStorageSync(STORAGE_KEY_PAGE_CONFIG);
|
||||||
|
if (cached && typeof cached === 'object') {
|
||||||
|
config = cached;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// storage 读取失败,继续降级
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// L3: 云函数兜底
|
||||||
|
if (!config) {
|
||||||
|
try {
|
||||||
|
const res = (await wx.cloud.callFunction({
|
||||||
|
name: 'pageConfigFetch',
|
||||||
|
})) as { result?: { success?: boolean; data?: any } };
|
||||||
|
if (res.result?.success && res.result.data) {
|
||||||
|
config = res.result.data;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 云函数也失败,config 保持 null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入 globalData
|
||||||
|
if (config) {
|
||||||
|
this.globalData.pageConfig = config;
|
||||||
|
// 成功获取后写入 storage 缓存
|
||||||
|
try {
|
||||||
|
wx.setStorageSync(STORAGE_KEY_PAGE_CONFIG, config);
|
||||||
|
} catch {
|
||||||
|
// storage 写入失败不影响主流程
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记就绪,通知等待中的页面
|
||||||
|
this.globalData.pageConfigReady = true;
|
||||||
|
for (const resolve of _configResolvers) {
|
||||||
|
resolve(config);
|
||||||
|
}
|
||||||
|
_configResolvers = [];
|
||||||
|
},
|
||||||
|
|
||||||
|
getPageConfig(): Promise<PageConfigData | null> {
|
||||||
|
if (this.globalData.pageConfigReady) {
|
||||||
|
return Promise.resolve(this.globalData.pageConfig || null);
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
_configResolvers.push(resolve);
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const FOCUS_DRAW_DEFINITIONS = [
|
|||||||
{
|
{
|
||||||
id: 'color-pattern',
|
id: 'color-pattern',
|
||||||
title: '颜色找规律',
|
title: '颜色找规律',
|
||||||
subtitle: '观察颜色规律,在空白图形中涂色',
|
subtitle: '观察颜色规律,涂色',
|
||||||
ageMin: 4,
|
ageMin: 4,
|
||||||
ageMax: 6,
|
ageMax: 6,
|
||||||
difficulty: 2,
|
difficulty: 2,
|
||||||
@@ -66,7 +66,7 @@ const FOCUS_DRAW_DEFINITIONS = [
|
|||||||
{
|
{
|
||||||
id: 'line-recognition',
|
id: 'line-recognition',
|
||||||
title: '线条识别',
|
title: '线条识别',
|
||||||
subtitle: '认识不同线条,画出颜色对应的线条',
|
subtitle: '认识不同线条,画对应线条',
|
||||||
ageMin: 3,
|
ageMin: 3,
|
||||||
ageMax: 5,
|
ageMax: 5,
|
||||||
difficulty: 1,
|
difficulty: 1,
|
||||||
@@ -86,7 +86,7 @@ const FOCUS_DRAW_DEFINITIONS = [
|
|||||||
{
|
{
|
||||||
id: 'code-connect',
|
id: 'code-connect',
|
||||||
title: '译码连线',
|
title: '译码连线',
|
||||||
subtitle: '按数字顺序将数字对应颜色连线',
|
subtitle: '按数字顺序连线',
|
||||||
ageMin: 5,
|
ageMin: 5,
|
||||||
ageMax: 7,
|
ageMax: 7,
|
||||||
difficulty: 3,
|
difficulty: 3,
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ const MATH_DRAW_DEFINITIONS = [
|
|||||||
{
|
{
|
||||||
id: 'number-find',
|
id: 'number-find',
|
||||||
title: '找数字,涂一涂',
|
title: '找数字,涂一涂',
|
||||||
subtitle: '在数字方阵中找出目标数字并涂色',
|
subtitle: '找出目标数字并涂色',
|
||||||
|
|
||||||
ageMin: 3,
|
ageMin: 3,
|
||||||
ageMax: 5,
|
ageMax: 5,
|
||||||
difficulty: 1,
|
difficulty: 1,
|
||||||
@@ -26,7 +27,7 @@ const MATH_DRAW_DEFINITIONS = [
|
|||||||
{
|
{
|
||||||
id: 'number-write',
|
id: 'number-write',
|
||||||
title: '看数字,写一写',
|
title: '看数字,写一写',
|
||||||
subtitle: '按笔画顺序练习书写数字',
|
subtitle: '按笔画顺序书写数字',
|
||||||
ageMin: 3,
|
ageMin: 3,
|
||||||
ageMax: 5,
|
ageMax: 5,
|
||||||
difficulty: 1,
|
difficulty: 1,
|
||||||
@@ -36,7 +37,7 @@ const MATH_DRAW_DEFINITIONS = [
|
|||||||
{
|
{
|
||||||
id: 'number-coloring',
|
id: 'number-coloring',
|
||||||
title: '按数字,涂颜色',
|
title: '按数字,涂颜色',
|
||||||
subtitle: '按指定数字给对应圆圈涂色',
|
subtitle: '按指定数字涂色',
|
||||||
ageMin: 3,
|
ageMin: 3,
|
||||||
ageMax: 5,
|
ageMax: 5,
|
||||||
difficulty: 1,
|
difficulty: 1,
|
||||||
@@ -46,7 +47,7 @@ const MATH_DRAW_DEFINITIONS = [
|
|||||||
{
|
{
|
||||||
id: 'counting-matching',
|
id: 'counting-matching',
|
||||||
title: '数一数,连一连',
|
title: '数一数,连一连',
|
||||||
subtitle: '连线配对数字和对应数量图形',
|
subtitle: '连线配对数字和数量',
|
||||||
ageMin: 3,
|
ageMin: 3,
|
||||||
ageMax: 5,
|
ageMax: 5,
|
||||||
difficulty: 1,
|
difficulty: 1,
|
||||||
@@ -56,7 +57,7 @@ const MATH_DRAW_DEFINITIONS = [
|
|||||||
{
|
{
|
||||||
id: 'number-object-match',
|
id: 'number-object-match',
|
||||||
title: '数物连线',
|
title: '数物连线',
|
||||||
subtitle: '连线相同数量的物品和数字',
|
subtitle: '连线数量相同的物体和数字',
|
||||||
ageMin: 3,
|
ageMin: 3,
|
||||||
ageMax: 5,
|
ageMax: 5,
|
||||||
difficulty: 1,
|
difficulty: 1,
|
||||||
@@ -66,7 +67,7 @@ const MATH_DRAW_DEFINITIONS = [
|
|||||||
{
|
{
|
||||||
id: 'number-object-fill',
|
id: 'number-object-fill',
|
||||||
title: '数物填写',
|
title: '数物填写',
|
||||||
subtitle: '数物品数量,填写对应数字',
|
subtitle: '数出数量,填写对应数字',
|
||||||
ageMin: 3,
|
ageMin: 3,
|
||||||
ageMax: 5,
|
ageMax: 5,
|
||||||
difficulty: 1,
|
difficulty: 1,
|
||||||
@@ -76,7 +77,7 @@ const MATH_DRAW_DEFINITIONS = [
|
|||||||
{
|
{
|
||||||
id: 'counting-select',
|
id: 'counting-select',
|
||||||
title: '数一数,选一选',
|
title: '数一数,选一选',
|
||||||
subtitle: '数出物品数量,圈出正确答案',
|
subtitle: '数出数量,圈出正确答案',
|
||||||
ageMin: 3,
|
ageMin: 3,
|
||||||
ageMax: 5,
|
ageMax: 5,
|
||||||
difficulty: 1,
|
difficulty: 1,
|
||||||
@@ -116,7 +117,7 @@ const MATH_DRAW_DEFINITIONS = [
|
|||||||
{
|
{
|
||||||
id: 'missing-number',
|
id: 'missing-number',
|
||||||
title: '填上缺少的数字',
|
title: '填上缺少的数字',
|
||||||
subtitle: '在数列中找出并填写缺失数字',
|
subtitle: '找出并填写缺失数字',
|
||||||
ageMin: 4,
|
ageMin: 4,
|
||||||
ageMax: 6,
|
ageMax: 6,
|
||||||
difficulty: 2,
|
difficulty: 2,
|
||||||
@@ -146,7 +147,7 @@ const MATH_DRAW_DEFINITIONS = [
|
|||||||
{
|
{
|
||||||
id: 'one-digit-addition',
|
id: 'one-digit-addition',
|
||||||
title: '一位数加法',
|
title: '一位数加法',
|
||||||
subtitle: '通过圆点学习一位数加法运算',
|
subtitle: '通过圆点学习一位数加法',
|
||||||
ageMin: 4,
|
ageMin: 4,
|
||||||
ageMax: 6,
|
ageMax: 6,
|
||||||
difficulty: 2,
|
difficulty: 2,
|
||||||
@@ -156,7 +157,7 @@ const MATH_DRAW_DEFINITIONS = [
|
|||||||
{
|
{
|
||||||
id: 'addition-5',
|
id: 'addition-5',
|
||||||
title: '5以内加法',
|
title: '5以内加法',
|
||||||
subtitle: '图形化展示 5 以内加法',
|
subtitle: '图形化展示5以内加法',
|
||||||
ageMin: 3,
|
ageMin: 3,
|
||||||
ageMax: 5,
|
ageMax: 5,
|
||||||
difficulty: 1,
|
difficulty: 1,
|
||||||
@@ -166,7 +167,7 @@ const MATH_DRAW_DEFINITIONS = [
|
|||||||
{
|
{
|
||||||
id: 'addition-10',
|
id: 'addition-10',
|
||||||
title: '10以内加法',
|
title: '10以内加法',
|
||||||
subtitle: '图形化展示 10 以内加法',
|
subtitle: '图形化展示10以内加法',
|
||||||
ageMin: 4,
|
ageMin: 4,
|
||||||
ageMax: 6,
|
ageMax: 6,
|
||||||
difficulty: 2,
|
difficulty: 2,
|
||||||
@@ -176,7 +177,7 @@ const MATH_DRAW_DEFINITIONS = [
|
|||||||
{
|
{
|
||||||
id: 'subtraction-10',
|
id: 'subtraction-10',
|
||||||
title: '10以内减法',
|
title: '10以内减法',
|
||||||
subtitle: '图形化展示 10 以内减法',
|
subtitle: '图形化展示10以内减法',
|
||||||
ageMin: 4,
|
ageMin: 4,
|
||||||
ageMax: 6,
|
ageMax: 6,
|
||||||
difficulty: 2,
|
difficulty: 2,
|
||||||
@@ -196,7 +197,7 @@ const MATH_DRAW_DEFINITIONS = [
|
|||||||
{
|
{
|
||||||
id: 'make-ten',
|
id: 'make-ten',
|
||||||
title: '凑十法练习',
|
title: '凑十法练习',
|
||||||
subtitle: '20 以内进位加法',
|
subtitle: '20以内进位加法',
|
||||||
ageMin: 5,
|
ageMin: 5,
|
||||||
ageMax: 7,
|
ageMax: 7,
|
||||||
difficulty: 3,
|
difficulty: 3,
|
||||||
@@ -206,7 +207,7 @@ const MATH_DRAW_DEFINITIONS = [
|
|||||||
{
|
{
|
||||||
id: 'break-ten',
|
id: 'break-ten',
|
||||||
title: '破十法练习',
|
title: '破十法练习',
|
||||||
subtitle: '20 以内退位减法',
|
subtitle: '20以内退位减法',
|
||||||
ageMin: 5,
|
ageMin: 5,
|
||||||
ageMax: 7,
|
ageMax: 7,
|
||||||
difficulty: 3,
|
difficulty: 3,
|
||||||
@@ -216,7 +217,7 @@ const MATH_DRAW_DEFINITIONS = [
|
|||||||
{
|
{
|
||||||
id: 'flat-ten',
|
id: 'flat-ten',
|
||||||
title: '平十法练习',
|
title: '平十法练习',
|
||||||
subtitle: '20 以内退位减法',
|
subtitle: '20以内退位减法',
|
||||||
ageMin: 5,
|
ageMin: 5,
|
||||||
ageMax: 7,
|
ageMax: 7,
|
||||||
difficulty: 3,
|
difficulty: 3,
|
||||||
@@ -246,7 +247,7 @@ const MATH_DRAW_DEFINITIONS = [
|
|||||||
{
|
{
|
||||||
id: 'practice-subtraction',
|
id: 'practice-subtraction',
|
||||||
title: '减法运算',
|
title: '减法运算',
|
||||||
subtitle: '10/20/50/100 以内减法',
|
subtitle: '10/20/50/100以内减法',
|
||||||
ageMin: 5,
|
ageMin: 5,
|
||||||
ageMax: 8,
|
ageMax: 8,
|
||||||
difficulty: 3,
|
difficulty: 3,
|
||||||
@@ -256,7 +257,7 @@ const MATH_DRAW_DEFINITIONS = [
|
|||||||
{
|
{
|
||||||
id: 'practice-mixed',
|
id: 'practice-mixed',
|
||||||
title: '混合运算',
|
title: '混合运算',
|
||||||
subtitle: '10/20/50/100 以内加减法混合',
|
subtitle: '10/20/50/100以内加减法混合',
|
||||||
ageMin: 5,
|
ageMin: 5,
|
||||||
ageMax: 8,
|
ageMax: 8,
|
||||||
difficulty: 3,
|
difficulty: 3,
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ function item(input: ItemInput): CategoryItem {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 目前只作为兜底数据 */
|
||||||
const CATEGORY_ITEMS_BY_ID: Record<string, CategoryItem[]> = {
|
const CATEGORY_ITEMS_BY_ID: Record<string, CategoryItem[]> = {
|
||||||
math: [
|
math: [
|
||||||
item({
|
item({
|
||||||
|
|||||||
@@ -142,8 +142,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1rpx solid #bbb;
|
border: 1rpx solid rgba(50, 46, 37, 0.08);
|
||||||
background: #f0f0f0;
|
background: #f5edd8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cat-card__thumb-img {
|
.cat-card__thumb-img {
|
||||||
@@ -261,4 +261,46 @@
|
|||||||
.cat-empty__text {
|
.cat-empty__text {
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
color: @text-gray;
|
color: @text-gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Skeleton ──
|
||||||
|
@keyframes skeleton-pulse {
|
||||||
|
0% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-card--skeleton {
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
.cat-card__thumb {
|
||||||
|
background: #e8e2d5;
|
||||||
|
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-card__title,
|
||||||
|
.cat-card__subtitle {
|
||||||
|
background: #e8e2d5;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
color: transparent;
|
||||||
|
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-card__title {
|
||||||
|
width: 60%;
|
||||||
|
height: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-card__subtitle {
|
||||||
|
width: 80%;
|
||||||
|
height: 24rpx;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,16 @@ type CategoryTab = {
|
|||||||
icon: string;
|
icon: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CategoryDataset = {
|
||||||
|
searchPlaceholder: string;
|
||||||
|
categories: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
items: CategoryItem[];
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
const SIDEBAR_TABS: CategoryTab[] = CATEGORY_LIST_WITH_ALL.map(
|
const SIDEBAR_TABS: CategoryTab[] = CATEGORY_LIST_WITH_ALL.map(
|
||||||
({ id, name, icon }) => ({
|
({ id, name, icon }) => ({
|
||||||
id,
|
id,
|
||||||
@@ -15,25 +25,34 @@ const SIDEBAR_TABS: CategoryTab[] = CATEGORY_LIST_WITH_ALL.map(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const TAB_BAR_PATHS = new Set([
|
||||||
|
'/pages/home/home',
|
||||||
|
'/pages/category/category',
|
||||||
|
'/pages/age/age',
|
||||||
|
'/pages/favorites/favorites',
|
||||||
|
'/pages/profile/profile',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Page-level mutable state (shared across methods, set once on load)
|
||||||
|
let _categoryData: CategoryDataset | null = null;
|
||||||
|
|
||||||
function buildAllItems(): CategoryItem[] {
|
function buildAllItems(): CategoryItem[] {
|
||||||
|
if (!_categoryData) return [];
|
||||||
const items: CategoryItem[] = [];
|
const items: CategoryItem[] = [];
|
||||||
for (const group of CATEGORY_DATA.categories) {
|
for (const group of _categoryData.categories) {
|
||||||
for (const item of group.items) {
|
for (const item of group.items) {
|
||||||
items.push({
|
items.push({ ...item });
|
||||||
...item,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getItemsByCategory(categoryId: string): CategoryItem[] {
|
function getItemsByCategory(categoryId: string): CategoryItem[] {
|
||||||
|
if (!_categoryData) return [];
|
||||||
if (categoryId === 'all') return buildAllItems();
|
if (categoryId === 'all') return buildAllItems();
|
||||||
const group = CATEGORY_DATA.categories.find((c) => c.id === categoryId);
|
const group = _categoryData.categories.find((c) => c.id === categoryId);
|
||||||
if (!group) return [];
|
if (!group) return [];
|
||||||
return group.items.map((item) => ({
|
return group.items.map((item) => ({ ...item }));
|
||||||
...item,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterByKeyword(
|
function filterByKeyword(
|
||||||
@@ -49,33 +68,64 @@ function filterByKeyword(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const TAB_BAR_PATHS = new Set([
|
// Skeleton placeholder items for loading state
|
||||||
'/pages/home/home',
|
const SKELETON_ITEMS = Array.from({ length: 4 }, (_, i) => ({
|
||||||
'/pages/category/category',
|
id: `skeleton-${i}`,
|
||||||
'/pages/age/age',
|
title: '\u00A0',
|
||||||
'/pages/favorites/favorites',
|
subtitle: '\u00A0',
|
||||||
'/pages/profile/profile',
|
skeleton: true,
|
||||||
]);
|
}));
|
||||||
|
|
||||||
Page({
|
Page({
|
||||||
data: {
|
data: {
|
||||||
searchPlaceholder: CATEGORY_DATA.searchPlaceholder,
|
searchPlaceholder: '搜索练习纸...',
|
||||||
searchKeyword: '',
|
searchKeyword: '',
|
||||||
sidebarTabs: SIDEBAR_TABS,
|
sidebarTabs: SIDEBAR_TABS,
|
||||||
activeCategoryId: 'all',
|
activeCategoryId: 'all',
|
||||||
displayItems: buildAllItems() as CategoryItem[],
|
displayItems: SKELETON_ITEMS as any[],
|
||||||
scrollIntoViewId: '',
|
scrollIntoViewId: '',
|
||||||
|
loading: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
onLoad(options: Record<string, string>) {
|
onLoad(options: Record<string, string>) {
|
||||||
const id = options.id;
|
void this.initData(options);
|
||||||
if (id && id !== 'all') {
|
},
|
||||||
const items = getItemsByCategory(id);
|
|
||||||
this.setData({
|
async initData(options: Record<string, string>) {
|
||||||
activeCategoryId: id,
|
wx.showLoading({ title: '加载中', mask: false });
|
||||||
displayItems: items,
|
|
||||||
});
|
const app = getApp<IAppOption>();
|
||||||
|
const config = await app.getPageConfig();
|
||||||
|
|
||||||
|
// Use cloud config if available, otherwise fallback to local static data
|
||||||
|
if (config?.category?.categories?.length) {
|
||||||
|
_categoryData = {
|
||||||
|
searchPlaceholder:
|
||||||
|
config.category.searchPlaceholder || '搜索练习纸...',
|
||||||
|
categories: config.category.categories.map((cat) => ({
|
||||||
|
id: cat.id,
|
||||||
|
name: cat.name,
|
||||||
|
icon: cat.icon,
|
||||||
|
items: (cat.items || []).map(
|
||||||
|
(raw) => raw as unknown as CategoryItem,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
_categoryData = CATEGORY_DATA;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeId = options.id && options.id !== 'all' ? options.id : 'all';
|
||||||
|
const items = getItemsByCategory(activeId);
|
||||||
|
|
||||||
|
this.setData({
|
||||||
|
loading: false,
|
||||||
|
searchPlaceholder: _categoryData.searchPlaceholder,
|
||||||
|
activeCategoryId: activeId,
|
||||||
|
displayItems: items,
|
||||||
|
});
|
||||||
|
|
||||||
|
wx.hideLoading();
|
||||||
},
|
},
|
||||||
|
|
||||||
onTapCategory(e: WechatMiniprogram.TouchEvent) {
|
onTapCategory(e: WechatMiniprogram.TouchEvent) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<nav-bar title="分类" />
|
<nav-bar title="分类" />
|
||||||
|
|
||||||
<view class="cat-body">
|
<view class="cat-body">
|
||||||
<view class="cat-search-wrap">
|
<!-- <view class="cat-search-wrap">
|
||||||
<view class="cat-search">
|
<view class="cat-search">
|
||||||
<van-icon
|
<van-icon
|
||||||
name="search"
|
name="search"
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
color="rgba(50, 46, 37, 0.35)"
|
color="rgba(50, 46, 37, 0.35)"
|
||||||
bindtap="onClearSearch" />
|
bindtap="onClearSearch" />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view> -->
|
||||||
|
|
||||||
<view class="cat-main">
|
<view class="cat-main">
|
||||||
<scroll-view
|
<scroll-view
|
||||||
@@ -52,18 +52,20 @@
|
|||||||
<view
|
<view
|
||||||
wx:for="{{displayItems}}"
|
wx:for="{{displayItems}}"
|
||||||
wx:key="id"
|
wx:key="id"
|
||||||
class="cat-card"
|
class="cat-card {{item.skeleton ? 'cat-card--skeleton' : ''}}"
|
||||||
data-path="{{item.path}}"
|
data-path="{{item.path}}"
|
||||||
data-title="{{item.title}}"
|
data-title="{{item.title}}"
|
||||||
data-available="{{item.available}}"
|
data-available="{{item.available}}"
|
||||||
bindtap="onTapItem">
|
bindtap="{{item.skeleton ? '' : 'onTapItem'}}">
|
||||||
<view class="cat-card__thumb">
|
<view class="cat-card__thumb">
|
||||||
<image
|
<image
|
||||||
wx:if="{{item.previewImg}}"
|
wx:if="{{item.previewImg}}"
|
||||||
class="cat-card__thumb-img"
|
class="cat-card__thumb-img"
|
||||||
src="{{item.previewImg}}"
|
src="{{item.previewImg}}"
|
||||||
mode="aspectFill" />
|
mode="aspectFill" />
|
||||||
<text wx:else class="cat-card__thumb-icon"
|
<text
|
||||||
|
wx:elif="{{!item.skeleton}}"
|
||||||
|
class="cat-card__thumb-icon"
|
||||||
>{{item.icon}}</text
|
>{{item.icon}}</text
|
||||||
>
|
>
|
||||||
</view>
|
</view>
|
||||||
@@ -76,7 +78,9 @@
|
|||||||
<text class="cat-card__subtitle"
|
<text class="cat-card__subtitle"
|
||||||
>{{item.subtitle}}</text
|
>{{item.subtitle}}</text
|
||||||
>
|
>
|
||||||
<view class="cat-card__tags">
|
<view
|
||||||
|
wx:if="{{!item.skeleton}}"
|
||||||
|
class="cat-card__tags">
|
||||||
<text class="tag tag--age"
|
<text class="tag tag--age"
|
||||||
>{{item.ageBand}}</text
|
>{{item.ageBand}}</text
|
||||||
>
|
>
|
||||||
@@ -84,7 +88,9 @@
|
|||||||
>{{item.difficultyLabel}}</text
|
>{{item.difficultyLabel}}</text
|
||||||
>
|
>
|
||||||
</view>
|
</view>
|
||||||
<view class="cat-card__footer">
|
<view
|
||||||
|
wx:if="{{!item.skeleton}}"
|
||||||
|
class="cat-card__footer">
|
||||||
<view class="cat-card__stats">
|
<view class="cat-card__stats">
|
||||||
<view class="cat-card__stat">
|
<view class="cat-card__stat">
|
||||||
<toy-icon
|
<toy-icon
|
||||||
@@ -109,7 +115,7 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view
|
<view
|
||||||
wx:if="{{displayItems.length === 0}}"
|
wx:if="{{displayItems.length === 0 && !loading}}"
|
||||||
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>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export type HomeDisplayItem = {
|
|||||||
difficulty: '入门' | '基础' | '进阶' | '挑战';
|
difficulty: '入门' | '基础' | '进阶' | '挑战';
|
||||||
icon: string;
|
icon: string;
|
||||||
/** 横向滑动卡片顶部图,有则优先展示,可与 icon 二选一 */
|
/** 横向滑动卡片顶部图,有则优先展示,可与 icon 二选一 */
|
||||||
img?: string;
|
previewImg?: string;
|
||||||
badge?: string;
|
badge?: string;
|
||||||
path: string;
|
path: string;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
@@ -139,7 +139,7 @@ export const HOME_PRODUCT_TARGET_DATA: HomeDisplayDataset = {
|
|||||||
difficulty: '基础',
|
difficulty: '基础',
|
||||||
icon: '➕',
|
icon: '➕',
|
||||||
badge: '热门',
|
badge: '热门',
|
||||||
img: '/assets/entrancePicture/math/addition-5.png',
|
previewImg: '/assets/entrancePicture/math/addition-5.png',
|
||||||
path: '/mathPages/mathDraw/mathDraw?id=addition',
|
path: '/mathPages/mathDraw/mathDraw?id=addition',
|
||||||
available: true,
|
available: true,
|
||||||
source: '现有加减法页面可承接',
|
source: '现有加减法页面可承接',
|
||||||
@@ -160,7 +160,7 @@ export const HOME_PRODUCT_TARGET_DATA: HomeDisplayDataset = {
|
|||||||
ageBand: '3-4岁',
|
ageBand: '3-4岁',
|
||||||
difficulty: '入门',
|
difficulty: '入门',
|
||||||
icon: '🔢',
|
icon: '🔢',
|
||||||
img: '/assets/entrancePicture/math/number-find.png',
|
previewImg: '/assets/entrancePicture/math/number-find.png',
|
||||||
path: '/mathPages/mathDraw/mathDraw?id=number-find',
|
path: '/mathPages/mathDraw/mathDraw?id=number-find',
|
||||||
available: true,
|
available: true,
|
||||||
source: '现有 找数字/写数字 能力',
|
source: '现有 找数字/写数字 能力',
|
||||||
@@ -173,7 +173,7 @@ export const HOME_PRODUCT_TARGET_DATA: HomeDisplayDataset = {
|
|||||||
ageBand: '4-5岁',
|
ageBand: '4-5岁',
|
||||||
difficulty: '基础',
|
difficulty: '基础',
|
||||||
icon: '🧮',
|
icon: '🧮',
|
||||||
img: '/assets/entrancePicture/math/count-match.png',
|
previewImg: '/assets/entrancePicture/math/count-match.png',
|
||||||
path: '/mathPages/mathDraw/mathDraw?id=counting-matching',
|
path: '/mathPages/mathDraw/mathDraw?id=counting-matching',
|
||||||
available: true,
|
available: true,
|
||||||
source: '现有 countMatch/countingSelect',
|
source: '现有 countMatch/countingSelect',
|
||||||
@@ -186,7 +186,7 @@ export const HOME_PRODUCT_TARGET_DATA: HomeDisplayDataset = {
|
|||||||
ageBand: '5-7岁',
|
ageBand: '5-7岁',
|
||||||
difficulty: '基础',
|
difficulty: '基础',
|
||||||
icon: '➕',
|
icon: '➕',
|
||||||
img: '/assets/entrancePicture/math/addition-5.png',
|
previewImg: '/assets/entrancePicture/math/addition-5.png',
|
||||||
path: '/mathPages/mathDraw/mathDraw?id=addition',
|
path: '/mathPages/mathDraw/mathDraw?id=addition',
|
||||||
available: true,
|
available: true,
|
||||||
source: '现有 addition/calculationPractice',
|
source: '现有 addition/calculationPractice',
|
||||||
@@ -355,7 +355,7 @@ export const HOME_PRODUCT_TARGET_DATA: HomeDisplayDataset = {
|
|||||||
ageBand: '4-6岁',
|
ageBand: '4-6岁',
|
||||||
difficulty: '基础',
|
difficulty: '基础',
|
||||||
icon: '🧵',
|
icon: '🧵',
|
||||||
img: '/assets/entrancePicture/focus/dot-connect.png',
|
previewImg: '/assets/entrancePicture/focus/dot-connect.png',
|
||||||
path: '/focusPages/focusDraw/focusDraw?id=dot-connect',
|
path: '/focusPages/focusDraw/focusDraw?id=dot-connect',
|
||||||
available: true,
|
available: true,
|
||||||
source: '现有 dotConnect/matchConnect',
|
source: '现有 dotConnect/matchConnect',
|
||||||
@@ -435,7 +435,7 @@ export const HOME_CURRENT_CAPABILITY_DATA: HomeDisplayDataset = {
|
|||||||
difficulty: '基础',
|
difficulty: '基础',
|
||||||
icon: '➕',
|
icon: '➕',
|
||||||
badge: 'HOT',
|
badge: 'HOT',
|
||||||
img: '/assets/entrancePicture/math/addition-5.png',
|
previewImg: '/assets/entrancePicture/math/addition-5.png',
|
||||||
path: '/mathPages/mathDraw/mathDraw?id=addition',
|
path: '/mathPages/mathDraw/mathDraw?id=addition',
|
||||||
available: true,
|
available: true,
|
||||||
source: '现有功能清单 addition',
|
source: '现有功能清单 addition',
|
||||||
@@ -464,7 +464,7 @@ export const HOME_CURRENT_CAPABILITY_DATA: HomeDisplayDataset = {
|
|||||||
difficulty: '基础',
|
difficulty: '基础',
|
||||||
icon: '🔗',
|
icon: '🔗',
|
||||||
badge: '热门',
|
badge: '热门',
|
||||||
img: '/assets/entrancePicture/focus/dot-connect.png',
|
previewImg: '/assets/entrancePicture/focus/dot-connect.png',
|
||||||
path: '/focusPages/focusDraw/focusDraw?id=dot-connect',
|
path: '/focusPages/focusDraw/focusDraw?id=dot-connect',
|
||||||
available: true,
|
available: true,
|
||||||
source: '现有功能清单 dot-connect',
|
source: '现有功能清单 dot-connect',
|
||||||
@@ -480,7 +480,7 @@ export const HOME_CURRENT_CAPABILITY_DATA: HomeDisplayDataset = {
|
|||||||
difficulty: '入门',
|
difficulty: '入门',
|
||||||
icon: '🔢',
|
icon: '🔢',
|
||||||
badge: '热门',
|
badge: '热门',
|
||||||
img: '/assets/entrancePicture/math/number-find.png',
|
previewImg: '/assets/entrancePicture/math/number-find.png',
|
||||||
path: '/mathPages/mathDraw/mathDraw?id=number-find',
|
path: '/mathPages/mathDraw/mathDraw?id=number-find',
|
||||||
available: true,
|
available: true,
|
||||||
source: 'number-find',
|
source: 'number-find',
|
||||||
@@ -494,7 +494,7 @@ export const HOME_CURRENT_CAPABILITY_DATA: HomeDisplayDataset = {
|
|||||||
difficulty: '基础',
|
difficulty: '基础',
|
||||||
icon: '🧮',
|
icon: '🧮',
|
||||||
badge: '热门',
|
badge: '热门',
|
||||||
img: '/assets/entrancePicture/math/count-match.png',
|
previewImg: '/assets/entrancePicture/math/count-match.png',
|
||||||
path: '/mathPages/mathDraw/mathDraw?id=counting-matching',
|
path: '/mathPages/mathDraw/mathDraw?id=counting-matching',
|
||||||
available: true,
|
available: true,
|
||||||
source: 'counting-matching',
|
source: 'counting-matching',
|
||||||
@@ -508,7 +508,7 @@ export const HOME_CURRENT_CAPABILITY_DATA: HomeDisplayDataset = {
|
|||||||
difficulty: '进阶',
|
difficulty: '进阶',
|
||||||
icon: '🟨',
|
icon: '🟨',
|
||||||
badge: '热门',
|
badge: '热门',
|
||||||
img: '/assets/entrancePicture/focus/grid-drawing-5x5.png',
|
previewImg: '/assets/entrancePicture/focus/grid-drawing-5x5.png',
|
||||||
path: '/focusPages/focusDraw/focusDraw?id=grid-drawing',
|
path: '/focusPages/focusDraw/focusDraw?id=grid-drawing',
|
||||||
available: true,
|
available: true,
|
||||||
source: 'grid-drawing-5x5',
|
source: 'grid-drawing-5x5',
|
||||||
@@ -529,7 +529,7 @@ export const HOME_CURRENT_CAPABILITY_DATA: HomeDisplayDataset = {
|
|||||||
ageBand: '3-4岁',
|
ageBand: '3-4岁',
|
||||||
difficulty: '入门',
|
difficulty: '入门',
|
||||||
icon: '🔢',
|
icon: '🔢',
|
||||||
img: '/assets/entrancePicture/math/number-find.png',
|
previewImg: '/assets/entrancePicture/math/number-find.png',
|
||||||
path: '/mathPages/mathDraw/mathDraw?id=number-find',
|
path: '/mathPages/mathDraw/mathDraw?id=number-find',
|
||||||
available: true,
|
available: true,
|
||||||
source: 'number-find / number-write',
|
source: 'number-find / number-write',
|
||||||
@@ -542,7 +542,7 @@ export const HOME_CURRENT_CAPABILITY_DATA: HomeDisplayDataset = {
|
|||||||
ageBand: '4-5岁',
|
ageBand: '4-5岁',
|
||||||
difficulty: '基础',
|
difficulty: '基础',
|
||||||
icon: '🧮',
|
icon: '🧮',
|
||||||
img: '/assets/entrancePicture/math/count-match.png',
|
previewImg: '/assets/entrancePicture/math/count-match.png',
|
||||||
path: '/mathPages/mathDraw/mathDraw?id=counting-matching',
|
path: '/mathPages/mathDraw/mathDraw?id=counting-matching',
|
||||||
available: true,
|
available: true,
|
||||||
source: 'countMatch / countingSelect',
|
source: 'countMatch / countingSelect',
|
||||||
@@ -555,7 +555,7 @@ export const HOME_CURRENT_CAPABILITY_DATA: HomeDisplayDataset = {
|
|||||||
ageBand: '4-5岁',
|
ageBand: '4-5岁',
|
||||||
difficulty: '基础',
|
difficulty: '基础',
|
||||||
icon: '📈',
|
icon: '📈',
|
||||||
img: '/assets/entrancePicture/math/number-sort.png',
|
previewImg: '/assets/entrancePicture/math/number-sort.png',
|
||||||
path: '/mathPages/mathDraw/mathDraw?id=number-sort',
|
path: '/mathPages/mathDraw/mathDraw?id=number-sort',
|
||||||
available: true,
|
available: true,
|
||||||
source: 'numberSort / missingNumber',
|
source: 'numberSort / missingNumber',
|
||||||
@@ -568,7 +568,7 @@ export const HOME_CURRENT_CAPABILITY_DATA: HomeDisplayDataset = {
|
|||||||
ageBand: '5-7岁',
|
ageBand: '5-7岁',
|
||||||
difficulty: '基础',
|
difficulty: '基础',
|
||||||
icon: '➕',
|
icon: '➕',
|
||||||
img: '/assets/entrancePicture/math/addition-5.png',
|
previewImg: '/assets/entrancePicture/math/addition-5.png',
|
||||||
path: '/mathPages/mathDraw/mathDraw?id=addition',
|
path: '/mathPages/mathDraw/mathDraw?id=addition',
|
||||||
available: true,
|
available: true,
|
||||||
source: 'addition / calculationPractice',
|
source: 'addition / calculationPractice',
|
||||||
@@ -581,7 +581,7 @@ export const HOME_CURRENT_CAPABILITY_DATA: HomeDisplayDataset = {
|
|||||||
ageBand: '7-8岁',
|
ageBand: '7-8岁',
|
||||||
difficulty: '挑战',
|
difficulty: '挑战',
|
||||||
icon: '✖️',
|
icon: '✖️',
|
||||||
img: '/assets/entrancePicture/math/multiplication-table.png',
|
previewImg: '/assets/entrancePicture/math/multiplication-table.png',
|
||||||
path: '/mathPages/mathDraw/mathDraw?id=multiplication-table',
|
path: '/mathPages/mathDraw/mathDraw?id=multiplication-table',
|
||||||
available: true,
|
available: true,
|
||||||
source: 'multiplicationTable',
|
source: 'multiplicationTable',
|
||||||
@@ -646,7 +646,7 @@ export const HOME_CURRENT_CAPABILITY_DATA: HomeDisplayDataset = {
|
|||||||
ageBand: '4-6岁',
|
ageBand: '4-6岁',
|
||||||
difficulty: '基础',
|
difficulty: '基础',
|
||||||
icon: '🔗',
|
icon: '🔗',
|
||||||
img: '/assets/entrancePicture/focus/dot-connect.png',
|
previewImg: '/assets/entrancePicture/focus/dot-connect.png',
|
||||||
path: '/focusPages/focusDraw/focusDraw?id=dot-connect',
|
path: '/focusPages/focusDraw/focusDraw?id=dot-connect',
|
||||||
available: true,
|
available: true,
|
||||||
source: 'dot-connect',
|
source: 'dot-connect',
|
||||||
@@ -659,7 +659,7 @@ export const HOME_CURRENT_CAPABILITY_DATA: HomeDisplayDataset = {
|
|||||||
ageBand: '4-6岁',
|
ageBand: '4-6岁',
|
||||||
difficulty: '基础',
|
difficulty: '基础',
|
||||||
icon: '🧵',
|
icon: '🧵',
|
||||||
img: '/assets/entrancePicture/focus/match-connect.png',
|
previewImg: '/assets/entrancePicture/focus/match-connect.png',
|
||||||
path: '/focusPages/focusDraw/focusDraw?id=match-connect',
|
path: '/focusPages/focusDraw/focusDraw?id=match-connect',
|
||||||
available: true,
|
available: true,
|
||||||
source: 'match-connect',
|
source: 'match-connect',
|
||||||
@@ -672,7 +672,7 @@ export const HOME_CURRENT_CAPABILITY_DATA: HomeDisplayDataset = {
|
|||||||
ageBand: '4-6岁',
|
ageBand: '4-6岁',
|
||||||
difficulty: '基础',
|
difficulty: '基础',
|
||||||
icon: '🌈',
|
icon: '🌈',
|
||||||
img: '/assets/entrancePicture/focus/color-pattern.png',
|
previewImg: '/assets/entrancePicture/focus/color-pattern.png',
|
||||||
path: '/focusPages/focusDraw/focusDraw?id=color-pattern',
|
path: '/focusPages/focusDraw/focusDraw?id=color-pattern',
|
||||||
available: true,
|
available: true,
|
||||||
source: 'color-pattern',
|
source: 'color-pattern',
|
||||||
@@ -685,7 +685,7 @@ export const HOME_CURRENT_CAPABILITY_DATA: HomeDisplayDataset = {
|
|||||||
ageBand: '5-7岁',
|
ageBand: '5-7岁',
|
||||||
difficulty: '进阶',
|
difficulty: '进阶',
|
||||||
icon: '🧠',
|
icon: '🧠',
|
||||||
img: '/assets/entrancePicture/focus/grid-reasoning.png',
|
previewImg: '/assets/entrancePicture/focus/grid-reasoning.png',
|
||||||
path: '/focusPages/focusDraw/focusDraw?id=grid-reasoning',
|
path: '/focusPages/focusDraw/focusDraw?id=grid-reasoning',
|
||||||
available: true,
|
available: true,
|
||||||
source: 'grid-reasoning',
|
source: 'grid-reasoning',
|
||||||
@@ -698,7 +698,7 @@ export const HOME_CURRENT_CAPABILITY_DATA: HomeDisplayDataset = {
|
|||||||
ageBand: '4-7岁',
|
ageBand: '4-7岁',
|
||||||
difficulty: '进阶',
|
difficulty: '进阶',
|
||||||
icon: '🟨',
|
icon: '🟨',
|
||||||
img: '/assets/entrancePicture/focus/grid-drawing-5x5.png',
|
previewImg: '/assets/entrancePicture/focus/grid-drawing-5x5.png',
|
||||||
path: '/focusPages/focusDraw/focusDraw?id=grid-drawing',
|
path: '/focusPages/focusDraw/focusDraw?id=grid-drawing',
|
||||||
available: true,
|
available: true,
|
||||||
source: 'grid-drawing',
|
source: 'grid-drawing',
|
||||||
@@ -719,7 +719,7 @@ export const HOME_CURRENT_CAPABILITY_DATA: HomeDisplayDataset = {
|
|||||||
ageBand: '4-7岁',
|
ageBand: '4-7岁',
|
||||||
difficulty: '入门',
|
difficulty: '入门',
|
||||||
icon: '',
|
icon: '',
|
||||||
img: '/assets/entrancePicture/english/letter-tracing-single.jpg',
|
previewImg: '/assets/entrancePicture/english/letter-tracing-single.jpg',
|
||||||
path: '/englishPages/letterTracing/letterTracing?id=letter-tracing-single',
|
path: '/englishPages/letterTracing/letterTracing?id=letter-tracing-single',
|
||||||
available: true,
|
available: true,
|
||||||
source: 'letter-tracing-single',
|
source: 'letter-tracing-single',
|
||||||
@@ -732,7 +732,7 @@ export const HOME_CURRENT_CAPABILITY_DATA: HomeDisplayDataset = {
|
|||||||
ageBand: '4-7岁',
|
ageBand: '4-7岁',
|
||||||
difficulty: '入门',
|
difficulty: '入门',
|
||||||
icon: '',
|
icon: '',
|
||||||
img: '/assets/entrancePicture/english/letter-tracing-upper-lower.jpg',
|
previewImg: '/assets/entrancePicture/english/letter-tracing-upper-lower.jpg',
|
||||||
path: '/englishPages/letterTracing/letterTracing?id=letter-tracing-upper-lower',
|
path: '/englishPages/letterTracing/letterTracing?id=letter-tracing-upper-lower',
|
||||||
available: true,
|
available: true,
|
||||||
source: 'letter-tracing-upper-lower',
|
source: 'letter-tracing-upper-lower',
|
||||||
@@ -745,7 +745,7 @@ export const HOME_CURRENT_CAPABILITY_DATA: HomeDisplayDataset = {
|
|||||||
ageBand: '4-7岁',
|
ageBand: '4-7岁',
|
||||||
difficulty: '入门',
|
difficulty: '入门',
|
||||||
icon: '',
|
icon: '',
|
||||||
img: '/assets/entrancePicture/english/letter-tracing-case-pairing.jpg',
|
previewImg: '/assets/entrancePicture/english/letter-tracing-case-pairing.jpg',
|
||||||
path: '/englishPages/letterTracing/letterTracing?id=letter-tracing-case-pairing',
|
path: '/englishPages/letterTracing/letterTracing?id=letter-tracing-case-pairing',
|
||||||
available: true,
|
available: true,
|
||||||
source: 'letter-tracing-case-pairing',
|
source: 'letter-tracing-case-pairing',
|
||||||
@@ -758,7 +758,7 @@ export const HOME_CURRENT_CAPABILITY_DATA: HomeDisplayDataset = {
|
|||||||
ageBand: '4-7岁',
|
ageBand: '4-7岁',
|
||||||
difficulty: '入门',
|
difficulty: '入门',
|
||||||
icon: '',
|
icon: '',
|
||||||
img: '/assets/entrancePicture/english/letter-tracing-two-column.jpg',
|
previewImg: '/assets/entrancePicture/english/letter-tracing-two-column.jpg',
|
||||||
path: '/englishPages/letterTracing/letterTracing?id=letter-tracing-two-column',
|
path: '/englishPages/letterTracing/letterTracing?id=letter-tracing-two-column',
|
||||||
available: true,
|
available: true,
|
||||||
source: 'letter-tracing-two-column',
|
source: 'letter-tracing-two-column',
|
||||||
@@ -771,7 +771,7 @@ export const HOME_CURRENT_CAPABILITY_DATA: HomeDisplayDataset = {
|
|||||||
ageBand: '4-7岁',
|
ageBand: '4-7岁',
|
||||||
difficulty: '入门',
|
difficulty: '入门',
|
||||||
icon: '',
|
icon: '',
|
||||||
img: '/assets/entrancePicture/english/letter-tracing-half.jpg',
|
previewImg: '/assets/entrancePicture/english/letter-tracing-half.jpg',
|
||||||
path: '/englishPages/letterTracing/letterTracing?id=letter-tracing-half',
|
path: '/englishPages/letterTracing/letterTracing?id=letter-tracing-half',
|
||||||
available: true,
|
available: true,
|
||||||
source: 'letter-tracing-half',
|
source: 'letter-tracing-half',
|
||||||
@@ -784,7 +784,7 @@ export const HOME_CURRENT_CAPABILITY_DATA: HomeDisplayDataset = {
|
|||||||
ageBand: '4-7岁',
|
ageBand: '4-7岁',
|
||||||
difficulty: '入门',
|
difficulty: '入门',
|
||||||
icon: '',
|
icon: '',
|
||||||
img: '/assets/entrancePicture/english/letter-tracing-three.jpg',
|
previewImg: '/assets/entrancePicture/english/letter-tracing-three.jpg',
|
||||||
path: '/englishPages/letterTracing/letterTracing?id=letter-tracing-three',
|
path: '/englishPages/letterTracing/letterTracing?id=letter-tracing-three',
|
||||||
available: true,
|
available: true,
|
||||||
source: 'letter-tracing-three',
|
source: 'letter-tracing-three',
|
||||||
|
|||||||
@@ -83,6 +83,15 @@
|
|||||||
border-top-left-radius: 32rpx;
|
border-top-left-radius: 32rpx;
|
||||||
border-top-right-radius: 32rpx;
|
border-top-right-radius: 32rpx;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
border-bottom: 1rpx solid rgba(50, 46, 37, 0.08);
|
||||||
|
background: #f5edd8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero-card__cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-hero-card__icon {
|
.home-hero-card__icon {
|
||||||
@@ -216,6 +225,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
border-bottom: 1rpx solid rgba(50, 46, 37, 0.08);
|
||||||
|
background: #f5edd8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-track-card__cover {
|
.home-track-card__cover {
|
||||||
@@ -242,7 +253,6 @@
|
|||||||
padding: 20rpx @page-padding-inner-x 22rpx;
|
padding: 20rpx @page-padding-inner-x 22rpx;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
border-top: 1rpx solid #c5c5c5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-track-card__title {
|
.home-track-card__title {
|
||||||
@@ -264,7 +274,7 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 8rpx;
|
margin-top: 8rpx;
|
||||||
font-size: 22rpx;
|
font-size: 20rpx;
|
||||||
color: @text-gray;
|
color: @text-gray;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
@@ -350,12 +360,22 @@
|
|||||||
|
|
||||||
.home-hot-card__thumb {
|
.home-hot-card__thumb {
|
||||||
width: 144rpx;
|
width: 144rpx;
|
||||||
height: 144rpx;
|
height: 180rpx;
|
||||||
border-radius: 24rpx;
|
border-radius: 18rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1rpx solid rgba(50, 46, 37, 0.08);
|
||||||
|
background: #f5edd8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hot-card__thumb-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-hot-card__thumb-emoji {
|
.home-hot-card__thumb-emoji {
|
||||||
@@ -367,6 +387,9 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-self: stretch;
|
||||||
|
padding: 6rpx 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-hot-card__title {
|
.home-hot-card__title {
|
||||||
@@ -438,4 +461,71 @@
|
|||||||
background: @brand;
|
background: @brand;
|
||||||
color: @text-selected-btn;
|
color: @text-selected-btn;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Skeleton ──
|
||||||
|
@keyframes home-skeleton-pulse {
|
||||||
|
0% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero-card--skeleton {
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
.home-hero-card__top {
|
||||||
|
animation: home-skeleton-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero-card__title,
|
||||||
|
.home-hero-card__sub {
|
||||||
|
background: #e8e2d5;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
color: transparent;
|
||||||
|
animation: home-skeleton-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero-card__title {
|
||||||
|
width: 50%;
|
||||||
|
height: 36rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero-card__sub {
|
||||||
|
width: 70%;
|
||||||
|
height: 26rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hot-card--skeleton {
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
.home-hot-card__thumb {
|
||||||
|
animation: home-skeleton-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hot-card__title,
|
||||||
|
.home-hot-card__desc {
|
||||||
|
background: #e8e2d5;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
color: transparent;
|
||||||
|
animation: home-skeleton-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hot-card__title {
|
||||||
|
width: 40%;
|
||||||
|
height: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hot-card__desc {
|
||||||
|
width: 65%;
|
||||||
|
height: 24rpx;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
HOME_CURRENT_CAPABILITY_DATA,
|
HOME_CURRENT_CAPABILITY_DATA,
|
||||||
type HomeDisplayItem,
|
type HomeDisplayItem,
|
||||||
type HomeDisplaySection,
|
type HomeDisplaySection,
|
||||||
|
type HomeDisplayDataset,
|
||||||
} from './home.data';
|
} from './home.data';
|
||||||
|
|
||||||
type AgeBandKey = string;
|
type AgeBandKey = string;
|
||||||
@@ -21,7 +22,7 @@ type HomeAgeBandView = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type HomeDisplayItemView = HomeDisplayItem & {
|
type HomeDisplayItemView = HomeDisplayItem & {
|
||||||
topBg: string;
|
skeleton?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type HomeDisplaySectionView = HomeDisplaySection & {
|
type HomeDisplaySectionView = HomeDisplaySection & {
|
||||||
@@ -31,14 +32,6 @@ type HomeDisplaySectionView = HomeDisplaySection & {
|
|||||||
|
|
||||||
const AGE_BAND_ICONS = ['🌱', '🚀', '🎓', '🧠', '🏆'] as const;
|
const AGE_BAND_ICONS = ['🌱', '🚀', '🎓', '🧠', '🏆'] as const;
|
||||||
|
|
||||||
const CATEGORY_TOP_BG: Record<HomeDisplayItem['category'], string> = {
|
|
||||||
math: '#fff3c4',
|
|
||||||
chinese: '#d8f0d0',
|
|
||||||
english: '#e8f4ff',
|
|
||||||
puzzle: '#f5e6dc',
|
|
||||||
craft: '#fce4ec',
|
|
||||||
};
|
|
||||||
|
|
||||||
const SECTION_ANCHOR_PREFIX = 'section-';
|
const SECTION_ANCHOR_PREFIX = 'section-';
|
||||||
const TAB_BAR_PATHS = new Set([
|
const TAB_BAR_PATHS = new Set([
|
||||||
'/pages/home/home',
|
'/pages/home/home',
|
||||||
@@ -50,7 +43,6 @@ const TAB_BAR_PATHS = new Set([
|
|||||||
function toItemView(item: HomeDisplayItem): HomeDisplayItemView {
|
function toItemView(item: HomeDisplayItem): HomeDisplayItemView {
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
topBg: CATEGORY_TOP_BG[item.category],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,12 +56,7 @@ function sortSectionsByTabs(
|
|||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
return [...sections]
|
return [...sections]
|
||||||
.sort((a, b) => {
|
.sort((a, b) => (orderMap[a.id] ?? 999) - (orderMap[b.id] ?? 999))
|
||||||
const aOrder = orderMap[a.id];
|
|
||||||
const bOrder = orderMap[b.id];
|
|
||||||
// 注意:0 需要保留(不能用 || 进行兜底)
|
|
||||||
return (aOrder ?? 999) - (bOrder ?? 999);
|
|
||||||
})
|
|
||||||
.map((section) => ({
|
.map((section) => ({
|
||||||
...section,
|
...section,
|
||||||
anchorId: `${SECTION_ANCHOR_PREFIX}${section.id}`,
|
anchorId: `${SECTION_ANCHOR_PREFIX}${section.id}`,
|
||||||
@@ -78,6 +65,7 @@ function sortSectionsByTabs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function navigateByPath(path?: string, fallbackTitle?: string) {
|
function navigateByPath(path?: string, fallbackTitle?: string) {
|
||||||
|
console.log('path-----:', path);
|
||||||
if (!path) {
|
if (!path) {
|
||||||
wx.showToast({
|
wx.showToast({
|
||||||
title: fallbackTitle ? `${fallbackTitle} 即将上线` : '即将上线',
|
title: fallbackTitle ? `${fallbackTitle} 即将上线` : '即将上线',
|
||||||
@@ -94,31 +82,109 @@ function navigateByPath(path?: string, fallbackTitle?: string) {
|
|||||||
wx.navigateTo({ url: path });
|
wx.navigateTo({ url: path });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Skeleton data ──
|
||||||
|
|
||||||
|
function skeletonItem(id: string): HomeDisplayItemView {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: '\u00A0',
|
||||||
|
subtitle: '\u00A0',
|
||||||
|
category: 'math' as any,
|
||||||
|
ageBand: '',
|
||||||
|
difficulty: '基础',
|
||||||
|
icon: '',
|
||||||
|
path: '',
|
||||||
|
available: false,
|
||||||
|
skeleton: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SKELETON_FEATURED = Array.from({ length: 3 }, (_, i) =>
|
||||||
|
skeletonItem(`skeleton-featured-${i}`),
|
||||||
|
);
|
||||||
|
const SKELETON_HOT = Array.from({ length: 3 }, (_, i) =>
|
||||||
|
skeletonItem(`skeleton-hot-${i}`),
|
||||||
|
);
|
||||||
|
|
||||||
const win = wx.getWindowInfo();
|
const win = wx.getWindowInfo();
|
||||||
const HOME_DATA = HOME_CURRENT_CAPABILITY_DATA;
|
|
||||||
const HOME_TABS: HomeTab[] = HOME_DATA.categoryTabs.map((item) => ({
|
// Page-level mutable state
|
||||||
id: item.id,
|
let _homeData: HomeDisplayDataset | null = null;
|
||||||
name: item.name,
|
|
||||||
}));
|
function applyHomeData(data: HomeDisplayDataset) {
|
||||||
const HOME_SECTIONS = sortSectionsByTabs(HOME_TABS, HOME_DATA.sections);
|
_homeData = data;
|
||||||
console.log('HOME_DATA', HOME_DATA);
|
const tabs: HomeTab[] = data.categoryTabs.map((item) => ({
|
||||||
console.log('HOME_SECTIONS', HOME_SECTIONS);
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
}));
|
||||||
|
const sections = sortSectionsByTabs(tabs, data.sections);
|
||||||
|
const ageBands: HomeAgeBandView[] = data.ageBands.map((item, idx) => ({
|
||||||
|
...item,
|
||||||
|
icon: AGE_BAND_ICONS[idx] || '🌟',
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { tabs, sections, ageBands };
|
||||||
|
}
|
||||||
|
|
||||||
Page({
|
Page({
|
||||||
data: {
|
data: {
|
||||||
searchPlaceholder: HOME_DATA.searchPlaceholder,
|
searchPlaceholder: '搜索你喜欢的练习册...',
|
||||||
tabs: HOME_TABS,
|
tabs: [] as HomeTab[],
|
||||||
activeTab: 'all',
|
activeTab: 'all',
|
||||||
ageBands: HOME_DATA.ageBands.map((item, idx) => ({
|
ageBands: [] as HomeAgeBandView[],
|
||||||
...item,
|
activeAgeBand: '3-4' as AgeBandKey,
|
||||||
icon: AGE_BAND_ICONS[idx] || '🌟',
|
featuredItems: SKELETON_FEATURED,
|
||||||
})) as HomeAgeBandView[],
|
hotRecommends: SKELETON_HOT,
|
||||||
activeAgeBand: HOME_DATA.ageBands[0]?.key || ('3-4' as AgeBandKey),
|
sections: [] as HomeDisplaySectionView[],
|
||||||
featuredItems: HOME_DATA.featured.map(toItemView),
|
loading: true,
|
||||||
hotRecommends: HOME_DATA.hot.map(toItemView),
|
|
||||||
sections: HOME_SECTIONS,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onLoad() {
|
||||||
|
void this.initData();
|
||||||
|
},
|
||||||
|
|
||||||
|
async initData() {
|
||||||
|
wx.showLoading({ title: '加载中', mask: false });
|
||||||
|
|
||||||
|
const app = getApp<IAppOption>();
|
||||||
|
const config = await app.getPageConfig();
|
||||||
|
|
||||||
|
let homeData: HomeDisplayDataset;
|
||||||
|
|
||||||
|
if (config?.home) {
|
||||||
|
// Cloud config available — map to HomeDisplayDataset
|
||||||
|
const raw = config.home as Record<string, any>;
|
||||||
|
homeData = {
|
||||||
|
searchPlaceholder:
|
||||||
|
raw.searchPlaceholder || '搜索你喜欢的练习册...',
|
||||||
|
categoryTabs: raw.categoryTabs || [],
|
||||||
|
ageBands: raw.ageBands || [],
|
||||||
|
featured: (raw.featured || []) as HomeDisplayItem[],
|
||||||
|
hot: (raw.hot || []) as HomeDisplayItem[],
|
||||||
|
sections: (raw.sections || []) as HomeDisplaySection[],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
homeData = HOME_CURRENT_CAPABILITY_DATA;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tabs, sections, ageBands } = applyHomeData(homeData);
|
||||||
|
|
||||||
|
this.setData({
|
||||||
|
loading: false,
|
||||||
|
searchPlaceholder: homeData.searchPlaceholder,
|
||||||
|
tabs,
|
||||||
|
ageBands,
|
||||||
|
activeAgeBand: homeData.ageBands[0]?.key || '3-4',
|
||||||
|
featuredItems: homeData.featured.map(toItemView),
|
||||||
|
hotRecommends: homeData.hot.slice(0, 3).map(toItemView),
|
||||||
|
sections,
|
||||||
|
});
|
||||||
|
|
||||||
|
wx.hideLoading();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Tab & Navigation ──
|
||||||
|
|
||||||
onTabChange(e: WechatMiniprogram.CustomEvent) {
|
onTabChange(e: WechatMiniprogram.CustomEvent) {
|
||||||
const id = e.detail.id as string | undefined;
|
const id = e.detail.id as string | undefined;
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -126,10 +192,7 @@ Page({
|
|||||||
this.setData({ activeTab: id });
|
this.setData({ activeTab: id });
|
||||||
|
|
||||||
if (id === 'all') {
|
if (id === 'all') {
|
||||||
wx.pageScrollTo({
|
wx.pageScrollTo({ scrollTop: 0, duration: 400 });
|
||||||
scrollTop: 0,
|
|
||||||
duration: 400,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,14 +27,21 @@
|
|||||||
<view
|
<view
|
||||||
wx:for="{{featuredItems}}"
|
wx:for="{{featuredItems}}"
|
||||||
wx:key="id"
|
wx:key="id"
|
||||||
class="home-hero-card"
|
class="home-hero-card {{item.skeleton ? 'home-hero-card--skeleton' : ''}}"
|
||||||
data-title="{{item.title}}"
|
data-title="{{item.title}}"
|
||||||
data-path="{{item.path}}"
|
data-path="{{item.path}}"
|
||||||
bindtap="onTapFeatured">
|
bindtap="{{item.skeleton ? '' : 'onTapFeatured'}}">
|
||||||
<view
|
<view class="home-hero-card__top">
|
||||||
class="home-hero-card__top"
|
<image
|
||||||
style="background-color: {{item.topBg}}">
|
wx:if="{{!item.skeleton && item.previewImg}}"
|
||||||
<text class="home-hero-card__icon">{{item.icon}}</text>
|
class="home-hero-card__cover"
|
||||||
|
src="{{item.previewImg}}"
|
||||||
|
mode="aspectFill" />
|
||||||
|
<text
|
||||||
|
wx:if="{{!item.skeleton && !item.previewImg}}"
|
||||||
|
class="home-hero-card__icon"
|
||||||
|
>{{item.icon}}</text
|
||||||
|
>
|
||||||
<text
|
<text
|
||||||
wx:if="{{item.badge}}"
|
wx:if="{{item.badge}}"
|
||||||
class="home-hero-card__badge"
|
class="home-hero-card__badge"
|
||||||
@@ -46,7 +53,9 @@
|
|||||||
<text class="home-hero-card__title"
|
<text class="home-hero-card__title"
|
||||||
>{{item.title}}</text
|
>{{item.title}}</text
|
||||||
>
|
>
|
||||||
<text class="home-hero-card__meta"
|
<text
|
||||||
|
wx:if="{{!item.skeleton}}"
|
||||||
|
class="home-hero-card__meta"
|
||||||
>{{item.ageBand}} · {{item.difficulty}}</text
|
>{{item.ageBand}} · {{item.difficulty}}</text
|
||||||
>
|
>
|
||||||
</view>
|
</view>
|
||||||
@@ -98,14 +107,19 @@
|
|||||||
<view
|
<view
|
||||||
wx:for="{{hotRecommends}}"
|
wx:for="{{hotRecommends}}"
|
||||||
wx:key="id"
|
wx:key="id"
|
||||||
class="home-hot-card"
|
class="home-hot-card {{item.skeleton ? 'home-hot-card--skeleton' : ''}}"
|
||||||
data-title="{{item.title}}"
|
data-title="{{item.title}}"
|
||||||
data-path="{{item.path}}"
|
data-path="{{item.path}}"
|
||||||
bindtap="onTapHotRecommend">
|
bindtap="{{item.skeleton ? '' : 'onTapHotRecommend'}}">
|
||||||
<view
|
<view class="home-hot-card__thumb">
|
||||||
class="home-hot-card__thumb"
|
<image
|
||||||
style="background-color: {{item.topBg}}">
|
wx:if="{{!item.skeleton && item.previewImg}}"
|
||||||
<text class="home-hot-card__thumb-emoji"
|
class="home-hot-card__thumb-img"
|
||||||
|
src="{{item.previewImg}}"
|
||||||
|
mode="aspectFill" />
|
||||||
|
<text
|
||||||
|
wx:if="{{!item.skeleton && !item.previewImg}}"
|
||||||
|
class="home-hot-card__thumb-emoji"
|
||||||
>{{item.icon}}</text
|
>{{item.icon}}</text
|
||||||
>
|
>
|
||||||
</view>
|
</view>
|
||||||
@@ -114,7 +128,9 @@
|
|||||||
<text class="home-hot-card__desc"
|
<text class="home-hot-card__desc"
|
||||||
>{{item.subtitle}}</text
|
>{{item.subtitle}}</text
|
||||||
>
|
>
|
||||||
<view class="home-hot-card__meta">
|
<view
|
||||||
|
wx:if="{{!item.skeleton}}"
|
||||||
|
class="home-hot-card__meta">
|
||||||
<text class="home-hot-card__tag"
|
<text class="home-hot-card__tag"
|
||||||
>{{item.ageBand}}</text
|
>{{item.ageBand}}</text
|
||||||
>
|
>
|
||||||
@@ -123,7 +139,11 @@
|
|||||||
>
|
>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="home-hot-card__action">›</view>
|
<view
|
||||||
|
wx:if="{{!item.skeleton}}"
|
||||||
|
class="home-hot-card__action"
|
||||||
|
>›</view
|
||||||
|
>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -157,13 +177,11 @@
|
|||||||
data-title="{{item.title}}"
|
data-title="{{item.title}}"
|
||||||
data-path="{{item.path}}"
|
data-path="{{item.path}}"
|
||||||
bindtap="onTapTrackCard">
|
bindtap="onTapTrackCard">
|
||||||
<view
|
<view class="home-track-card__top">
|
||||||
class="home-track-card__top"
|
|
||||||
style="background-color: {{item.topBg}}">
|
|
||||||
<image
|
<image
|
||||||
wx:if="{{item.img}}"
|
wx:if="{{item.previewImg}}"
|
||||||
class="home-track-card__cover"
|
class="home-track-card__cover"
|
||||||
src="{{item.img}}"
|
src="{{item.previewImg}}"
|
||||||
mode="widthFix" />
|
mode="widthFix" />
|
||||||
<text wx:else class="home-track-card__icon"
|
<text wx:else class="home-track-card__icon"
|
||||||
>{{item.icon}}</text
|
>{{item.icon}}</text
|
||||||
|
|||||||
@@ -1,13 +1,32 @@
|
|||||||
/// <reference path="./global.d.ts" />
|
/// <reference path="./global.d.ts" />
|
||||||
/// <reference path="./common.d.ts" />
|
/// <reference path="./common.d.ts" />
|
||||||
|
|
||||||
|
interface PageConfigData {
|
||||||
|
home?: Record<string, any>;
|
||||||
|
category?: {
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
categories?: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
items: Array<Record<string, any>>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
age?: Record<string, any>;
|
||||||
|
version?: number;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface IAppOption {
|
interface IAppOption {
|
||||||
globalData: {
|
globalData: {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
userInfo?: WechatMiniprogram.UserInfo;
|
userInfo?: WechatMiniprogram.UserInfo;
|
||||||
env: string;
|
env: string;
|
||||||
printConfig?: PrintConfig;
|
printConfig?: PrintConfig;
|
||||||
|
pageConfig?: PageConfigData;
|
||||||
|
pageConfigReady?: boolean;
|
||||||
};
|
};
|
||||||
userInfoReadyCallback?: WechatMiniprogram.GetUserInfoSuccessCallback;
|
userInfoReadyCallback?: WechatMiniprogram.GetUserInfoSuccessCallback;
|
||||||
|
getPageConfig(): Promise<PageConfigData | null>;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|||||||