feat: 分类接口替换为api 接口

This commit is contained in:
R524809
2026-10-16 17:33:24 +08:00
parent eddd7d75e2
commit 8de06ce66b
12 changed files with 1086 additions and 283 deletions
+21 -5
View File
@@ -288,10 +288,12 @@
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg,
transparent 25%,
rgba(255, 255, 255, 0.7) 50%,
transparent 75%);
background: linear-gradient(
135deg,
transparent 25%,
rgba(255, 255, 255, 0.7) 50%,
transparent 75%
);
animation: skeleton-shimmer 1.2s linear infinite;
z-index: 1;
}
@@ -316,4 +318,18 @@
width: 80%;
height: 24rpx;
}
}
}
// 请求失败时允许显式重试,不用本地静态题型补回下架内容。
.cat-empty__retry {
margin: 8rpx 0 0;
padding: 0 32rpx;
border-radius: 999rpx;
background: #f3dba2;
color: @text-title;
font-size: 26rpx;
line-height: 72rpx;
&::after {
border: none;
}
}
@@ -0,0 +1,150 @@
import type { ApiCategory, ApiWorksheet } from '../../services/content-api';
export interface CategoryTab {
id: string;
name: string;
icon: string;
}
export interface CategoryItem {
id: string;
title: string;
subtitle: string;
icon: string;
previewImg: string;
ageBand: string;
difficultyLabel: string;
path: string;
available: boolean;
likes: string;
downloads: string;
date: string;
updatedAt: string;
skeleton?: boolean;
}
export interface CategoryDataset {
sidebarTabs: CategoryTab[];
groups: Map<string, CategoryItem[]>;
}
export const BASE_TABS: CategoryTab[] = [
{ id: 'all', name: '全部', icon: '📋' },
{ id: 'hot', name: '热门推荐', icon: '🔥' },
{ id: 'top-downloads', name: '下载最多', icon: '📥' },
];
const DIFFICULTY_LABELS = ['入门', '基础', '进阶', '挑战'];
/** 比较任意长度的非负十进制计数;微信运行时无需 BigInt,也不丢失 64 位精度。 */
export function compareCountDescending(left: string, right: string): number {
const a = left.replace(/^0+(?=\d)/, '');
const b = right.replace(/^0+(?=\d)/, '');
return a.length !== b.length
? b.length - a.length
: a === b
? 0
: a < b
? 1
: -1;
}
function time(value: string): number {
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : 0;
}
/** 保留原页面交互:最近更新的两项置顶,其余下载量降序,并列按稳定 ID。 */
function sortWorksheets(items: CategoryItem[]): CategoryItem[] {
const byId = (a: CategoryItem, b: CategoryItem) =>
a.id < b.id ? -1 : a.id === b.id ? 0 : 1;
const sorted = [...items].sort(
(a, b) => time(b.updatedAt) - time(a.updatedAt) || byId(a, b),
);
return [
...sorted.slice(0, 2),
...sorted
.slice(2)
.sort(
(a, b) =>
compareCountDescending(a.downloads, b.downloads) ||
byId(a, b),
),
];
}
/** 只展示本次接口返回的分类;并发下架导致目录缺失的题型直接丢弃。 */
export function buildCategoryDataset(
categories: ApiCategory[],
worksheets: ApiWorksheet[],
): CategoryDataset {
const reservedIds = new Set(BASE_TABS.map((tab) => tab.id));
const groups = new Map<string, CategoryItem[]>();
const icons = new Map<string, string>();
for (const category of categories) {
if (reservedIds.has(category.id) || groups.has(category.id))
throw new Error('分类数据异常,请稍后重试');
groups.set(category.id, []);
icons.set(category.id, category.icon || '📄');
}
for (const row of worksheets) {
const group = groups.get(row.category);
if (!group) continue;
const date = row.contentUpdatedAt || row.createdAt;
group.push({
id: row.id,
title: row.title,
subtitle: row.subtitle,
icon: icons.get(row.category) || '📄',
previewImg: row.previewImg || '',
ageBand: `${row.ageMin}-${row.ageMax}岁`,
difficultyLabel: DIFFICULTY_LABELS[row.difficulty - 1] || '基础',
path: row.path,
available: Boolean(row.path),
likes: row.likes,
downloads: row.downloads,
date: Number.isFinite(Date.parse(date))
? new Date(date).toISOString().slice(0, 10)
: '',
updatedAt: row.updatedAt,
});
}
return {
sidebarTabs: [
...BASE_TABS,
...categories.map(({ id, name, icon }) => ({ id, name, icon })),
],
groups,
};
}
/** 两个榜单沿用旧页面含义:热门推荐按收藏数,下载最多按下载数,各取前 12。 */
export function getCategoryItems(
dataset: CategoryDataset | null,
categoryId: string,
keyword = '',
): CategoryItem[] {
if (!dataset) return [];
let items: CategoryItem[];
if (
categoryId === 'all' ||
categoryId === 'hot' ||
categoryId === 'top-downloads'
) {
const all = sortWorksheets(Array.from(dataset.groups.values()).flat());
if (categoryId === 'all') items = all;
else {
const key = categoryId === 'hot' ? 'likes' : 'downloads';
items = all
.sort((a, b) => compareCountDescending(a[key], b[key]))
.slice(0, 12);
}
} else items = sortWorksheets(dataset.groups.get(categoryId) || []);
const kw = keyword.trim().toLowerCase();
return kw
? items.filter(
(item) =>
item.title.toLowerCase().includes(kw) ||
item.subtitle.toLowerCase().includes(kw),
)
: items;
}
+160 -271
View File
@@ -1,44 +1,19 @@
import { appSharePageMethods } from '../../base/pageMixin';
import { CATEGORY_LIST_WITH_ALL } from '../../core/data/categories';
import { parseMiniProgramUrl, takePendingCategoryTabId } from '../../utils/index';
import { takePendingCategoryTabId } from '../../utils/index';
import tracker from '../../utils/tracker';
import { CATEGORY_DATA, type CategoryItem } from './category.data';
type CategoryTab = {
id: string;
name: string;
icon: string;
};
type CategoryDataset = {
searchPlaceholder: string;
categories: Array<{
id: string;
name: string;
icon: string;
items: CategoryItem[];
}>;
};
const DIFFICULTY_LABELS: Record<number, string> = {
1: '入门',
2: '基础',
3: '进阶',
4: '挑战',
};
const DYNAMIC_TABS: CategoryTab[] = [
{ id: 'hot', name: '热门推荐', icon: '🔥' },
{ id: 'top-downloads', name: '下载最多', icon: '📥' },
];
const SIDEBAR_TABS: CategoryTab[] = [
{ id: 'all', name: '全部', icon: '📋' },
...DYNAMIC_TABS,
...CATEGORY_LIST_WITH_ALL.filter((c) => c.id !== 'all').map(
({ id, name, icon }) => ({ id, name, icon }),
),
];
import { ApiRequestError } from '../../services/api-request';
import {
getWorksheet,
listAllWorksheets,
listCategories,
} from '../../services/content-api';
import {
BASE_TABS,
buildCategoryDataset,
getCategoryItems,
type CategoryDataset,
type CategoryItem,
} from './category.model';
const TAB_BAR_PATHS = new Set([
'/pages/home/home',
@@ -47,182 +22,42 @@ const TAB_BAR_PATHS = new Set([
'/pages/favorites/favorites',
'/pages/profile/profile',
]);
// Page-level mutable state (shared across methods)
let _categoryData: CategoryDataset | null = null;
// --- 以下仅为本地调试:拉取分类数据后查看各 worksheet 的 downloads / likes,非业务逻辑 ---
function debugLogWorksheetStatsAfterCategoryLoad(
source:
| { kind: 'cloud'; rows: Record<string, any>[] }
| { kind: 'static'; data: CategoryDataset },
) {
let map: Record<string, { downloads: number; likes: number }> = {};
if (source.kind === 'cloud') {
for (const raw of source.rows) {
const id = String(raw._id || '').trim();
if (!id) continue;
map[id] = {
downloads: Number(raw.downloads) || 0,
likes: Number(raw.likes) || 0,
};
}
} else {
for (const group of source.data.categories) {
for (const item of group.items) {
map[item.id] = {
downloads: Number(item.downloads) || 0,
likes: Number(item.likes) || 0,
};
}
}
}
console.log('worksheetStatsById', JSON.stringify(map, null, 2));
}
// --- 调试块结束 ---
function toContentDate(raw: Record<string, any>): string {
const ts = raw.contentUpdatedAt || raw.createdAt;
return ts
? new Date(ts).toISOString().slice(0, 10)
: new Date().toISOString().slice(0, 10);
}
/** 将云端 worksheet 原始数据转换为 CategoryItem */
function toDisplayItem(raw: Record<string, any>): CategoryItem {
const difficulty = (Number(raw.difficulty) || 2) as 1 | 2 | 3 | 4;
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 || ''),
previewImg: String(raw.previewImg || ''),
ageBand: `${ageMin}-${ageMax}岁`,
ageMin,
ageMax,
difficulty,
difficultyLabel: DIFFICULTY_LABELS[difficulty] || '基础',
path: String(raw.path || ''),
available: true,
likes: Number(raw.likes) || 0,
downloads: Number(raw.downloads) || 0,
date: toContentDate(raw),
updatedAt: raw.updatedAt ? String(raw.updatedAt) : '',
};
}
/** 排序:updatedAt 最新的 2 个置顶,其余按 downloads 降序 */
function sortWorksheets(items: CategoryItem[]): CategoryItem[] {
const byTime = [...items].sort(
(a, b) =>
new Date(b.updatedAt || '').getTime() -
new Date(a.updatedAt || '').getTime(),
);
const recent = byTime.slice(0, 2);
const recentIds = new Set(recent.map((w) => w.id));
const rest = byTime
.filter((w) => !recentIds.has(w.id))
.sort((a, b) => (b.downloads || 0) - (a.downloads || 0));
return [...recent, ...rest];
}
/** 将云端 worksheet 数组按分类分组,构建 CategoryDataset */
function buildCategoryDataFromCloud(
rawList: Record<string, any>[],
): CategoryDataset {
const itemsByCategory: Record<string, CategoryItem[]> = {};
for (const raw of rawList) {
const item = toDisplayItem(raw);
const cat = String(raw.category || '');
if (!itemsByCategory[cat]) itemsByCategory[cat] = [];
itemsByCategory[cat].push(item);
}
const categories = CATEGORY_LIST_WITH_ALL.filter((c) => c.id !== 'all').map(
(cat) => ({
id: cat.id,
name: cat.name,
icon: cat.icon,
items: sortWorksheets(itemsByCategory[cat.id] || []),
}),
);
return { searchPlaceholder: '搜索练习纸...', categories };
}
function buildAllItems(): CategoryItem[] {
if (!_categoryData) return [];
const items: CategoryItem[] = [];
for (const group of _categoryData.categories) {
for (const item of group.items) {
items.push({ ...item });
}
}
return sortWorksheets(items);
}
const DYNAMIC_LIMIT = 12;
function buildHotItems(): CategoryItem[] {
const all = buildAllItems();
return [...all]
.sort((a, b) => (b.likes || 0) - (a.likes || 0))
.slice(0, DYNAMIC_LIMIT);
}
function buildTopDownloadsItems(): CategoryItem[] {
const all = buildAllItems();
return [...all]
.sort((a, b) => (b.downloads || 0) - (a.downloads || 0))
.slice(0, DYNAMIC_LIMIT);
}
function getItemsByCategory(categoryId: string): CategoryItem[] {
if (!_categoryData) return [];
if (categoryId === 'all') return buildAllItems();
if (categoryId === 'hot') return buildHotItems();
if (categoryId === 'top-downloads') return buildTopDownloadsItems();
const group = _categoryData.categories.find((c) => c.id === categoryId);
if (!group) return [];
return group.items.map((item) => ({ ...item }));
}
function filterByKeyword(
items: CategoryItem[],
keyword: string,
): CategoryItem[] {
if (!keyword) return items;
const kw = keyword.toLowerCase();
return items.filter(
(item) =>
item.title.toLowerCase().includes(kw) ||
item.subtitle.toLowerCase().includes(kw),
);
}
// Skeleton placeholder items for loading state
const SKELETON_ITEMS = Array.from({ length: 6 }, (_, i) => ({
id: `skeleton-${i}`,
title: '\u00A0',
subtitle: '\u00A0',
skeleton: true,
}));
const SKELETON_ITEMS: CategoryItem[] = Array.from(
{ length: 6 },
(_, index) => ({
id: `skeleton-${index}`,
title: '\u00a0',
subtitle: '\u00a0',
icon: '',
previewImg: '',
ageBand: '',
difficultyLabel: '',
path: '',
available: false,
likes: '',
downloads: '',
date: '',
updatedAt: '',
skeleton: true,
}),
);
Page({
data: {
searchPlaceholder: '搜索练习纸...',
searchKeyword: '',
sidebarTabs: SIDEBAR_TABS,
sidebarTabs: BASE_TABS,
activeCategoryId: 'all',
displayItems: SKELETON_ITEMS as any[],
displayItems: SKELETON_ITEMS,
scrollIntoViewId: '',
loading: true,
errorMessage: '',
},
_defaultCategoryId: 'all',
_categoryData: null as CategoryDataset | null,
_loadVersion: 0,
_opening: false,
onLoad(options: Record<string, string>) {
tracker.reportPageView('pages/category/category');
@@ -233,58 +68,92 @@ Page({
},
onShow() {
_categoryData = null;
this.setData({ displayItems: SKELETON_ITEMS, loading: true });
void this.loadWorksheets(takePendingCategoryTabId() ?? undefined);
},
async loadWorksheets(overrideCategoryId?: string | null) {
try {
const res = await wx.cloud.callFunction({
name: 'worksheetsQuery',
data: { status: 'active' },
});
const result = (res?.result as any) || {};
if (!result.success) throw new Error(result.message || '查询失败');
onHide() {
this._loadVersion++;
},
const rows = result.data || [];
console.log('rows', rows);
_categoryData = buildCategoryDataFromCloud(rows);
debugLogWorksheetStatsAfterCategoryLoad({ kind: 'cloud', rows });
} catch {
_categoryData = CATEGORY_DATA;
debugLogWorksheetStatsAfterCategoryLoad({
kind: 'static',
data: CATEGORY_DATA,
onUnload() {
this._loadVersion++;
},
/** 每次进入重新读取公开目录与完整分页;失败清空内容,不回退静态数据或旧云服务。 */
async loadWorksheets(overrideCategoryId?: string) {
const version = ++this._loadVersion;
this._categoryData = null;
this.setData({
displayItems: SKELETON_ITEMS,
sidebarTabs: BASE_TABS,
loading: true,
errorMessage: '',
});
try {
const [categories, worksheets] = await Promise.all([
listCategories(),
listAllWorksheets(),
]);
if (version !== this._loadVersion) return;
const dataset = buildCategoryDataset(categories, worksheets);
const desiredId =
overrideCategoryId ||
this.data.activeCategoryId ||
this._defaultCategoryId;
const activeId = dataset.sidebarTabs.some(
(tab) => tab.id === desiredId,
)
? desiredId
: 'all';
this._categoryData = dataset;
this.setData({
loading: false,
sidebarTabs: dataset.sidebarTabs,
activeCategoryId: activeId,
displayItems: getCategoryItems(
dataset,
activeId,
this.data.searchKeyword,
),
scrollIntoViewId: '',
errorMessage: '',
});
} catch (error) {
if (version !== this._loadVersion) return;
this._categoryData = null;
this.setData({
loading: false,
displayItems: [],
sidebarTabs: BASE_TABS,
errorMessage:
error instanceof Error
? error.message
: '加载失败,请稍后重试',
});
}
},
const activeId =
overrideCategoryId != null &&
String(overrideCategoryId).trim() !== ''
? String(overrideCategoryId).trim()
: this.data.activeCategoryId || this._defaultCategoryId;
const items = getItemsByCategory(activeId);
const filtered = filterByKeyword(items, this.data.searchKeyword);
this.setData({
loading: false,
searchPlaceholder: _categoryData.searchPlaceholder,
activeCategoryId: activeId,
displayItems: filtered,
});
onRetry() {
void this.loadWorksheets();
},
onTapCategory(e: WechatMiniprogram.TouchEvent) {
const id = e.currentTarget.dataset.id as string;
if (!id || id === this.data.activeCategoryId) return;
const items = getItemsByCategory(id);
const filtered = filterByKeyword(items, this.data.searchKeyword);
if (
!this._categoryData ||
this.data.loading ||
!id ||
id === this.data.activeCategoryId ||
!this._categoryData.sidebarTabs.some((tab) => tab.id === id)
)
return;
this.setData({
activeCategoryId: id,
displayItems: filtered,
displayItems: getCategoryItems(
this._categoryData,
id,
this.data.searchKeyword,
),
scrollIntoViewId: '',
});
tracker.report('category_select', 'pages/category/category', {
@@ -294,49 +163,69 @@ Page({
onSearchInput(e: WechatMiniprogram.Input) {
const keyword = (e.detail.value ?? '').trim();
const items = getItemsByCategory(this.data.activeCategoryId);
const filtered = filterByKeyword(items, keyword);
this.setData({
searchKeyword: keyword,
displayItems: filtered,
displayItems: getCategoryItems(
this._categoryData,
this.data.activeCategoryId,
keyword,
),
});
},
onClearSearch() {
const items = getItemsByCategory(this.data.activeCategoryId);
this.setData({
searchKeyword: '',
displayItems: items,
displayItems: getCategoryItems(
this._categoryData,
this.data.activeCategoryId,
),
});
},
onTapItem(e: WechatMiniprogram.TouchEvent) {
const path = e.currentTarget.dataset.path as string;
const title = e.currentTarget.dataset.title as string;
const available = e.currentTarget.dataset.available;
if (!available || !path) {
/** 点击时重新检查详情,已下架或网络不可用时不进入内置生成页。 */
async onTapItem(e: WechatMiniprogram.TouchEvent) {
const id = e.currentTarget.dataset.id as string;
if (
this._opening ||
this.data.loading ||
!id ||
!this.data.displayItems.some(
(item) => item.id === id && item.available,
)
)
return;
this._opening = true;
const version = this._loadVersion;
try {
const row = await getWorksheet(id);
if (version !== this._loadVersion) return;
if (!row.path) throw new Error('内容暂不可用');
tracker.reportWorksheetClick('pages/category/category', {
worksheet_id: row.id,
worksheet_title: row.title,
category_id: this.data.activeCategoryId,
source: 'category_list',
});
const fail = () =>
wx.showToast({ title: '暂时无法打开该内容', icon: 'none' });
if (TAB_BAR_PATHS.has(row.path))
wx.switchTab({ url: row.path, fail });
else wx.navigateTo({ url: row.path, fail });
} catch (error) {
if (version !== this._loadVersion) return;
wx.showToast({
title: title ? `${title} 即将上线` : '即将上线',
title:
error instanceof Error
? error.message
: '暂时无法打开,请稍后重试',
icon: 'none',
});
return;
if (error instanceof ApiRequestError && error.statusCode === 404)
void this.loadWorksheets();
} finally {
this._opening = false;
}
tracker.reportWorksheetClick('pages/category/category', {
worksheet_id: String(parseMiniProgramUrl(path).query.id || ''),
worksheet_title: title || '',
category_id: this.data.activeCategoryId,
source: 'category_list',
});
if (TAB_BAR_PATHS.has(path)) {
wx.switchTab({ url: path });
return;
}
wx.navigateTo({ url: path });
},
...appSharePageMethods,
+12 -4
View File
@@ -55,9 +55,7 @@
wx:for="{{displayItems}}"
wx:key="id"
class="cat-card {{item.skeleton ? 'cat-card--skeleton' : ''}}"
data-path="{{item.path}}"
data-title="{{item.title}}"
data-available="{{item.available}}"
data-id="{{item.id}}"
bindtap="{{item.skeleton ? '' : 'onTapItem'}}">
<view class="cat-card__thumb">
<image
@@ -117,7 +115,17 @@
</view>
<view
wx:if="{{displayItems.length === 0 && !loading}}"
wx:if="{{errorMessage && !loading}}"
class="cat-empty">
<text class="cat-empty__icon">📡</text>
<text class="cat-empty__text">{{errorMessage}}</text>
<button class="cat-empty__retry" bindtap="onRetry">
重新加载
</button>
</view>
<view
wx:if="{{displayItems.length === 0 && !loading && !errorMessage}}"
class="cat-empty">
<text class="cat-empty__icon">🔍</text>
<text class="cat-empty__text">没有找到匹配的内容</text>