feat:开发分类页内容管理

This commit is contained in:
R524809
2026-04-29 18:17:48 +08:00
parent 2cb9ec55d6
commit fa39938e5a
12 changed files with 853 additions and 0 deletions
+1
View File
@@ -14,6 +14,7 @@
"guide/guide",
"debug/debug",
"categoryManage/categoryManage",
"categoryContentManage/categoryContentManage",
"index/index",
"mathIndex/mathIndex",
"focusIndex/focusIndex",
@@ -0,0 +1,10 @@
{
"navigationBarTitleText": "分类页内容管理",
"navigationBarTextStyle": "black",
"navigationBarBackgroundColor": "#F8F0E0",
"backgroundColor": "#F8F0E0",
"enablePullDownRefresh": true,
"usingComponents": {
"toy-button": "/toy/button-v2/button"
}
}
@@ -0,0 +1,237 @@
@import '../../style/theme.less';
page {
min-height: 100%;
background: @bg-header;
}
.ccm-page {
min-height: 100vh;
padding: 24rpx;
padding-bottom: 160rpx;
background: @bg-header;
box-sizing: border-box;
}
// ── Category Selector ──
.ccm-page__categories {
white-space: nowrap;
padding-bottom: 8rpx;
}
.ccm-pill {
display: inline-flex;
align-items: center;
padding: 16rpx 28rpx;
margin-right: 16rpx;
background: @bg-white;
border-radius: 999rpx;
border: 2rpx solid transparent;
box-shadow: @shadow;
}
.ccm-pill--active {
background: @brand-light;
border-color: @brand;
}
.ccm-pill__icon {
font-size: 28rpx;
margin-right: 8rpx;
}
.ccm-pill__text {
font-size: 26rpx;
font-weight: 600;
color: @text-title;
white-space: nowrap;
}
// ── Stats ──
.ccm-page__stats {
display: flex;
margin-top: 24rpx;
}
.ccm-stat {
flex: 1;
padding: 24rpx 16rpx;
background: @bg-white;
border-radius: @radius-xl;
text-align: center;
box-shadow: @shadow;
}
.ccm-stat + .ccm-stat {
margin-left: 16rpx;
}
.ccm-stat__num {
display: block;
font-size: 36rpx;
font-weight: 800;
color: @text-title;
}
.ccm-stat__label {
display: block;
margin-top: 8rpx;
font-size: 22rpx;
color: @text-secondary;
}
// ── Loading / Empty ──
.ccm-page__loading,
.ccm-page__empty {
margin-top: 24rpx;
padding: 24rpx 28rpx;
background: @bg-white;
border-radius: @radius;
font-size: 24rpx;
line-height: 1.6;
color: @text-secondary;
box-shadow: @shadow;
}
// ── Worksheet List ──
.ccm-page__list {
margin-top: 24rpx;
}
.ws-card {
padding: 28rpx;
margin-bottom: 20rpx;
background: @bg-white;
border-radius: @radius-xl;
box-shadow: @shadow;
border: 2rpx solid transparent;
}
.ws-card--active {
border-color: fade(#93d333, 30%);
}
.ws-card--draft {
border-color: fade(#ffb703, 40%);
}
.ws-card--hidden {
border-color: fade(#8a8478, 25%);
}
.ws-card__header {
display: flex;
align-items: flex-start;
}
.ws-card__img {
width: 120rpx;
height: 120rpx;
border-radius: @radius;
flex-shrink: 0;
background: @bg-gray;
}
.ws-card__img--placeholder {
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
color: @text-gray;
}
.ws-card__info {
flex: 1;
min-width: 0;
margin-left: 20rpx;
}
.ws-card__title {
display: block;
font-size: 28rpx;
font-weight: 700;
color: @text-title;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ws-card__subtitle {
display: block;
margin-top: 6rpx;
font-size: 22rpx;
color: @text-secondary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ws-card__meta {
display: flex;
margin-top: 10rpx;
gap: 12rpx;
}
.ws-card__tag {
padding: 4rpx 14rpx;
border-radius: 999rpx;
font-size: 20rpx;
color: @text-secondary;
background: @bg-gray;
}
.ws-card__id {
display: block;
margin-top: 8rpx;
font-size: 20rpx;
color: @text-gray;
}
.ws-card__footer {
display: flex;
align-items: center;
margin-top: 20rpx;
padding-top: 20rpx;
border-top: @border;
}
.ws-card__badge {
padding: 6rpx 16rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 700;
}
.ws-card__badge--active {
background: fade(#93d333, 18%);
color: #4a7a12;
}
.ws-card__badge--draft {
background: fade(#ffb703, 18%);
color: #8a6d00;
}
.ws-card__badge--hidden {
background: fade(#8a8478, 15%);
color: #605b50;
}
.ws-card__sort {
flex: 1;
margin-left: 16rpx;
font-size: 22rpx;
color: @text-gray;
}
// ── Build Button ──
.ccm-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);
}
@@ -0,0 +1,240 @@
import { CATEGORY_LIST } from '../../core/data/categories';
type WorksheetItem = {
_id: string;
title: string;
subtitle: string;
category: string;
previewImg: string;
ageMin: number;
ageMax: number;
difficulty: 1 | 2 | 3 | 4;
tags: string[];
status: 'draft' | 'active' | 'hidden';
sortOrder: number;
updatedAt: string;
// display helpers
ageBand: string;
difficultyLabel: string;
statusText: string;
actionText: string;
actionTarget: string;
};
type Stats = {
total: number;
draft: number;
active: number;
hidden: number;
};
type CloudFunctionResult<T> = {
success?: boolean;
message?: string;
data?: T;
stats?: Stats;
};
const DIFFICULTY_LABELS: Record<number, string> = {
1: '入门',
2: '基础',
3: '进阶',
4: '挑战',
};
const STATUS_CONFIG: Record<
string,
{ text: string; action: string; target: string }
> = {
draft: { text: '草稿', action: '激活', target: 'active' },
active: { text: '线上', action: '下架', target: 'hidden' },
hidden: { text: '已隐藏', action: '恢复', target: 'draft' },
};
function formatWorksheet(raw: Record<string, unknown>): WorksheetItem {
const status = String(raw.status || 'draft');
const cfg = STATUS_CONFIG[status] || STATUS_CONFIG.draft;
const ageMin = Number(raw.ageMin) || 0;
const ageMax = Number(raw.ageMax) || 0;
const difficulty = (Number(raw.difficulty) || 2) as 1 | 2 | 3 | 4;
return {
_id: String(raw._id || ''),
title: String(raw.title || ''),
subtitle: String(raw.subtitle || ''),
category: String(raw.category || ''),
previewImg: String(raw.previewImg || ''),
ageMin,
ageMax,
difficulty,
tags: Array.isArray(raw.tags) ? raw.tags.map(String) : [],
status: status as WorksheetItem['status'],
sortOrder: Number(raw.sortOrder) || 0,
updatedAt: raw.updatedAt ? String(raw.updatedAt) : '',
ageBand: `${ageMin}-${ageMax}`,
difficultyLabel: DIFFICULTY_LABELS[difficulty] || '基础',
statusText: cfg.text,
actionText: cfg.action,
actionTarget: cfg.target,
};
}
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: {
categories: CATEGORY_LIST,
activeCategoryId: CATEGORY_LIST[0]?.id || 'math',
worksheets: [] as WorksheetItem[],
stats: { total: 0, draft: 0, active: 0, hidden: 0 } as Stats,
loading: false,
updatingId: '',
building: false,
},
onLoad() {
void this.loadWorksheets();
},
async onPullDownRefresh() {
await this.loadWorksheets();
wx.stopPullDownRefresh();
},
async loadWorksheets() {
this.setData({ loading: true });
try {
const result = await callCloudFunction<WorksheetItem[]>(
'worksheetsQuery',
{ category: this.data.activeCategoryId },
);
if (!result.success) {
throw new Error(result.message || '查询失败');
}
const worksheets = (result.data || []).map((raw) =>
formatWorksheet(raw as unknown as Record<string, unknown>),
);
this.setData({
loading: false,
worksheets,
stats: result.stats || {
total: 0,
draft: 0,
active: 0,
hidden: 0,
},
});
} catch (error) {
this.setData({ loading: false });
wx.showToast({
title: error instanceof Error ? error.message : '查询失败',
icon: 'none',
});
}
},
onCategoryTap(e: WechatMiniprogram.TouchEvent) {
const id = e.currentTarget.dataset.id as string;
if (!id || id === this.data.activeCategoryId) return;
this.setData({ activeCategoryId: id });
void this.loadWorksheets();
},
async onStatusTap(e: WechatMiniprogram.TouchEvent) {
const id = e.currentTarget.dataset.id as string;
const target = e.currentTarget.dataset.target as string;
if (!id || !target || this.data.updatingId) return;
const ws = this.data.worksheets.find((w) => w._id === id);
if (!ws) return;
const confirmed = await new Promise<boolean>((resolve) => {
wx.showModal({
title: `${ws.actionText}`,
content: `确认将「${ws.title}${ws.actionText}吗?`,
success: (res) => resolve(!!res.confirm),
fail: () => resolve(false),
});
});
if (!confirmed) return;
this.setData({ updatingId: id });
try {
const result = await callCloudFunction('worksheetsUpdateStatus', {
id,
status: target,
});
if (!result.success) {
throw new Error(result.message || '操作失败');
}
wx.showToast({ title: `${ws.actionText}成功`, icon: 'success' });
await this.loadWorksheets();
} catch (error) {
wx.showToast({
title:
error instanceof Error ? error.message : '操作失败',
icon: 'none',
});
} finally {
this.setData({ updatingId: '' });
}
},
async onBuildTap() {
if (this.data.building) return;
const confirmed = await new Promise<boolean>((resolve) => {
wx.showModal({
title: '更新分类页数据',
content:
'将根据当前线上 worksheet 重新生成分类页数据,确认继续?',
success: (res) => resolve(!!res.confirm),
fail: () => resolve(false),
});
});
if (!confirmed) return;
this.setData({ building: true });
try {
const result = await callCloudFunction<{
version: number;
stats: Record<string, number>;
}>('pageContentBuild', { page: 'category' });
if (!result.success) {
throw new Error(result.message || '生成失败');
}
const version = result.data?.version || 0;
wx.showToast({
title: `生成成功 v${version}`,
icon: 'success',
});
} catch (error) {
wx.showToast({
title:
error instanceof Error ? error.message : '生成失败',
icon: 'none',
});
} finally {
this.setData({ building: false });
}
},
});
@@ -0,0 +1,98 @@
<view class="ccm-page">
<!-- Category Selector -->
<scroll-view class="ccm-page__categories" scroll-x enable-flex>
<view
wx:for="{{categories}}"
wx:key="id"
class="ccm-pill {{activeCategoryId === item.id ? 'ccm-pill--active' : ''}}"
data-id="{{item.id}}"
bindtap="onCategoryTap">
<text class="ccm-pill__icon">{{item.icon}}</text>
<text class="ccm-pill__text">{{item.name}}</text>
</view>
</scroll-view>
<!-- Stats -->
<view class="ccm-page__stats">
<view class="ccm-stat">
<text class="ccm-stat__num">{{stats.total}}</text>
<text class="ccm-stat__label">总数</text>
</view>
<view class="ccm-stat">
<text class="ccm-stat__num">{{stats.draft}}</text>
<text class="ccm-stat__label">草稿</text>
</view>
<view class="ccm-stat">
<text class="ccm-stat__num">{{stats.active}}</text>
<text class="ccm-stat__label">线上</text>
</view>
<view class="ccm-stat">
<text class="ccm-stat__num">{{stats.hidden}}</text>
<text class="ccm-stat__label">已隐藏</text>
</view>
</view>
<!-- Loading -->
<view wx:if="{{loading}}" class="ccm-page__loading">
正在加载 worksheet 数据...
</view>
<!-- Empty -->
<view wx:elif="{{!worksheets.length}}" class="ccm-page__empty">
当前分类下暂无 worksheet
</view>
<!-- Worksheet List -->
<view wx:else class="ccm-page__list">
<view
wx:for="{{worksheets}}"
wx:key="_id"
class="ws-card ws-card--{{item.status}}">
<view class="ws-card__header">
<image
wx:if="{{item.previewImg}}"
class="ws-card__img"
src="{{item.previewImg}}"
mode="aspectFill" />
<view wx:else class="ws-card__img ws-card__img--placeholder">
<text>无图</text>
</view>
<view class="ws-card__info">
<text class="ws-card__title">{{item.title}}</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>
</view>
<text class="ws-card__id">{{item._id}}</text>
</view>
</view>
<view class="ws-card__footer">
<text class="ws-card__badge ws-card__badge--{{item.status}}">
{{item.statusText}}
</text>
<text class="ws-card__sort">排序 {{item.sortOrder}}</text>
<toy-button
type="primary"
size="small"
data-id="{{item._id}}"
data-target="{{item.actionTarget}}"
disabled="{{updatingId === item._id}}"
bindtap="onStatusTap">
{{updatingId === item._id ? '处理中...' : item.actionText}}
</toy-button>
</view>
</view>
</view>
<!-- Build Button -->
<view class="ccm-page__build">
<toy-button
type="primary"
loading="{{building}}"
disabled="{{building}}"
bindtap="onBuildTap">
{{building ? '生成中...' : '更新分类页数据'}}
</toy-button>
</view>
</view>
+7
View File
@@ -14,6 +14,13 @@ const DEBUG_ENTRIES: DebugEntry[] = [
icon: '🗂️',
path: '/supportPages/categoryManage/categoryManage',
},
{
id: 'category-content',
title: '分类页内容管理',
subtitle: '管理各分类下 worksheet 状态,生成分类页数据。',
icon: '📋',
path: '/supportPages/categoryContentManage/categoryContentManage',
},
];
Page({