feat: 分类接口替换为api 接口
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
export type ApiEnvironment = 'local' | 'online';
|
||||
type MiniProgramVersion = 'develop' | 'trial' | 'release';
|
||||
|
||||
/** 统一接口入口;本地真机调试时将 localBaseUrl 的 IP 改为电脑局域网 IP。 */
|
||||
export const apiConfig = {
|
||||
developmentEnvironment: 'local' as ApiEnvironment,
|
||||
localBaseUrl: 'http://127.0.0.1:8800/api/v1',
|
||||
// 线上域名尚未确定:填写完整 HTTPS 地址,例如 https://你的域名/api/v1。
|
||||
onlineBaseUrl: '',
|
||||
timeout: 10000,
|
||||
};
|
||||
|
||||
/** develop 可切换本地/线上;体验版、正式版和未知运行环境均只允许线上入口。 */
|
||||
export function resolveApiConfig(version: MiniProgramVersion | string) {
|
||||
const environment: ApiEnvironment =
|
||||
version === 'develop' ? apiConfig.developmentEnvironment : 'online';
|
||||
const baseUrl = (
|
||||
environment === 'local'
|
||||
? apiConfig.localBaseUrl
|
||||
: apiConfig.onlineBaseUrl
|
||||
)
|
||||
.trim()
|
||||
.replace(/\/+$/, '');
|
||||
if (!baseUrl) throw new Error('请先配置线上接口地址');
|
||||
if (
|
||||
!/^https?:\/\/[^/?#\s]+\/api\/v1$/.test(baseUrl) ||
|
||||
(environment === 'online' && !baseUrl.startsWith('https://'))
|
||||
) {
|
||||
throw new Error('接口地址配置不正确');
|
||||
}
|
||||
return { environment, baseUrl, timeout: apiConfig.timeout };
|
||||
}
|
||||
|
||||
/** 请求时读取微信包版本,避免依赖 App 启动顺序,也不接受本地缓存覆盖正式版环境。 */
|
||||
export function getApiConfig() {
|
||||
return resolveApiConfig(wx.getAccountInfoSync().miniProgram.envVersion);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { getApiConfig } from '../config/api';
|
||||
|
||||
interface ApiEnvelope<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
/** 保留有限的状态用于页面反馈,不把服务器原始响应或凭证输出到界面。 */
|
||||
export class ApiRequestError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly statusCode: number = 0,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
/** 新后端统一请求层:校验 HTTP 与业务状态,旧云函数/CDN 请求保持独立。 */
|
||||
export async function apiRequest<T>(
|
||||
path: string,
|
||||
options: {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
data?: Record<string, unknown>;
|
||||
header?: Record<string, string>;
|
||||
} = {},
|
||||
): Promise<T> {
|
||||
const config = getApiConfig();
|
||||
if (!path.startsWith('/') || path.startsWith('//'))
|
||||
throw new Error('接口路径不正确');
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.request<ApiEnvelope<T>>({
|
||||
url: `${config.baseUrl}${path}`,
|
||||
method: options.method || 'GET',
|
||||
data: options.data,
|
||||
header: { 'Content-Type': 'application/json', ...options.header },
|
||||
timeout: config.timeout,
|
||||
success(res) {
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
reject(
|
||||
new ApiRequestError(
|
||||
res.statusCode === 404
|
||||
? '内容暂不可用'
|
||||
: '接口请求失败,请稍后重试',
|
||||
res.statusCode,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!res.data ||
|
||||
res.data.code !== 0 ||
|
||||
!Object.prototype.hasOwnProperty.call(res.data, 'data')
|
||||
) {
|
||||
reject(
|
||||
new ApiRequestError(
|
||||
'接口返回异常,请稍后重试',
|
||||
res.statusCode,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve(res.data.data);
|
||||
},
|
||||
fail() {
|
||||
reject(
|
||||
new ApiRequestError('连接失败,请确认后端已启动及网络配置'),
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { apiRequest } from './api-request';
|
||||
|
||||
/** 小程序使用的公开接口字段,不复用旧云集合的 _id 和管理配置。 */
|
||||
export interface ApiCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
parentId: string | null;
|
||||
status: 'active';
|
||||
}
|
||||
|
||||
export interface ApiWorksheet {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
category: string;
|
||||
path: string;
|
||||
ageMin: number;
|
||||
ageMax: number;
|
||||
difficulty: 1 | 2 | 3 | 4;
|
||||
previewImg: string | null;
|
||||
likes: string;
|
||||
downloads: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
contentUpdatedAt: string | null;
|
||||
}
|
||||
|
||||
interface WorksheetPage {
|
||||
items: ApiWorksheet[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
hasNext: boolean;
|
||||
}
|
||||
|
||||
/** 分类直接由服务端发布状态决定,不用本地目录补齐或恢复下架分类。 */
|
||||
export async function listCategories(): Promise<ApiCategory[]> {
|
||||
const rows = await apiRequest<ApiCategory[]>('/categories');
|
||||
if (
|
||||
!Array.isArray(rows) ||
|
||||
rows.some(
|
||||
(row) =>
|
||||
!row ||
|
||||
typeof row.id !== 'string' ||
|
||||
!row.id ||
|
||||
typeof row.name !== 'string' ||
|
||||
typeof row.icon !== 'string' ||
|
||||
row.status !== 'active',
|
||||
)
|
||||
) {
|
||||
throw new Error('分类数据异常,请稍后重试');
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 展示计数保持十进制字符串,不转换 Number 或重复叠加展示种子。 */
|
||||
function validateWorksheet(row: ApiWorksheet): void {
|
||||
if (
|
||||
!row ||
|
||||
!row.id ||
|
||||
typeof row.id !== 'string' ||
|
||||
typeof row.title !== 'string' ||
|
||||
typeof row.subtitle !== 'string' ||
|
||||
typeof row.category !== 'string' ||
|
||||
typeof row.path !== 'string' ||
|
||||
!Number.isInteger(row.ageMin) ||
|
||||
!Number.isInteger(row.ageMax) ||
|
||||
row.ageMin < 0 ||
|
||||
row.ageMax < row.ageMin ||
|
||||
![1, 2, 3, 4].includes(row.difficulty) ||
|
||||
typeof row.downloads !== 'string' ||
|
||||
!/^\d+$/.test(row.downloads) ||
|
||||
typeof row.likes !== 'string' ||
|
||||
!/^\d+$/.test(row.likes) ||
|
||||
(row.previewImg !== null && typeof row.previewImg !== 'string')
|
||||
) {
|
||||
throw new Error('题型数据异常,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
/** 取完整分页后才能在客户端计算收藏榜和混合排序;分页变化或缺页时整次重试。 */
|
||||
export async function listAllWorksheets(): Promise<ApiWorksheet[]> {
|
||||
const pageSize = 100;
|
||||
const rows: ApiWorksheet[] = [];
|
||||
const ids = new Set<string>();
|
||||
let expectedTotal: number | undefined;
|
||||
for (let page = 1; ; page++) {
|
||||
const result = await apiRequest<WorksheetPage>('/worksheets', {
|
||||
data: { page, pageSize },
|
||||
});
|
||||
if (
|
||||
!result ||
|
||||
!Array.isArray(result.items) ||
|
||||
!Number.isSafeInteger(result.total) ||
|
||||
result.total < 0 ||
|
||||
result.page !== page ||
|
||||
result.pageSize !== pageSize ||
|
||||
result.hasNext !== page * pageSize < result.total ||
|
||||
result.items.length !==
|
||||
Math.max(
|
||||
0,
|
||||
Math.min(pageSize, result.total - (page - 1) * pageSize),
|
||||
) ||
|
||||
(expectedTotal !== undefined && result.total !== expectedTotal)
|
||||
) {
|
||||
throw new Error('题型列表已变化,请重试');
|
||||
}
|
||||
expectedTotal = result.total;
|
||||
for (const row of result.items) {
|
||||
validateWorksheet(row);
|
||||
if (ids.has(row.id)) throw new Error('题型列表已变化,请重试');
|
||||
ids.add(row.id);
|
||||
rows.push(row);
|
||||
}
|
||||
if (!result.hasNext) return rows;
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开题型前重新检查公开详情,避免已下架内容从旧列表继续进入。 */
|
||||
export async function getWorksheet(id: string): Promise<ApiWorksheet> {
|
||||
const row = await apiRequest<ApiWorksheet>(
|
||||
`/worksheets/${encodeURIComponent(id)}`,
|
||||
);
|
||||
validateWorksheet(row);
|
||||
if (row.id !== id) throw new Error('题型数据异常,请稍后重试');
|
||||
return row;
|
||||
}
|
||||
Reference in New Issue
Block a user