feat:完成首页、分类页内容管理功能开发
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"triggers": [
|
||||
{
|
||||
"name": "dailyRefresh",
|
||||
"type": "timer",
|
||||
"config": "0 0 22 * * * *"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
const cloud = require('wx-server-sdk');
|
||||
|
||||
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
|
||||
|
||||
const CONFIG_PATH = 'content/page-config.json';
|
||||
|
||||
const DIFFICULTY_LABELS = {
|
||||
1: '入门',
|
||||
2: '基础',
|
||||
3: '进阶',
|
||||
4: '挑战',
|
||||
};
|
||||
|
||||
function ageBand(min, max) {
|
||||
return `${min}-${max}岁`;
|
||||
}
|
||||
|
||||
function toHomeDisplayItem(ws) {
|
||||
return {
|
||||
id: ws._id,
|
||||
title: ws.title || '',
|
||||
subtitle: ws.subtitle || '',
|
||||
category: ws.category || '',
|
||||
ageBand: ageBand(ws.ageMin || 0, ws.ageMax || 0),
|
||||
difficulty: DIFFICULTY_LABELS[ws.difficulty] || '基础',
|
||||
icon: '',
|
||||
img: ws.previewImg || '',
|
||||
badge: ws.isNew ? 'NEW' : ws.isHot ? 'HOT' : '',
|
||||
path: ws.path || '',
|
||||
available: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function readCurrentConfig() {
|
||||
const db = cloud.database();
|
||||
try {
|
||||
const { data } = await db
|
||||
.collection('page_configs')
|
||||
.doc('current')
|
||||
.get();
|
||||
return data;
|
||||
} catch {
|
||||
return { home: null, category: null, age: null, version: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig(config) {
|
||||
const db = cloud.database();
|
||||
const toSave = { ...config };
|
||||
delete toSave._id;
|
||||
try {
|
||||
await db.collection('page_configs').doc('current').get();
|
||||
await db.collection('page_configs').doc('current').update({
|
||||
data: toSave,
|
||||
});
|
||||
} catch {
|
||||
await db.collection('page_configs').doc('current').set({
|
||||
data: toSave,
|
||||
});
|
||||
}
|
||||
const buffer = Buffer.from(JSON.stringify(config), 'utf-8');
|
||||
await cloud.uploadFile({
|
||||
cloudPath: CONFIG_PATH,
|
||||
fileContent: buffer,
|
||||
});
|
||||
}
|
||||
|
||||
async function isEnabled(db) {
|
||||
try {
|
||||
const { data } = await db
|
||||
.collection('settings')
|
||||
.doc('homeAutoRefresh')
|
||||
.get();
|
||||
return data.enabled !== false;
|
||||
} catch {
|
||||
// Document doesn't exist yet — default to enabled
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
exports.main = async () => {
|
||||
try {
|
||||
const db = cloud.database();
|
||||
|
||||
// Check if timer is enabled
|
||||
if (!(await isEnabled(db))) {
|
||||
return { success: true, message: '定时任务已暂停,跳过执行' };
|
||||
}
|
||||
|
||||
// Query top 5 by downloads
|
||||
const { data: topDownloads } = await db
|
||||
.collection('worksheets')
|
||||
.where({ status: 'active' })
|
||||
.orderBy('downloads', 'desc')
|
||||
.limit(5)
|
||||
.get();
|
||||
|
||||
// Query top 5 by likes
|
||||
const { data: topLikes } = await db
|
||||
.collection('worksheets')
|
||||
.where({ status: 'active' })
|
||||
.orderBy('likes', 'desc')
|
||||
.limit(5)
|
||||
.get();
|
||||
|
||||
const featured = topDownloads.map(toHomeDisplayItem);
|
||||
const hot = topLikes.map(toHomeDisplayItem);
|
||||
|
||||
// Read current config, only update featured & hot
|
||||
const config = await readCurrentConfig();
|
||||
if (!config.home) {
|
||||
config.home = {};
|
||||
}
|
||||
config.home.featured = featured;
|
||||
config.home.hot = hot;
|
||||
config.version = (config.version || 0) + 1;
|
||||
config.updatedAt = new Date().toISOString();
|
||||
|
||||
await saveConfig(config);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
version: config.version,
|
||||
featured: featured.length,
|
||||
hot: hot.length,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
error instanceof Error ? error.message : '自动刷新首页推荐失败',
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "home-auto-refresh",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"wx-server-sdk": "^3.0.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
const cloud = require('wx-server-sdk');
|
||||
|
||||
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
|
||||
|
||||
exports.main = async (event) => {
|
||||
try {
|
||||
const db = cloud.database();
|
||||
const enabled = !!event.enabled;
|
||||
|
||||
const collection = db.collection('settings');
|
||||
const docId = 'homeAutoRefresh';
|
||||
|
||||
try {
|
||||
await collection.doc(docId).get();
|
||||
await collection.doc(docId).update({
|
||||
data: { enabled, updatedAt: db.serverDate() },
|
||||
});
|
||||
} catch {
|
||||
// Document doesn't exist, create it
|
||||
await collection.doc(docId).set({
|
||||
data: { enabled, updatedAt: db.serverDate() },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { enabled },
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: '更新定时任务状态失败',
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "home-timer-control",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"wx-server-sdk": "^3.0.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
const cloud = require('wx-server-sdk');
|
||||
|
||||
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
|
||||
|
||||
exports.main = async () => {
|
||||
try {
|
||||
const db = cloud.database();
|
||||
const { data } = await db
|
||||
.collection('page_configs')
|
||||
.doc('current')
|
||||
.get();
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
error instanceof Error ? error.message : '获取页面配置失败',
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "page-config-fetch",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"wx-server-sdk": "^3.0.4"
|
||||
}
|
||||
}
|
||||
@@ -36,15 +36,68 @@ function toDisplayItem(ws) {
|
||||
};
|
||||
}
|
||||
|
||||
function toHomeDisplayItem(ws) {
|
||||
return {
|
||||
id: ws._id,
|
||||
title: ws.title || '',
|
||||
subtitle: ws.subtitle || '',
|
||||
category: ws.category || '',
|
||||
ageBand: ageBand(ws.ageMin || 0, ws.ageMax || 0),
|
||||
difficulty: DIFFICULTY_LABELS[ws.difficulty] || '基础',
|
||||
icon: '',
|
||||
img: ws.previewImg || '',
|
||||
badge: ws.isNew ? 'NEW' : ws.isHot ? 'HOT' : '',
|
||||
path: ws.path || '',
|
||||
available: true,
|
||||
};
|
||||
}
|
||||
|
||||
const HOME_AGE_BANDS = [
|
||||
{ key: '3-4', label: '3-4 岁', desc: '启蒙认知', path: '/pages/age/age' },
|
||||
{ key: '4-5', label: '4-5 岁', desc: '基础练习', path: '/pages/age/age' },
|
||||
{ key: '5-6', label: '5-6 岁', desc: '能力提升', path: '/pages/age/age' },
|
||||
{ key: '6-7', label: '6-7 岁', desc: '幼小衔接', path: '/pages/age/age' },
|
||||
{ key: '7-8', label: '7-8 岁', desc: '知识拓展', path: '/pages/age/age' },
|
||||
];
|
||||
|
||||
async function readCurrentConfig() {
|
||||
const db = cloud.database();
|
||||
try {
|
||||
const res = await cloud.downloadFile({ fileID: CONFIG_PATH });
|
||||
return JSON.parse(res.fileContent.toString('utf-8'));
|
||||
const { data } = await db
|
||||
.collection('page_configs')
|
||||
.doc('current')
|
||||
.get();
|
||||
return data;
|
||||
} catch {
|
||||
return { home: null, category: null, age: null, version: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig(config) {
|
||||
const db = cloud.database();
|
||||
|
||||
// 1. Save to database (source of truth)
|
||||
const toSave = { ...config };
|
||||
delete toSave._id; // _id can't be in update data
|
||||
try {
|
||||
await db.collection('page_configs').doc('current').get();
|
||||
await db.collection('page_configs').doc('current').update({
|
||||
data: toSave,
|
||||
});
|
||||
} catch {
|
||||
await db.collection('page_configs').doc('current').set({
|
||||
data: toSave,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Upload to cloud storage (for mini-program pre-fetch)
|
||||
const buffer = Buffer.from(JSON.stringify(config), 'utf-8');
|
||||
await cloud.uploadFile({
|
||||
cloudPath: CONFIG_PATH,
|
||||
fileContent: buffer,
|
||||
});
|
||||
}
|
||||
|
||||
async function buildCategoryData(db) {
|
||||
// Get all categories
|
||||
const { data: categories } = await db
|
||||
@@ -87,38 +140,126 @@ async function buildCategoryData(db) {
|
||||
return { categoryData, stats };
|
||||
}
|
||||
|
||||
async function buildHomeData(db, event) {
|
||||
const featuredIds = Array.isArray(event.featuredIds) ? event.featuredIds : [];
|
||||
const hotIds = Array.isArray(event.hotIds) ? event.hotIds : [];
|
||||
const sectionConfigs = Array.isArray(event.sections) ? event.sections : [];
|
||||
|
||||
// Collect all referenced worksheet ids
|
||||
const allIds = new Set([
|
||||
...featuredIds,
|
||||
...hotIds,
|
||||
...sectionConfigs.flatMap((s) => s.worksheetIds || []),
|
||||
]);
|
||||
|
||||
// Batch query all referenced worksheets
|
||||
const wsMap = {};
|
||||
if (allIds.size > 0) {
|
||||
const _ = db.command;
|
||||
const { data } = await db
|
||||
.collection('worksheets')
|
||||
.where({ _id: _.in([...allIds]) })
|
||||
.limit(100)
|
||||
.get();
|
||||
for (const ws of data) {
|
||||
wsMap[ws._id] = ws;
|
||||
}
|
||||
}
|
||||
|
||||
// Build categoryTabs from categories collection
|
||||
const { data: categories } = await db
|
||||
.collection('categories')
|
||||
.orderBy('sortOrder', 'asc')
|
||||
.get();
|
||||
const categoryTabs = [
|
||||
{ id: 'all', name: '全部' },
|
||||
...categories.map((c) => ({ id: c._id, name: c.name || '' })),
|
||||
];
|
||||
|
||||
// Build featured / hot arrays
|
||||
const featured = featuredIds
|
||||
.filter((id) => wsMap[id])
|
||||
.map((id) => toHomeDisplayItem(wsMap[id]));
|
||||
const hot = hotIds
|
||||
.filter((id) => wsMap[id])
|
||||
.map((id) => toHomeDisplayItem(wsMap[id]));
|
||||
|
||||
// Build sections
|
||||
const sections = sectionConfigs
|
||||
.filter((s) => s.id && Array.isArray(s.worksheetIds))
|
||||
.map((s) => {
|
||||
const cat = categories.find((c) => c._id === s.id);
|
||||
return {
|
||||
id: s.id,
|
||||
title: cat ? cat.name : s.id,
|
||||
subtitle: s.subtitle || '',
|
||||
morePath: s.morePath || '',
|
||||
items: (s.worksheetIds || [])
|
||||
.filter((id) => wsMap[id])
|
||||
.map((id) => toHomeDisplayItem(wsMap[id])),
|
||||
};
|
||||
});
|
||||
|
||||
const homeData = {
|
||||
searchPlaceholder: '搜索你喜欢的练习册...',
|
||||
categoryTabs,
|
||||
ageBands: HOME_AGE_BANDS,
|
||||
featured,
|
||||
hot,
|
||||
sections,
|
||||
};
|
||||
|
||||
return { homeData };
|
||||
}
|
||||
|
||||
exports.main = async (event) => {
|
||||
try {
|
||||
const db = cloud.database();
|
||||
const page = String(event.page || '').trim();
|
||||
|
||||
if (page !== 'category') {
|
||||
throw new Error('当前仅支持 page=category');
|
||||
if (page !== 'category' && page !== 'home') {
|
||||
throw new Error('page 参数不合法,支持 category / home');
|
||||
}
|
||||
|
||||
const { categoryData, stats } = await buildCategoryData(db);
|
||||
|
||||
// Read current config, merge, bump version
|
||||
const config = await readCurrentConfig();
|
||||
config.category = categoryData;
|
||||
config.version = (config.version || 0) + 1;
|
||||
config.updatedAt = new Date().toISOString();
|
||||
|
||||
// Upload to cloud storage
|
||||
const buffer = Buffer.from(JSON.stringify(config), 'utf-8');
|
||||
await cloud.uploadFile({
|
||||
cloudPath: CONFIG_PATH,
|
||||
fileContent: buffer,
|
||||
});
|
||||
if (page === 'category') {
|
||||
const { categoryData, stats } = await buildCategoryData(db);
|
||||
config.category = categoryData;
|
||||
config.version = (config.version || 0) + 1;
|
||||
config.updatedAt = new Date().toISOString();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
version: config.version,
|
||||
updatedAt: config.updatedAt,
|
||||
stats,
|
||||
},
|
||||
};
|
||||
await saveConfig(config);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
version: config.version,
|
||||
updatedAt: config.updatedAt,
|
||||
stats,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (page === 'home') {
|
||||
const { homeData } = await buildHomeData(db, event);
|
||||
config.home = homeData;
|
||||
config.version = (config.version || 0) + 1;
|
||||
config.updatedAt = new Date().toISOString();
|
||||
|
||||
await saveConfig(config);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
version: config.version,
|
||||
updatedAt: config.updatedAt,
|
||||
featured: homeData.featured.length,
|
||||
hot: homeData.hot.length,
|
||||
sections: homeData.sections.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -3,11 +3,11 @@ const cloud = require('wx-server-sdk');
|
||||
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
|
||||
|
||||
const VALID_STATUS = new Set(['draft', 'active', 'hidden']);
|
||||
const STATUS_ORDER = { draft: 0, hidden: 1, active: 2 };
|
||||
|
||||
exports.main = async (event) => {
|
||||
try {
|
||||
const db = cloud.database();
|
||||
const _ = db.command;
|
||||
const collection = db.collection('worksheets');
|
||||
|
||||
const category = String(event.category || '').trim();
|
||||
@@ -26,6 +26,13 @@ exports.main = async (event) => {
|
||||
.limit(100)
|
||||
.get();
|
||||
|
||||
// Sort by status order: draft → hidden → active
|
||||
data.sort((a, b) => {
|
||||
const orderA = STATUS_ORDER[a.status] ?? 99;
|
||||
const orderB = STATUS_ORDER[b.status] ?? 99;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
// Compute stats for the category (all statuses)
|
||||
let allData = data;
|
||||
if (status) {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
- [ ] 首页今日推荐、热门推荐创建定时任务,每日获取最新前 5 名并更新数据预拉取数据
|
||||
@@ -0,0 +1,36 @@
|
||||
## 现有名称的问题
|
||||
|
||||
「数感识字幼儿专注学习卡」堆了四个关键词,读起来像 SEO 标题,而且只覆盖了 math 和 chinese
|
||||
两个品类,英语、益智、手工都没体现。
|
||||
|
||||
## 「育儿打印资料」的问题
|
||||
|
||||
- 「资料」偏正式,和「涂鸦丫」的童趣调性不搭
|
||||
- 「育儿」太宽泛,涵盖了喂养、睡眠、情绪管理等,用户搜索「育儿」时预期和你的产品不一定匹配
|
||||
- 没有体现核心场景:给 3-8 岁孩子打印练习纸
|
||||
|
||||
## 几个方向供参考
|
||||
|
||||
┌─────────────────────────┬──────────────────────────────────────────────────┐
|
||||
│ 方案 │ 思路 │
|
||||
├─────────────────────────┼──────────────────────────────────────────────────┤
|
||||
│ 涂鸦丫-幼儿打印练习纸 │ 直接说清楚产品是什么,「练习纸」比「资料」具体 │
|
||||
├─────────────────────────┼──────────────────────────────────────────────────┤
|
||||
│ 涂鸦丫-儿童打印学习卡 │ 保留「学习卡」的说法,去掉堆砌的品类词 │
|
||||
├─────────────────────────┼──────────────────────────────────────────────────┤
|
||||
│ 涂鸦丫-幼儿启蒙打印乐园 │ 偏品牌感,「启蒙」点明年龄段,「乐园」呼应趣味性 │
|
||||
├─────────────────────────┼──────────────────────────────────────────────────┤
|
||||
│ 涂鸦丫-宝宝练习纸打印 │ 口语化,家长搜索习惯更接近「宝宝 + 练习纸」 │
|
||||
└─────────────────────────┴──────────────────────────────────────────────────┘
|
||||
|
||||
## 我的建议
|
||||
|
||||
优先考虑「涂鸦丫-幼儿打印练习纸」或「涂鸦丫-儿童打印学习卡」。理由:
|
||||
|
||||
1. 「幼儿/儿童」比「育儿」更精准地指向你的用户群体(家长为孩子找内容)
|
||||
2. 「打印」是你的核心差异点,必须保留
|
||||
3. 「练习纸/学习卡」比「资料」更具象,用户一看就知道能得到什么
|
||||
4. 不堆品类词,品广度让用户进来后自己发现
|
||||
|
||||
微信搜索里,用户更可能搜「幼儿练习纸」「打印学习卡」「数学练习纸打印」这类词,副标题里有「打印」+「练习纸/学习卡」的组合命中
|
||||
率会更高。
|
||||
+40
-14
@@ -125,30 +125,44 @@ type PageContentUpdatePayload = {
|
||||
|
||||
### 3.1 管理 UI
|
||||
|
||||
首页管理 tab 分为四个区块:
|
||||
首页管理页分为以下区块:
|
||||
|
||||
|
||||
| 区块 | 对应字段 | 管理方式 |
|
||||
| ------ | -------------- | ----------------------------------- |
|
||||
| 分类 tab | `categoryTabs` | 跟随 `CATEGORY_LIST_WITH_ALL`,一般不单独管理 |
|
||||
| 年龄入口 | `ageBands` | 跟随 `AGE_BANDS`,可管理描述文案 |
|
||||
| 今日推荐 | `featured` | 手动选择 3-5 个 active worksheet |
|
||||
| 热门推荐 | `hot` | 手动选择或按 downloads 自动生成 |
|
||||
| 今日推荐 | `featured` | 自动取 downloads 最多的 5 个 active worksheet,支持手动微调 |
|
||||
| 热门推荐 | `hot` | 自动取 likes 最多的 5 个 active worksheet,支持手动微调 |
|
||||
| 分类分区 | `sections` | 每个分类选择若干 active worksheet,支持排序 |
|
||||
|
||||
|
||||
交互流程:
|
||||
### 3.2 自动刷新与定时任务
|
||||
|
||||
今日推荐和热门推荐采用**自动化 + 手动微调**的方式:
|
||||
|
||||
- **默认数据**:页面加载时自动查询 active worksheets,按 `downloads desc` 取前 5 填入 featured,按 `likes desc` 取前 5 填入 hot
|
||||
- **定时任务**:云函数 `homeAutoRefresh` 配置定时触发器,每日 22:00 自动执行,查询最新 top5 数据更新 `page-config.json` 中的 `home.featured` 和 `home.hot`
|
||||
- **开关控制**:通过 `settings` 集合中 `homeAutoRefresh` 文档的 `enabled` 字段控制。管理页提供暂停/启动按钮,调用 `homeTimerControl` 云函数切换状态。`homeAutoRefresh` 执行时先检查此标记,`enabled === false` 则跳过
|
||||
- **手动微调**:用户可在自动填充的基础上手动添加/移除 worksheet,点击「更新首页数据」手动生成
|
||||
|
||||
```
|
||||
首页 tab 展示当前配置
|
||||
→ 每个位置点击「选择内容」
|
||||
→ 弹出 worksheet 选择器(筛选 active 内容)
|
||||
→ 保存配置
|
||||
→ 点击「更新首页数据」
|
||||
→ 调用 pageContentUpdate({ page: 'home', data: ... })
|
||||
管理页 onLoad
|
||||
→ 查询 active worksheets
|
||||
→ 按 downloads desc 取前 5 → featured
|
||||
→ 按 likes desc 取前 5 → hot
|
||||
→ 用户可手动微调
|
||||
→ 点击「更新首页数据」→ pageContentBuild({ page: 'home', ... })
|
||||
|
||||
定时任务(每日 22:00)
|
||||
→ homeAutoRefresh 云函数
|
||||
→ 检查 settings/homeAutoRefresh.enabled
|
||||
→ 查询 top5 downloads → featured
|
||||
→ 查询 top5 likes → hot
|
||||
→ 更新 page-config.json 中 home.featured 和 home.hot
|
||||
```
|
||||
|
||||
### 3.2 数据结构
|
||||
### 3.3 数据结构
|
||||
|
||||
```ts
|
||||
type HomePageData = {
|
||||
@@ -168,11 +182,21 @@ type HomePageData = {
|
||||
| -------------- | ---------------------------------------------- |
|
||||
| `categoryTabs` | 由 `CATEGORY_LIST_WITH_ALL` 生成 |
|
||||
| `ageBands` | 由 `AGE_BANDS` 生成 |
|
||||
| `featured` | 优先使用手动选择,不足时补 `isNew == true` |
|
||||
| `hot` | 优先使用手动选择,不足时补 `isHot == true` 或 downloads 高的内容 |
|
||||
| `featured` | 自动:downloads 最多的 5 个 active worksheet |
|
||||
| `hot` | 自动:likes 最多的 5 个 active worksheet |
|
||||
| `sections` | 使用配置中的 worksheetIds,不足时从该分类 active 内容补齐 |
|
||||
|
||||
|
||||
### 3.4 相关云函数
|
||||
|
||||
|
||||
| 云函数 | 职责 |
|
||||
| ------------------- | ------------------------------------------- |
|
||||
| `pageContentBuild` | 手动更新首页全量数据(featured + hot + sections) |
|
||||
| `homeAutoRefresh` | 定时任务,每日 22:00 自动更新 featured 和 hot |
|
||||
| `homeTimerControl` | 暂停/启动定时任务(更新 settings 集合标记位) |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 四、分类页内容管理
|
||||
@@ -445,7 +469,9 @@ supportPages/debug/debug
|
||||
| 文件 / 模块 | 职责 |
|
||||
| ----------------------------------- | ------------------------------ |
|
||||
| `supportPages/contentManage/` | 三 tab 内容管理页 |
|
||||
| `cloudfunctions/pageContentUpdate/` | 更新 `page-config.json` 中指定页面的配置 |
|
||||
| `cloudfunctions/pageContentBuild/` | 生成 `page-config.json` 中指定页面的配置 |
|
||||
| `cloudfunctions/homeAutoRefresh/` | 定时任务,每日自动更新首页推荐位数据 |
|
||||
| `cloudfunctions/homeTimerControl/` | 暂停/启动首页定时刷新任务 |
|
||||
| `pages/age/age.config.ts` | 分龄页兜底配置(年龄段、能力目标、默认 worksheet) |
|
||||
| `pages/home/home.data.ts` | 首页兜底数据 |
|
||||
| `pages/category/category.data.ts` | 分类页兜底数据 |
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"debug/debug",
|
||||
"categoryManage/categoryManage",
|
||||
"categoryContentManage/categoryContentManage",
|
||||
"homeContentManage/homeContentManage",
|
||||
"index/index",
|
||||
"mathIndex/mathIndex",
|
||||
"focusIndex/focusIndex",
|
||||
|
||||
@@ -4,15 +4,46 @@ export const ALL_CATEGORY: CategoryType = {
|
||||
id: 'all',
|
||||
name: '全部',
|
||||
icon: '📋',
|
||||
path: '/pages/category/category?id=all',
|
||||
};
|
||||
|
||||
export const CATEGORY_LIST: CategoryType[] = [
|
||||
{ id: 'math', name: '数感启蒙', icon: '📐' },
|
||||
{ id: 'puzzle', name: '益智游戏', icon: '🧩' },
|
||||
{ id: 'pinyin', name: '汉语拼音', icon: '🔤' },
|
||||
{ id: 'chinese', name: '趣味识字', icon: '✏️' },
|
||||
{ id: 'english', name: '英语启蒙', icon: '🔤' },
|
||||
{ id: 'craft', name: '创意手工', icon: '🌈' },
|
||||
{
|
||||
id: 'math',
|
||||
name: '数感启蒙',
|
||||
icon: '📐',
|
||||
path: '/pages/category/category?id=math',
|
||||
},
|
||||
{
|
||||
id: 'puzzle',
|
||||
name: '益智游戏',
|
||||
icon: '🧩',
|
||||
path: '/pages/category/category?id=puzzle',
|
||||
},
|
||||
{
|
||||
id: 'pinyin',
|
||||
name: '汉语拼音',
|
||||
icon: '🔤',
|
||||
path: '/pages/category/category?id=pinyin',
|
||||
},
|
||||
{
|
||||
id: 'chinese',
|
||||
name: '趣味识字',
|
||||
icon: '✏️',
|
||||
path: '/pages/category/category?id=chinese',
|
||||
},
|
||||
{
|
||||
id: 'english',
|
||||
name: '英语启蒙',
|
||||
icon: '🔤',
|
||||
path: '/pages/category/category?id=english',
|
||||
},
|
||||
{
|
||||
id: 'craft',
|
||||
name: '创意手工',
|
||||
icon: '🌈',
|
||||
path: '/pages/category/category?id=craft',
|
||||
},
|
||||
];
|
||||
|
||||
export const CATEGORY_LIST_WITH_ALL: CategoryType[] = [
|
||||
|
||||
@@ -10,6 +10,7 @@ export type CategoryId =
|
||||
export interface CategoryType {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
icon: string;
|
||||
img?: string;
|
||||
color?: string;
|
||||
|
||||
@@ -67,6 +67,17 @@ Page({
|
||||
scrollIntoViewId: '',
|
||||
},
|
||||
|
||||
onLoad(options: Record<string, string>) {
|
||||
const id = options.id;
|
||||
if (id && id !== 'all') {
|
||||
const items = getItemsByCategory(id);
|
||||
this.setData({
|
||||
activeCategoryId: id,
|
||||
displayItems: items,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onTapCategory(e: WechatMiniprogram.TouchEvent) {
|
||||
const id = e.currentTarget.dataset.id as string;
|
||||
if (!id || id === this.data.activeCategoryId) return;
|
||||
|
||||
@@ -62,7 +62,7 @@ page {
|
||||
box-shadow: @shadow;
|
||||
}
|
||||
|
||||
.ccm-stat + .ccm-stat {
|
||||
.ccm-stat+.ccm-stat {
|
||||
margin-left: 16rpx;
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ page {
|
||||
// ── Worksheet List ──
|
||||
.ccm-page__list {
|
||||
margin-top: 24rpx;
|
||||
margin-bottom: 80rpx;
|
||||
}
|
||||
|
||||
.ws-card {
|
||||
@@ -125,11 +126,12 @@ page {
|
||||
}
|
||||
|
||||
.ws-card__img {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
width: 140rpx;
|
||||
height: 180rpx;
|
||||
border-radius: @radius;
|
||||
flex-shrink: 0;
|
||||
background: @bg-gray;
|
||||
border: @border;
|
||||
}
|
||||
|
||||
.ws-card__img--placeholder {
|
||||
@@ -144,6 +146,9 @@ page {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-left: 20rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.ws-card__title {
|
||||
@@ -158,7 +163,6 @@ page {
|
||||
|
||||
.ws-card__subtitle {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 22rpx;
|
||||
color: @text-secondary;
|
||||
overflow: hidden;
|
||||
@@ -182,11 +186,20 @@ page {
|
||||
|
||||
.ws-card__id {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 20rpx;
|
||||
font-size: 22rpx;
|
||||
color: @text-gray;
|
||||
}
|
||||
|
||||
.ws-card__tags {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.ws-card__tags-text {
|
||||
font-size: 22rpx;
|
||||
color: @text-secondary;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ws-card__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -196,10 +209,13 @@ page {
|
||||
}
|
||||
|
||||
.ws-card__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6rpx 16rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ws-card__badge--active {
|
||||
@@ -234,4 +250,4 @@ page {
|
||||
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
|
||||
background: @bg-header;
|
||||
box-shadow: 0 -4rpx 16rpx rgba(50, 46, 37, 0.06);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ type WorksheetItem = {
|
||||
ageMax: number;
|
||||
difficulty: 1 | 2 | 3 | 4;
|
||||
tags: string[];
|
||||
tagsText: string;
|
||||
status: 'draft' | 'active' | 'hidden';
|
||||
sortOrder: number;
|
||||
updatedAt: string;
|
||||
@@ -19,6 +20,7 @@ type WorksheetItem = {
|
||||
statusText: string;
|
||||
actionText: string;
|
||||
actionTarget: string;
|
||||
actionType: string;
|
||||
};
|
||||
|
||||
type Stats = {
|
||||
@@ -44,11 +46,11 @@ const DIFFICULTY_LABELS: Record<number, string> = {
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
string,
|
||||
{ text: string; action: string; target: string }
|
||||
{ text: string; action: string; target: string; actionType: string }
|
||||
> = {
|
||||
draft: { text: '草稿', action: '激活', target: 'active' },
|
||||
active: { text: '线上', action: '下架', target: 'hidden' },
|
||||
hidden: { text: '已隐藏', action: '恢复', target: 'draft' },
|
||||
draft: { text: '草稿', action: '激活', target: 'active', actionType: 'green' },
|
||||
active: { text: '线上', action: '下架', target: 'hidden', actionType: 'default' },
|
||||
hidden: { text: '已隐藏', action: '恢复', target: 'draft', actionType: 'primary' },
|
||||
};
|
||||
|
||||
function formatWorksheet(raw: Record<string, unknown>): WorksheetItem {
|
||||
@@ -68,6 +70,7 @@ function formatWorksheet(raw: Record<string, unknown>): WorksheetItem {
|
||||
ageMax,
|
||||
difficulty,
|
||||
tags: Array.isArray(raw.tags) ? raw.tags.map(String) : [],
|
||||
tagsText: Array.isArray(raw.tags) ? raw.tags.map(String).join('、') : '',
|
||||
status: status as WorksheetItem['status'],
|
||||
sortOrder: Number(raw.sortOrder) || 0,
|
||||
updatedAt: raw.updatedAt ? String(raw.updatedAt) : '',
|
||||
@@ -76,6 +79,7 @@ function formatWorksheet(raw: Record<string, unknown>): WorksheetItem {
|
||||
statusText: cfg.text,
|
||||
actionText: cfg.action,
|
||||
actionTarget: cfg.target,
|
||||
actionType: cfg.actionType,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -59,21 +59,26 @@
|
||||
</view>
|
||||
<view class="ws-card__info">
|
||||
<text class="ws-card__title">{{item.title}}</text>
|
||||
<text class="ws-card__id">{{item._id}}</text>
|
||||
<text class="ws-card__subtitle">{{item.subtitle}}</text>
|
||||
<view class="ws-card__meta">
|
||||
<text class="ws-card__tag">{{item.ageBand}}</text>
|
||||
<text class="ws-card__tag">{{item.difficultyLabel}}</text>
|
||||
<text class="ws-card__tag"
|
||||
>{{item.difficultyLabel}}</text
|
||||
>
|
||||
</view>
|
||||
<text class="ws-card__id">{{item._id}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view wx:if="{{item.tagsText}}" class="ws-card__tags">
|
||||
<text class="ws-card__tags-text">{{item.tagsText}}</text>
|
||||
</view>
|
||||
<view class="ws-card__footer">
|
||||
<text class="ws-card__badge ws-card__badge--{{item.status}}">
|
||||
{{item.statusText}}
|
||||
</text>
|
||||
<view class="ws-card__badge ws-card__badge--{{item.status}}">
|
||||
<text>{{item.statusText}}</text>
|
||||
</view>
|
||||
<text class="ws-card__sort">排序 {{item.sortOrder}}</text>
|
||||
<toy-button
|
||||
type="primary"
|
||||
type="{{item.actionType}}"
|
||||
size="small"
|
||||
data-id="{{item._id}}"
|
||||
data-target="{{item.actionTarget}}"
|
||||
@@ -89,10 +94,11 @@
|
||||
<view class="ccm-page__build">
|
||||
<toy-button
|
||||
type="primary"
|
||||
width="100%"
|
||||
loading="{{building}}"
|
||||
disabled="{{building}}"
|
||||
bindtap="onBuildTap">
|
||||
{{building ? '生成中...' : '更新分类页数据'}}
|
||||
</toy-button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -21,6 +21,13 @@ const DEBUG_ENTRIES: DebugEntry[] = [
|
||||
icon: '📋',
|
||||
path: '/supportPages/categoryContentManage/categoryContentManage',
|
||||
},
|
||||
{
|
||||
id: 'home-content',
|
||||
title: '首页内容管理',
|
||||
subtitle: '编排首页推荐位、热门推荐和分类分区内容。',
|
||||
icon: '🏠',
|
||||
path: '/supportPages/homeContentManage/homeContentManage',
|
||||
},
|
||||
];
|
||||
|
||||
Page({
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"navigationBarTitleText": "首页内容管理",
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarBackgroundColor": "#F8F0E0",
|
||||
"backgroundColor": "#F8F0E0",
|
||||
"enablePullDownRefresh": true,
|
||||
"usingComponents": {
|
||||
"toy-button": "/toy/button-v2/button"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
@import '../../style/theme.less';
|
||||
|
||||
page {
|
||||
min-height: 100%;
|
||||
background: @bg-header;
|
||||
}
|
||||
|
||||
.hcm-page {
|
||||
min-height: 100vh;
|
||||
padding: 24rpx;
|
||||
padding-bottom: 160rpx;
|
||||
background: @bg-header;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// ── Loading ──
|
||||
.hcm-page__loading {
|
||||
padding: 24rpx 28rpx;
|
||||
background: @bg-white;
|
||||
border-radius: @radius;
|
||||
font-size: 24rpx;
|
||||
color: @text-secondary;
|
||||
box-shadow: @shadow;
|
||||
}
|
||||
|
||||
// ── Section ──
|
||||
.hcm-timer-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24rpx;
|
||||
padding: 28rpx;
|
||||
background: @bg-white;
|
||||
border-radius: @radius-xl;
|
||||
box-shadow: @shadow;
|
||||
}
|
||||
|
||||
.hcm-timer-card__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hcm-timer-card__title {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: @text-title;
|
||||
}
|
||||
|
||||
.hcm-timer-card__desc {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: @text-secondary;
|
||||
}
|
||||
|
||||
.hcm-timer-card__right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
flex-shrink: 0;
|
||||
margin-left: 20rpx;
|
||||
}
|
||||
|
||||
.hcm-timer-card__badge {
|
||||
padding: 6rpx 16rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hcm-timer-card__badge--on {
|
||||
background: fade(#93d333, 18%);
|
||||
color: #4a7a12;
|
||||
}
|
||||
|
||||
.hcm-timer-card__badge--off {
|
||||
background: fade(#8a8478, 15%);
|
||||
color: #605b50;
|
||||
}
|
||||
|
||||
.hcm-section {
|
||||
margin-bottom: 24rpx;
|
||||
padding: 28rpx;
|
||||
background: @bg-white;
|
||||
border-radius: @radius-xl;
|
||||
box-shadow: @shadow;
|
||||
|
||||
&.last {
|
||||
margin-bottom: 60rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.hcm-section__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.hcm-section__title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: @text-title;
|
||||
}
|
||||
|
||||
.hcm-section__count {
|
||||
font-size: 22rpx;
|
||||
color: @text-gray;
|
||||
}
|
||||
|
||||
.hcm-section__hint {
|
||||
margin-left: 12rpx;
|
||||
font-size: 22rpx;
|
||||
color: @text-gray;
|
||||
}
|
||||
|
||||
.hcm-section__empty {
|
||||
padding: 20rpx 0;
|
||||
font-size: 24rpx;
|
||||
color: @text-gray;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
// ── Item List ──
|
||||
.hcm-item-list {
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.hcm-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16rpx 0;
|
||||
border-bottom: @border;
|
||||
}
|
||||
|
||||
.hcm-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.hcm-item__img {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: @radius-sm;
|
||||
flex-shrink: 0;
|
||||
background: @bg-gray;
|
||||
}
|
||||
|
||||
.hcm-item__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-left: 16rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.hcm-item__title {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: @text-title;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hcm-item__id {
|
||||
font-size: 20rpx;
|
||||
color: @text-gray;
|
||||
}
|
||||
|
||||
// ── Build Button ──
|
||||
.hcm-page__build {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 24rpx 40rpx;
|
||||
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
|
||||
background: @bg-header;
|
||||
box-shadow: 0 -4rpx 16rpx rgba(50, 46, 37, 0.06);
|
||||
}
|
||||
|
||||
// ── Selector Popup ──
|
||||
.hcm-selector-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.hcm-selector {
|
||||
width: 100%;
|
||||
max-height: 70vh;
|
||||
background: @bg-white;
|
||||
border-radius: @radius-xl @radius-xl 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hcm-selector__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 28rpx 32rpx;
|
||||
border-bottom: @border;
|
||||
}
|
||||
|
||||
.hcm-selector__title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: @text-title;
|
||||
}
|
||||
|
||||
.hcm-selector__close {
|
||||
font-size: 26rpx;
|
||||
color: @text-secondary;
|
||||
padding: 8rpx 16rpx;
|
||||
}
|
||||
|
||||
.hcm-selector__list {
|
||||
flex: 1;
|
||||
max-height: 60vh;
|
||||
padding: 0 32rpx;
|
||||
}
|
||||
|
||||
.hcm-selector__empty {
|
||||
padding: 40rpx 0;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
color: @text-gray;
|
||||
}
|
||||
|
||||
.hcm-selector-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20rpx 0;
|
||||
border-bottom: @border;
|
||||
}
|
||||
|
||||
.hcm-selector-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.hcm-selector-item__img {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: @radius-sm;
|
||||
flex-shrink: 0;
|
||||
background: @bg-gray;
|
||||
}
|
||||
|
||||
.hcm-selector-item__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-left: 16rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.hcm-selector-item__title {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: @text-title;
|
||||
}
|
||||
|
||||
.hcm-selector-item__sub {
|
||||
font-size: 22rpx;
|
||||
color: @text-secondary;
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import { CATEGORY_LIST } from '../../core/data/categories';
|
||||
|
||||
type SimpleWorksheet = {
|
||||
_id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
category: string;
|
||||
previewImg: string;
|
||||
ageMin: number;
|
||||
ageMax: number;
|
||||
difficulty: number;
|
||||
isNew: boolean;
|
||||
isHot: boolean;
|
||||
path: string;
|
||||
ageBand: string;
|
||||
downloads: number;
|
||||
likes: number;
|
||||
};
|
||||
|
||||
type SectionConfig = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
subtitle?: string;
|
||||
morePath?: string;
|
||||
items: SimpleWorksheet[];
|
||||
};
|
||||
|
||||
type HomeConfigItem = {
|
||||
id: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
morePath?: string;
|
||||
items: Array<{ id: string; path?: string; [key: string]: unknown }>;
|
||||
};
|
||||
|
||||
type PageConfig = {
|
||||
home?: {
|
||||
featured?: Array<{ id: string; [key: string]: unknown }>;
|
||||
hot?: Array<{ id: string; [key: string]: unknown }>;
|
||||
sections?: HomeConfigItem[];
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type CloudFunctionResult<T> = {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
data?: T;
|
||||
};
|
||||
|
||||
function formatSimple(raw: Record<string, unknown>): SimpleWorksheet {
|
||||
const ageMin = Number(raw.ageMin) || 0;
|
||||
const ageMax = Number(raw.ageMax) || 0;
|
||||
return {
|
||||
_id: String(raw._id || ''),
|
||||
title: String(raw.title || ''),
|
||||
subtitle: String(raw.subtitle || ''),
|
||||
category: String(raw.category || ''),
|
||||
previewImg: String(raw.previewImg || ''),
|
||||
ageMin,
|
||||
ageMax,
|
||||
difficulty: Number(raw.difficulty) || 2,
|
||||
isNew: !!raw.isNew,
|
||||
isHot: !!raw.isHot,
|
||||
path: String(raw.path || ''),
|
||||
ageBand: `${ageMin}-${ageMax}岁`,
|
||||
downloads: Number(raw.downloads) || 0,
|
||||
likes: Number(raw.likes) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function callCloudFunction<T>(
|
||||
name: string,
|
||||
data?: Record<string, unknown>,
|
||||
): Promise<CloudFunctionResult<T>> {
|
||||
const response = (await wx.cloud.callFunction({
|
||||
name,
|
||||
data: data || {},
|
||||
})) as { result?: CloudFunctionResult<T> };
|
||||
return response.result || {};
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
featured: [] as SimpleWorksheet[],
|
||||
hot: [] as SimpleWorksheet[],
|
||||
sections: [] as SectionConfig[],
|
||||
// worksheet pool (all active)
|
||||
allActive: [] as SimpleWorksheet[],
|
||||
// selector popup
|
||||
selectorVisible: false,
|
||||
selectorTarget: '', // 'featured' | 'hot' | category id
|
||||
selectorTargetLabel: '',
|
||||
selectorItems: [] as SimpleWorksheet[],
|
||||
// timer
|
||||
timerEnabled: true,
|
||||
timerLoading: false,
|
||||
// states
|
||||
loading: false,
|
||||
building: false,
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.setData({
|
||||
sections: CATEGORY_LIST.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
icon: c.icon,
|
||||
items: [],
|
||||
})),
|
||||
});
|
||||
void this.loadData();
|
||||
void this.loadTimerStatus();
|
||||
},
|
||||
|
||||
async onPullDownRefresh() {
|
||||
await this.loadData();
|
||||
await this.loadTimerStatus();
|
||||
wx.stopPullDownRefresh();
|
||||
},
|
||||
|
||||
async loadTimerStatus() {
|
||||
try {
|
||||
const db = wx.cloud.database();
|
||||
const { data } = await db
|
||||
.collection('settings')
|
||||
.doc('homeAutoRefresh')
|
||||
.get();
|
||||
this.setData({ timerEnabled: (data as { enabled?: boolean }).enabled !== false });
|
||||
} catch {
|
||||
// Document doesn't exist — default enabled
|
||||
this.setData({ timerEnabled: true });
|
||||
}
|
||||
},
|
||||
|
||||
async loadData() {
|
||||
this.setData({ loading: true });
|
||||
try {
|
||||
// Fetch in parallel: active worksheets + saved page config
|
||||
const [wsResult, configResult] = await Promise.all([
|
||||
callCloudFunction<SimpleWorksheet[]>('worksheetsQuery', {
|
||||
status: 'active',
|
||||
}),
|
||||
callCloudFunction<PageConfig>('pageConfigFetch'),
|
||||
]);
|
||||
|
||||
if (!wsResult.success) throw new Error(wsResult.message || '查询失败');
|
||||
|
||||
const allActive = (wsResult.data || []).map((raw) =>
|
||||
formatSimple(raw as unknown as Record<string, unknown>),
|
||||
);
|
||||
|
||||
// Build a lookup map for quick access
|
||||
const wsMap = new Map(allActive.map((w) => [w._id, w]));
|
||||
|
||||
// featured: always top-5 by downloads; hot: always top-5 by likes
|
||||
const featured: SimpleWorksheet[] = [...allActive]
|
||||
.sort((a, b) => (b.downloads ?? 0) - (a.downloads ?? 0))
|
||||
.slice(0, 5);
|
||||
const hot: SimpleWorksheet[] = [...allActive]
|
||||
.sort((a, b) => (b.likes ?? 0) - (a.likes ?? 0))
|
||||
.slice(0, 5);
|
||||
|
||||
// Restore saved sections from config
|
||||
const savedHome = (configResult.data as PageConfig | undefined)?.home;
|
||||
const savedSections: HomeConfigItem[] = savedHome?.sections || [];
|
||||
const savedSectionMap = new Map(savedSections.map((s) => [s.id, s]));
|
||||
|
||||
const sections: SectionConfig[] = CATEGORY_LIST.map((c) => {
|
||||
const saved = savedSectionMap.get(c.id);
|
||||
const items = saved
|
||||
? (saved.items || [])
|
||||
.map((item) => wsMap.get(item.id))
|
||||
.filter((w): w is SimpleWorksheet => !!w)
|
||||
: [];
|
||||
return {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
icon: c.icon,
|
||||
subtitle: saved?.subtitle || '',
|
||||
morePath: c.path,
|
||||
items,
|
||||
};
|
||||
});
|
||||
|
||||
this.setData({ loading: false, allActive, featured, hot, sections });
|
||||
} catch (error) {
|
||||
this.setData({ loading: false });
|
||||
wx.showToast({
|
||||
title: error instanceof Error ? error.message : '加载失败',
|
||||
icon: 'none',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// ── Timer Control ──
|
||||
|
||||
async onToggleTimer() {
|
||||
if (this.data.timerLoading) return;
|
||||
const newEnabled = !this.data.timerEnabled;
|
||||
|
||||
this.setData({ timerLoading: true });
|
||||
try {
|
||||
const result = await callCloudFunction('homeTimerControl', {
|
||||
enabled: newEnabled,
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || '操作失败');
|
||||
}
|
||||
this.setData({ timerEnabled: newEnabled });
|
||||
wx.showToast({
|
||||
title: newEnabled ? '定时任务已启动' : '定时任务已暂停',
|
||||
icon: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
wx.showToast({
|
||||
title: error instanceof Error ? error.message : '操作失败',
|
||||
icon: 'none',
|
||||
});
|
||||
} finally {
|
||||
this.setData({ timerLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
// ── Selector ──
|
||||
|
||||
onTapAdd(e: WechatMiniprogram.TouchEvent) {
|
||||
const target = e.currentTarget.dataset.target as string;
|
||||
if (!target) return;
|
||||
|
||||
let label = '';
|
||||
let selectedIds: Set<string>;
|
||||
|
||||
if (target === 'featured') {
|
||||
label = '今日推荐';
|
||||
selectedIds = new Set(this.data.featured.map((w) => w._id));
|
||||
} else if (target === 'hot') {
|
||||
label = '热门推荐';
|
||||
selectedIds = new Set(this.data.hot.map((w) => w._id));
|
||||
} else {
|
||||
const section = this.data.sections.find((s) => s.id === target);
|
||||
label = section?.name || target;
|
||||
selectedIds = new Set((section?.items || []).map((w) => w._id));
|
||||
}
|
||||
|
||||
// Filter: show active worksheets not already selected
|
||||
let pool = this.data.allActive.filter((w) => !selectedIds.has(w._id));
|
||||
// For sections, prefer same category
|
||||
if (target !== 'featured' && target !== 'hot') {
|
||||
pool = pool.filter((w) => w.category === target);
|
||||
}
|
||||
|
||||
this.setData({
|
||||
selectorVisible: true,
|
||||
selectorTarget: target,
|
||||
selectorTargetLabel: label,
|
||||
selectorItems: pool,
|
||||
});
|
||||
},
|
||||
|
||||
onCloseSelector() {
|
||||
this.setData({ selectorVisible: false });
|
||||
},
|
||||
|
||||
onSelectItem(e: WechatMiniprogram.TouchEvent) {
|
||||
const id = e.currentTarget.dataset.id as string;
|
||||
if (!id) return;
|
||||
|
||||
const ws = this.data.allActive.find((w) => w._id === id);
|
||||
if (!ws) return;
|
||||
|
||||
const target = this.data.selectorTarget;
|
||||
|
||||
if (target === 'featured') {
|
||||
this.setData({ featured: [...this.data.featured, ws] });
|
||||
} else if (target === 'hot') {
|
||||
this.setData({ hot: [...this.data.hot, ws] });
|
||||
} else {
|
||||
const sections = [...this.data.sections];
|
||||
const idx = sections.findIndex((s) => s.id === target);
|
||||
if (idx >= 0) {
|
||||
sections[idx] = {
|
||||
...sections[idx],
|
||||
items: [...sections[idx].items, ws],
|
||||
};
|
||||
this.setData({ sections });
|
||||
}
|
||||
}
|
||||
|
||||
this.setData({ selectorVisible: false });
|
||||
},
|
||||
|
||||
onRemoveItem(e: WechatMiniprogram.TouchEvent) {
|
||||
const target = e.currentTarget.dataset.target as string;
|
||||
const id = e.currentTarget.dataset.id as string;
|
||||
if (!target || !id) return;
|
||||
|
||||
if (target === 'featured') {
|
||||
this.setData({
|
||||
featured: this.data.featured.filter((w) => w._id !== id),
|
||||
});
|
||||
} else if (target === 'hot') {
|
||||
this.setData({
|
||||
hot: this.data.hot.filter((w) => w._id !== id),
|
||||
});
|
||||
} else {
|
||||
const sections = [...this.data.sections];
|
||||
const idx = sections.findIndex((s) => s.id === target);
|
||||
if (idx >= 0) {
|
||||
sections[idx] = {
|
||||
...sections[idx],
|
||||
items: sections[idx].items.filter((w) => w._id !== id),
|
||||
};
|
||||
this.setData({ sections });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── Build ──
|
||||
|
||||
async onBuildTap() {
|
||||
if (this.data.building) return;
|
||||
|
||||
const confirmed = await new Promise<boolean>((resolve) => {
|
||||
wx.showModal({
|
||||
title: '更新首页数据',
|
||||
content: '将根据当前配置重新生成首页数据,确认继续?',
|
||||
success: (res) => resolve(!!res.confirm),
|
||||
fail: () => resolve(false),
|
||||
});
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
this.setData({ building: true });
|
||||
|
||||
try {
|
||||
const result = await callCloudFunction<{
|
||||
version: number;
|
||||
}>('pageContentBuild', {
|
||||
page: 'home',
|
||||
featuredIds: this.data.featured.map((w) => w._id),
|
||||
hotIds: this.data.hot.map((w) => w._id),
|
||||
sections: this.data.sections
|
||||
.filter((s) => s.items.length > 0)
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
subtitle: s.subtitle || '',
|
||||
morePath: s.morePath || '',
|
||||
worksheetIds: s.items.map((w) => w._id),
|
||||
})),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || '生成失败');
|
||||
}
|
||||
|
||||
wx.showToast({
|
||||
title: `生成成功 v${result.data?.version || 0}`,
|
||||
icon: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
wx.showToast({
|
||||
title: error instanceof Error ? error.message : '生成失败',
|
||||
icon: 'none',
|
||||
});
|
||||
} finally {
|
||||
this.setData({ building: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
<view class="hcm-page">
|
||||
<!-- Loading -->
|
||||
<view wx:if="{{loading}}" class="hcm-page__loading">
|
||||
正在加载 active worksheet 数据...
|
||||
</view>
|
||||
|
||||
<block wx:else>
|
||||
<!-- Timer Status -->
|
||||
<view class="hcm-timer-card">
|
||||
<view class="hcm-timer-card__info">
|
||||
<text class="hcm-timer-card__title">定时自动刷新</text>
|
||||
<text class="hcm-timer-card__desc"
|
||||
>每日 22:00 自动更新推荐位数据</text
|
||||
>
|
||||
</view>
|
||||
<view class="hcm-timer-card__right">
|
||||
<view
|
||||
class="hcm-timer-card__badge {{timerEnabled ? 'hcm-timer-card__badge--on' : 'hcm-timer-card__badge--off'}}">
|
||||
<text>{{timerEnabled ? '运行中' : '已暂停'}}</text>
|
||||
</view>
|
||||
<toy-button
|
||||
type="{{timerEnabled ? 'default' : 'green'}}"
|
||||
size="mini"
|
||||
disabled="{{timerLoading}}"
|
||||
bindtap="onToggleTimer">
|
||||
{{timerEnabled ? '暂停' : '启动'}}
|
||||
</toy-button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Featured Section -->
|
||||
<view class="hcm-section">
|
||||
<view class="hcm-section__header">
|
||||
<view>
|
||||
<text class="hcm-section__title">今日推荐</text>
|
||||
<text class="hcm-section__hint">按下载量排序</text>
|
||||
</view>
|
||||
<text class="hcm-section__count">{{featured.length}} 个</text>
|
||||
</view>
|
||||
<view wx:if="{{featured.length}}" class="hcm-item-list">
|
||||
<view wx:for="{{featured}}" wx:key="_id" class="hcm-item">
|
||||
<image
|
||||
wx:if="{{item.previewImg}}"
|
||||
class="hcm-item__img"
|
||||
src="{{item.previewImg}}"
|
||||
mode="aspectFill" />
|
||||
<view class="hcm-item__info">
|
||||
<text class="hcm-item__title">{{item.title}}</text>
|
||||
<text class="hcm-item__id">{{item._id}}</text>
|
||||
</view>
|
||||
<toy-button
|
||||
type="default"
|
||||
size="mini"
|
||||
data-target="featured"
|
||||
data-id="{{item._id}}"
|
||||
bindtap="onRemoveItem">
|
||||
移除
|
||||
</toy-button>
|
||||
</view>
|
||||
</view>
|
||||
<view wx:else class="hcm-section__empty">暂未选择内容</view>
|
||||
<toy-button
|
||||
type="primary"
|
||||
size="small"
|
||||
data-target="featured"
|
||||
bindtap="onTapAdd">
|
||||
添加
|
||||
</toy-button>
|
||||
</view>
|
||||
|
||||
<!-- WXML_APPEND_1 -->
|
||||
|
||||
<!-- Hot Section -->
|
||||
<view class="hcm-section">
|
||||
<view class="hcm-section__header">
|
||||
<view>
|
||||
<text class="hcm-section__title">热门推荐</text>
|
||||
<text class="hcm-section__hint">按点赞量排序</text>
|
||||
</view>
|
||||
<text class="hcm-section__count">{{hot.length}} 个</text>
|
||||
</view>
|
||||
<view wx:if="{{hot.length}}" class="hcm-item-list">
|
||||
<view wx:for="{{hot}}" wx:key="_id" class="hcm-item">
|
||||
<image
|
||||
wx:if="{{item.previewImg}}"
|
||||
class="hcm-item__img"
|
||||
src="{{item.previewImg}}"
|
||||
mode="aspectFill" />
|
||||
<view class="hcm-item__info">
|
||||
<text class="hcm-item__title">{{item.title}}</text>
|
||||
<text class="hcm-item__id">{{item._id}}</text>
|
||||
</view>
|
||||
<toy-button
|
||||
type="default"
|
||||
size="mini"
|
||||
data-target="hot"
|
||||
data-id="{{item._id}}"
|
||||
bindtap="onRemoveItem">
|
||||
移除
|
||||
</toy-button>
|
||||
</view>
|
||||
</view>
|
||||
<view wx:else class="hcm-section__empty">暂未选择内容</view>
|
||||
<toy-button
|
||||
type="primary"
|
||||
size="small"
|
||||
data-target="hot"
|
||||
bindtap="onTapAdd">
|
||||
添加
|
||||
</toy-button>
|
||||
</view>
|
||||
|
||||
<!-- Category Sections -->
|
||||
<view
|
||||
wx:for="{{sections}}"
|
||||
wx:key="id"
|
||||
class="hcm-section {{index === sections.length - 1 ? 'last' : ''}}">
|
||||
<view class="hcm-section__header">
|
||||
<text class="hcm-section__title"
|
||||
>{{item.icon}} {{item.name}}</text
|
||||
>
|
||||
<text class="hcm-section__count">{{item.items.length}} 个</text>
|
||||
</view>
|
||||
<view wx:if="{{item.items.length}}" class="hcm-item-list">
|
||||
<view
|
||||
wx:for="{{item.items}}"
|
||||
wx:for-item="ws"
|
||||
wx:key="_id"
|
||||
class="hcm-item">
|
||||
<image
|
||||
wx:if="{{ws.previewImg}}"
|
||||
class="hcm-item__img"
|
||||
src="{{ws.previewImg}}"
|
||||
mode="aspectFill" />
|
||||
<view class="hcm-item__info">
|
||||
<text class="hcm-item__title">{{ws.title}}</text>
|
||||
<text class="hcm-item__id">{{ws._id}}</text>
|
||||
</view>
|
||||
<toy-button
|
||||
type="default"
|
||||
size="mini"
|
||||
data-target="{{item.id}}"
|
||||
data-id="{{ws._id}}"
|
||||
bindtap="onRemoveItem">
|
||||
移除
|
||||
</toy-button>
|
||||
</view>
|
||||
</view>
|
||||
<view wx:else class="hcm-section__empty">暂未选择内容</view>
|
||||
<toy-button
|
||||
type="primary"
|
||||
size="small"
|
||||
data-target="{{item.id}}"
|
||||
bindtap="onTapAdd">
|
||||
添加
|
||||
</toy-button>
|
||||
</view>
|
||||
|
||||
<!-- WXML_APPEND_2 -->
|
||||
|
||||
<!-- Build Button -->
|
||||
<view class="hcm-page__build">
|
||||
<toy-button
|
||||
type="primary"
|
||||
width="100%"
|
||||
loading="{{building}}"
|
||||
disabled="{{building}}"
|
||||
bindtap="onBuildTap">
|
||||
{{building ? '生成中...' : '更新首页数据'}}
|
||||
</toy-button>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- Selector Popup -->
|
||||
<view
|
||||
wx:if="{{selectorVisible}}"
|
||||
class="hcm-selector-mask"
|
||||
bindtap="onCloseSelector">
|
||||
<view class="hcm-selector" catchtap="">
|
||||
<view class="hcm-selector__header">
|
||||
<text class="hcm-selector__title"
|
||||
>选择内容 - {{selectorTargetLabel}}</text
|
||||
>
|
||||
<text class="hcm-selector__close" bindtap="onCloseSelector"
|
||||
>关闭</text
|
||||
>
|
||||
</view>
|
||||
<scroll-view class="hcm-selector__list" scroll-y>
|
||||
<view
|
||||
wx:if="{{!selectorItems.length}}"
|
||||
class="hcm-selector__empty">
|
||||
暂无可选内容
|
||||
</view>
|
||||
<view
|
||||
wx:for="{{selectorItems}}"
|
||||
wx:key="_id"
|
||||
class="hcm-selector-item"
|
||||
data-id="{{item._id}}"
|
||||
bindtap="onSelectItem">
|
||||
<image
|
||||
wx:if="{{item.previewImg}}"
|
||||
class="hcm-selector-item__img"
|
||||
src="{{item.previewImg}}"
|
||||
mode="aspectFill" />
|
||||
<view class="hcm-selector-item__info">
|
||||
<text class="hcm-selector-item__title"
|
||||
>{{item.title}}</text
|
||||
>
|
||||
<text class="hcm-selector-item__sub"
|
||||
>{{item.ageBand}} · {{item._id}}</text
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -24,12 +24,19 @@
|
||||
"miniprogram": {
|
||||
"list": [
|
||||
{
|
||||
"name": "englishPages/letterTracing/letterTracing",
|
||||
"pathName": "englishPages/letterTracing/letterTracing",
|
||||
"name": "supportPages/debug/debug",
|
||||
"pathName": "supportPages/debug/debug",
|
||||
"query": "",
|
||||
"scene": null,
|
||||
"launchMode": "default"
|
||||
},
|
||||
{
|
||||
"name": "englishPages/letterTracing/letterTracing",
|
||||
"pathName": "englishPages/letterTracing/letterTracing",
|
||||
"query": "",
|
||||
"launchMode": "default",
|
||||
"scene": null
|
||||
},
|
||||
{
|
||||
"name": "supportPages/debug/debug",
|
||||
"pathName": "supportPages/debug/debug",
|
||||
|
||||
Reference in New Issue
Block a user