feat:完成首页、分类页内容管理功能开发

This commit is contained in:
R524809
2026-04-30 13:30:42 +08:00
parent fa39938e5a
commit c112c70bab
25 changed files with 1465 additions and 65 deletions
@@ -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>