From 8de06ce66b91a487de04e25005c3b029560e3d43 Mon Sep 17 00:00:00 2001 From: R524809 Date: Fri, 16 Oct 2026 17:33:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=86=E7=B1=BB=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E6=9B=BF=E6=8D=A2=E4=B8=BAapi=20=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/v3.5/云存储资源目录清单.md | 67 +++ docs/v3.5/本地后端接口联调.md | 64 +++ miniprogram/config/api.ts | 37 ++ miniprogram/pages/category/category.less | 26 +- miniprogram/pages/category/category.model.ts | 150 +++++++ miniprogram/pages/category/category.ts | 431 +++++++------------ miniprogram/pages/category/category.wxml | 16 +- miniprogram/services/api-request.ts | 74 ++++ miniprogram/services/content-api.ts | 128 ++++++ package.json | 6 +- project.private.config.json | 2 +- tests/category-api.test.mjs | 368 ++++++++++++++++ 12 files changed, 1086 insertions(+), 283 deletions(-) create mode 100644 docs/v3.5/云存储资源目录清单.md create mode 100644 docs/v3.5/本地后端接口联调.md create mode 100644 miniprogram/config/api.ts create mode 100644 miniprogram/pages/category/category.model.ts create mode 100644 miniprogram/services/api-request.ts create mode 100644 miniprogram/services/content-api.ts create mode 100644 tests/category-api.test.mjs diff --git a/docs/v3.5/云存储资源目录清单.md b/docs/v3.5/云存储资源目录清单.md new file mode 100644 index 0000000..f2e0aab --- /dev/null +++ b/docs/v3.5/云存储资源目录清单.md @@ -0,0 +1,67 @@ +# 微信云存储资源目录清单 + +更新:2026-10-16。依据 doodle-mini 的 miniprogram 与 cloudfunctions 源码、路径构造函数及仓库内缓存分析,不是云端存储桶的实时文件列表。本次未下载、上传、迁移或删除云文件。 + +## 目录树 + +以下为代码涉及的云存储相对路径,仅列到最后一级目录,共 9 个末级目录: + +```text +/ +├── assets/ +│ ├── fonts/ +│ └── previews/ +│ ├── chinese/ +│ ├── english/ +│ ├── math/ +│ ├── papers/ +│ ├── pinyin/ +│ └── puzzle/ +├── content/ +└── user-avatars/ +``` + +对象存储中的“目录”是文件 key 的前缀。此处表示代码引用或能够写入的路径,不保证每个目录已经存在文件,也不能据此排除云端的历史/未引用目录。 + +## 用途与代码依据 + +| 最后一级目录 | 用途与确认范围 | 代码依据 | +| ------------------------ | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| assets/previews/chinese/ | 语文、控笔、识字等题型预览;首页静态项目也直接引用此路径 | [首页数据](../../miniprogram/pages/home/home.data.ts)、[语文发布配置](../../miniprogram/chinesePages/wordColoring/wordColoring.config.ts) | +| assets/previews/english/ | 英文字母题型预览;首页静态项目直接引用此路径 | [首页数据](../../miniprogram/pages/home/home.data.ts)、[英语发布配置](../../miniprogram/englishPages/letterTracing/letterTracing.config.ts) | +| assets/previews/math/ | 数学题型预览;由发布路径构造,旧页面配置缓存也有文件引用 | [数学发布配置](../../miniprogram/mathPages/mathDraw/mathDraw.config.ts)、[路径构造](../../miniprogram/utils/debugPublish.ts) | +| assets/previews/papers/ | 作业纸预览的动态上传路径;有发布配置,不能仅凭代码断言云端已有文件 | [作业纸发布配置](../../miniprogram/papersPages/paperSheet/paperSheet.config.ts)、[路径构造](../../miniprogram/utils/debugPublish.ts) | +| assets/previews/pinyin/ | 拼音题型预览;首页静态项目直接引用此路径 | [首页数据](../../miniprogram/pages/home/home.data.ts)、[拼音发布配置](../../miniprogram/pinyinPages/pinyinDictation/pinyinDictation.config.ts) | +| assets/previews/puzzle/ | 益智题型预览;由发布路径构造,旧页面配置缓存也有文件引用 | [益智发布配置](../../miniprogram/focusPages/focusDraw/focusDraw.config.ts)、[路径构造](../../miniprogram/utils/debugPublish.ts) | +| assets/fonts/ | ToneOZ 拼音字体;通过 cloud fileID 兑换临时链接,再加载字体 | [字体配置](../../miniprogram/core/font/fontProfiles.ts)、[字体加载](../../miniprogram/core/font/fontLoader.ts) | +| content/ | 首页展示配置 JSON;内容构建和定时刷新云函数上传到 content/page-config.json | [内容构建](../../cloudfunctions/pageContentBuild/index.js)、[定时刷新](../../cloudfunctions/homeAutoRefresh/index.js) | +| user-avatars/ | 用户头像;按用户 ID 和时间生成文件名上传,文件数量须另查用户数据或云端清单 | [头像上传](../../miniprogram/pages/profile/profile.ts) | + +预览图统一写入规则为 `assets/previews/${category}/${id}.jpg`,由页面发布元数据传入 category,再通过 `base/pageMixin.ts` 调用 wx.cloud.uploadFile。扫描发布配置得到 chinese、english、math、papers、pinyin、puzzle 六个分类。类型或分类导航里存在 craft,不等于存在 craft 云预览目录,本清单不据此添加。 + +cloudfunctions 中另有两处实际上传调用,均写 content/page-config.json。头像上传是一个调用点,题型预览上传是一个公共调用点;未发现其他业务云文件上传路径。旧云数据库 previewImg/avatarUrl 字段可以保存代码未硬编码的 fileID,因此运行时数据仍可能增加清单以外的引用。 + +## 已走 CDN 的远程资源 + +以下引用使用 `https://cdn.joeyone.cn`,不是当前源码中的微信 cloud fileID,不混入上述云存储目录。仅凭域名不能确认底层存储服务商或 bucket,也不假定需要重复迁移。 + +| CDN 路径 | 内容 | 代码依据 | +| ----------------- | -------------------------------------- | ------------------------------------------------------------------------ | +| doodle/fonts/ | 六个字体文件,包含手写、描红和数字字体 | [字体配置](../../miniprogram/core/font/fontProfiles.ts) | +| doodle/hanzi/ | 汉字笔画及读音 JSON | [汉字数据加载](../../miniprogram/chinesePages/shared/getWordsSvgJson.ts) | +| doodle/share-img/ | 小程序分享图片 | [分享配置](../../miniprogram/config/config.ts) | + +小程序包内的 assets/imgs、assets/tabBar、iconfont 等本地文件不属于云存储迁移范围;不能因为本地路径也以 assets 开头,就当作微信云文件。 + +## 后续快速同步建议 + +先做一次批量迁移,稳定后再考虑持续同步;当前仅为方案,未执行: + +1. 生成文件级清单:题型预览从旧 worksheets.previewImg/新后端 assets.source_ref 获取,头像从用户当前引用获取,补上已确认的字体和配置路径。代码目录用于限定范围,不能替代实际文件枚举。旧缓存只作线索,不作为当前有效引用的唯一依据。 +2. 按清单从原云环境授权下载到本地备份;记录相对路径、MIME、大小和 SHA-256,可失败重试。不将临时签名 URL 保存为长期资源地址。 +3. 上传七牛并保留可追踪路径映射。例如公共资源可采用统一前缀加原相对路径,或按资产 ID/内容摘要生成不可变 key;目标空间、前缀和同名文件处理在执行前明确。头像按现有后端设计使用私有空间,不能与公共预览一并公开。 +4. 回读校验后再更新引用:51 张已导入预览保留现有 assetId/preview_asset_id,回填七牛对象信息并标记 ready;字体更新配置 URL,头像按用户绑定并经服务端签名访问。 +5. 首页配置 JSON 含嵌套预览地址,需在迁移图片后重建或替换这些引用,并处理小程序缓存。仅复制 JSON 文件不会自动转换其中的 cloud fileID。 +6. 同时切换后续上传、发布和定时任务的目标,否则旧代码继续往微信云存储写入,七牛副本会再次落后。切换核对完成前保留源文件。 + +已有实施调研见 [云存储图片迁移七牛调研](../../../doodle-store/docs/v3.5/云存储图片迁移七牛调研.md)。本地未发现可直接使用的 tcb、cloudbase、qshell 命令;已有小程序 AppSecret 不等于源环境存储管理权限或七牛凭据。后续实施需确认可读源文件的授权方式,以及目标七牛 bucket、区域、HTTPS 域名和服务端凭据,不把凭据写入文档或前端。 diff --git a/docs/v3.5/本地后端接口联调.md b/docs/v3.5/本地后端接口联调.md new file mode 100644 index 0000000..bfdf058 --- /dev/null +++ b/docs/v3.5/本地后端接口联调.md @@ -0,0 +1,64 @@ +# 小程序分类页接入新后端 + +更新:2026-10-16。分类页已由旧 CloudBase 查询切换为 doodle-store 的公开 HTTP 接口;首页、微信登录、收藏、下载计数及其他页面仍使用原有云服务,本次不做双写或隐式数据同步。 + +## 全局配置 + +入口:`miniprogram/config/api.ts`。 + +| 配置 | 当前值 | 用途 | +| ---------------------- | ---------------------------- | --------------------------------------------- | +| developmentEnvironment | local | 开发版选择 local 或 online | +| localBaseUrl | http://127.0.0.1:8800/api/v1 | 开发者工具连接本机后端 | +| onlineBaseUrl | 空串 | 线上地址待确定;填写完整 HTTPS 地址及 /api/v1 | +| timeout | 10000 | 请求超时,毫秒 | + +环境通过 `wx.getAccountInfoSync().miniProgram.envVersion` 获取。develop 默认走 local,可以修改 developmentEnvironment 联调线上;trial、release 和未知版本只走 online。线上地址为空时明确显示“请先配置线上接口地址”,不会回退本地或云函数。该配置不保存 AppSecret、JWT 密钥或数据库凭据。 + +新后端请求统一使用 `services/api-request.ts`,分别校验 HTTP 状态、业务 code 和响应结构;`services/content-api.ts` 封装分类、完整题型分页与详情。旧 `utils/http.ts` 和旧云请求保留,以免影响其他页面。 + +## 如何切换接口环境 + +修改 [miniprogram/config/api.ts](../../miniprogram/config/api.ts): + +```ts +export const apiConfig = { + developmentEnvironment: 'local' as ApiEnvironment, + localBaseUrl: 'http://127.0.0.1:8800/api/v1', + onlineBaseUrl: '', + timeout: 10000, +}; +``` + +- **开发版连接本地**:将 `developmentEnvironment` 设置为 `'local'`,在 `localBaseUrl` 中填写本地接口地址。 +- **开发版连接线上**:先填写 `onlineBaseUrl`,再将 `developmentEnvironment` 设置为 `'online'`。 +- **体验版、正式版**:自动使用 `onlineBaseUrl`,不受 `developmentEnvironment` 影响。地址需要使用 HTTPS,并包含 `/api/v1`;目前线上地址尚未确定,保持空串,确定后再填写。 +- **手机测试本地服务**:保持 `'local'`,将 `localBaseUrl` 中的 `127.0.0.1` 改为电脑局域网 IP;同时将后端 `doodle-store/apps/server/.env` 中的 `HOST` 设置为 `0.0.0.0` 并重启后端。手机和电脑需在同一局域网。 + +修改后重新编译小程序即可。当前此配置用于已迁移的分类页,其他页面仍使用原云服务。 + +## 本地运行 + +1. 在 doodle-store 执行 `pnpm dev`。实际 `apps/server/.env` 和缺省端口已改为 8800,默认 HOST 仍为 127.0.0.1。 +2. 浏览器访问 `http://127.0.0.1:8800/api/v1/health` 或 `http://127.0.0.1:8800/api-docs`。 +3. 微信开发者工具打开 doodle-mini,重新编译后进入分类页。本机 `project.private.config.json` 的 urlCheck 已设为 false,公共 project.config.json 保持域名校验;私有配置覆盖公共配置。 +4. 真机测试时手机和电脑在同一局域网;后端 HOST 改为 0.0.0.0 并重启,将 localBaseUrl 的 IP 改为电脑局域网 IP,保留 8800 和 /api/v1。手机上的 127.0.0.1 指向手机自身。 + +开发者工具打开服务端口后才可使用微信官方 CLI 自动化。本机当前未开启该端口,本次没有修改 IDE 全局安全设置、上传代码或发布小程序。 + +## 分类页行为 + +- 分类导航读取 GET /categories,不再用写死目录决定上线状态;全部、热门推荐、下载最多是前端内置入口。现有后端目录包含 craft、不含 papers,页面按后端实际目录展示。 +- GET /worksheets?page=1&pageSize=100,按 hasNext 继续读取,完整成功后才生成页面数据。分页变化、重复 ID 或缺页时展示重试,不返回局部榜单。 +- 保留旧交互:全部及普通分类以 updatedAt 最近两条置顶,其余按下载数降序;热门推荐仍为收藏数前 12,下载最多为下载数前 12。前端“热门推荐”与服务端 sort=hot(下载热度)含义不同,因此不直接用后者替代。 +- likes/downloads 已含展示 seed,保留十进制字符串;按字符串长度及字典序比较,不转换为 Number,不要求微信运行时支持 BigInt。 +- previewImg 为空时显示所属分类图标;当前 51 张预览尚未迁移,不阻止题型数据展示。 +- 每次 onShow 重新读取。加载失败清空旧数据,展示原因和“重新加载”;不恢复 category.data.ts、写死目录或旧云查询。 +- 请求采用页面版本标记,旧请求或离开页面后的响应不会覆盖新数据。已下架的默认分类回到“全部”。 +- 点击题型先查询 GET /worksheets/:id,再使用当前服务端 path 跳转;404 时刷新列表,网络失败不直接进入内置生成页。通过分享或其他页面直接进入生成页的访问检查另行迁移,不能只凭分类页迁移认定全站均受新后端发布控制。 + +## 验证 + +`npm run test:category` 运行环境切换、请求失败、121 条完整分页、分页变化、64 位计数、服务端目录、空图、页面重试、过期响应和点击下架检查。`TEST_LOCAL_API=1 npm run test:category` 额外连接真实本地 8800 服务。 + +本次 9 项含真实接口的测试全部通过,读取 6 个分类及 51 个题型,卡片数据及点击详情正常。小程序全量 TypeScript 检查原有 117 条报错仍存在,修改前后结果一致,本次相关文件无新增错误。后端 pnpm check 通过。微信开发者工具模拟器的实际编译/显示尚未通过 CLI 验证(服务端口关闭),不能把脚本联调等同于真机验收。 diff --git a/miniprogram/config/api.ts b/miniprogram/config/api.ts new file mode 100644 index 0000000..9c84c67 --- /dev/null +++ b/miniprogram/config/api.ts @@ -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); +} diff --git a/miniprogram/pages/category/category.less b/miniprogram/pages/category/category.less index 474f38c..cbea79e 100644 --- a/miniprogram/pages/category/category.less +++ b/miniprogram/pages/category/category.less @@ -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; } -} \ No newline at end of file +} +// 请求失败时允许显式重试,不用本地静态题型补回下架内容。 +.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; + } +} diff --git a/miniprogram/pages/category/category.model.ts b/miniprogram/pages/category/category.model.ts new file mode 100644 index 0000000..dda497d --- /dev/null +++ b/miniprogram/pages/category/category.model.ts @@ -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; +} + +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(); + const icons = new Map(); + 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; +} diff --git a/miniprogram/pages/category/category.ts b/miniprogram/pages/category/category.ts index fc91ecf..676701d 100644 --- a/miniprogram/pages/category/category.ts +++ b/miniprogram/pages/category/category.ts @@ -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 = { - 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[] } - | { kind: 'static'; data: CategoryDataset }, -) { - let map: Record = {}; - 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 { - 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): 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[], -): CategoryDataset { - const itemsByCategory: Record = {}; - 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) { 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, diff --git a/miniprogram/pages/category/category.wxml b/miniprogram/pages/category/category.wxml index 2964138..42beb9a 100644 --- a/miniprogram/pages/category/category.wxml +++ b/miniprogram/pages/category/category.wxml @@ -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'}}"> + 📡 + {{errorMessage}} + + + + 🔍 没有找到匹配的内容 diff --git a/miniprogram/services/api-request.ts b/miniprogram/services/api-request.ts new file mode 100644 index 0000000..73c349c --- /dev/null +++ b/miniprogram/services/api-request.ts @@ -0,0 +1,74 @@ +import { getApiConfig } from '../config/api'; + +interface ApiEnvelope { + 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( + path: string, + options: { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + data?: Record; + header?: Record; + } = {}, +): Promise { + const config = getApiConfig(); + if (!path.startsWith('/') || path.startsWith('//')) + throw new Error('接口路径不正确'); + return new Promise((resolve, reject) => { + wx.request>({ + 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('连接失败,请确认后端已启动及网络配置'), + ); + }, + }); + }); +} diff --git a/miniprogram/services/content-api.ts b/miniprogram/services/content-api.ts new file mode 100644 index 0000000..6995e4b --- /dev/null +++ b/miniprogram/services/content-api.ts @@ -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 { + const rows = await apiRequest('/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 { + const pageSize = 100; + const rows: ApiWorksheet[] = []; + const ids = new Set(); + let expectedTotal: number | undefined; + for (let page = 1; ; page++) { + const result = await apiRequest('/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 { + const row = await apiRequest( + `/worksheets/${encodeURIComponent(id)}`, + ); + validateWorksheet(row); + if (row.id !== id) throw new Error('题型数据异常,请稍后重试'); + return row; +} diff --git a/package.json b/package.json index 34e4c70..c29e583 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "worksheet:patch:ages": "tsx scripts/worksheet-sync-cloud-payload.ts --ages", "worksheet:patch:tags": "tsx scripts/worksheet-sync-cloud-payload.ts --tags", "worksheet:patch:all": "tsx scripts/worksheet-sync-cloud-payload.ts --all", - "worksheet:patch:dry": "tsx scripts/worksheet-sync-cloud-payload.ts --all --dry-run" + "worksheet:patch:dry": "tsx scripts/worksheet-sync-cloud-payload.ts --all --dry-run", + "test:category": "tsx --test tests/category-api.test.mjs" }, "keywords": [], "author": "", @@ -22,6 +23,7 @@ "globals": "^15.14.0", "miniprogram-api-typings": "^4.0.5", "prettier": "3.4.2", - "typescript-eslint": "^8.18.1" + "typescript-eslint": "^8.18.1", + "typescript": "5.8.3" } } diff --git a/project.private.config.json b/project.private.config.json index 476fb1d..7ba554e 100644 --- a/project.private.config.json +++ b/project.private.config.json @@ -3,7 +3,7 @@ "projectname": "doodle-mini", "setting": { "compileHotReLoad": true, - "urlCheck": true, + "urlCheck": false, "coverView": false, "lazyloadPlaceholderEnable": false, "skylineRenderEnable": true, diff --git a/tests/category-api.test.mjs b/tests/category-api.test.mjs new file mode 100644 index 0000000..dab1ddf --- /dev/null +++ b/tests/category-api.test.mjs @@ -0,0 +1,368 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test, beforeEach } from 'node:test'; +import vm from 'node:vm'; +import ts from 'typescript'; +import { apiConfig, resolveApiConfig } from '../miniprogram/config/api.ts'; +import { + apiRequest, + ApiRequestError, +} from '../miniprogram/services/api-request.ts'; +import * as contentApi from '../miniprogram/services/content-api.ts'; +import * as model from '../miniprogram/pages/category/category.model.ts'; + +const categories = [ + { + id: 'math', + name: '数感启蒙', + icon: '📐', + parentId: null, + status: 'active', + }, +]; +const worksheet = (id, extra = {}) => ({ + id, + title: id, + subtitle: '', + category: 'math', + path: `/mathPages/mathDraw/mathDraw?id=${id}`, + ageMin: 3, + ageMax: 8, + difficulty: 1, + previewImg: null, + likes: '2', + downloads: '10', + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', + contentUpdatedAt: null, + ...extra, +}); +const pageOf = (items, page = 1, total = items.length) => ({ + items, + page, + pageSize: 100, + total, + hasNext: page * 100 < total, +}); +let requests; +let navigations; +let handler; + +beforeEach(() => { + requests = []; + navigations = []; + apiConfig.developmentEnvironment = 'local'; + apiConfig.onlineBaseUrl = ''; + handler = () => ({ data: [] }); + globalThis.wx = { + getAccountInfoSync: () => ({ miniProgram: { envVersion: 'develop' } }), + request(options) { + requests.push(options); + Promise.resolve() + .then(() => handler(options)) + .then((result) => { + options.success({ + statusCode: result.statusCode ?? 200, + data: result.raw ?? { + code: 0, + message: 'ok', + data: result.data, + }, + }); + }) + .catch(() => options.fail({ errMsg: 'request:fail' })); + }, + showToast() {}, + navigateTo(options) { + navigations.push(options.url); + }, + switchTab(options) { + navigations.push(options.url); + }, + }; +}); + +/** 仅隔离埋点与绘图分享依赖;分类页、请求层及数据模型均使用真实源码。 */ +function createCategoryPage(api = contentApi) { + let options; + const source = readFileSync( + new URL('../miniprogram/pages/category/category.ts', import.meta.url), + 'utf8', + ); + const code = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + }, + }).outputText; + const modules = { + '../../base/pageMixin': { appSharePageMethods: {} }, + '../../utils/index': { takePendingCategoryTabId: () => null }, + '../../utils/tracker': { + default: { + reportPageView() {}, + report() {}, + reportWorksheetClick() {}, + }, + }, + '../../services/api-request': { ApiRequestError }, + '../../services/content-api': api, + './category.model': model, + }; + vm.runInNewContext(code, { + exports: {}, + wx: globalThis.wx, + Error, + require(path) { + assert.ok(path in modules, path); + return modules[path]; + }, + Page(value) { + options = value; + }, + }); + const instance = { + ...options, + data: structuredClone(options.data), + setData(value) { + Object.assign(this.data, value); + }, + }; + return instance; +} + +function useRows(rows, selectedCategories = categories) { + handler = ({ url, data }) => { + const path = new URL(url).pathname; + if (path.endsWith('/categories')) return { data: selectedCategories }; + if (path.endsWith('/worksheets')) { + const page = data.page; + return { + data: pageOf( + rows.slice((page - 1) * 100, page * 100), + page, + rows.length, + ), + }; + } + const row = rows.find((item) => + path.endsWith(`/${encodeURIComponent(item.id)}`), + ); + return row ? { data: row } : { statusCode: 404 }; + }; +} + +test('环境配置:开发版默认本地,正式/体验/未知环境不回退本地,线上要求 HTTPS', () => { + assert.equal( + resolveApiConfig('develop').baseUrl, + 'http://127.0.0.1:8800/api/v1', + ); + for (const version of ['release', 'trial', 'unknown']) + assert.throws(() => resolveApiConfig(version), /线上/); + apiConfig.onlineBaseUrl = 'https://api.example.test/api/v1/'; + for (const version of ['release', 'trial']) + assert.equal( + resolveApiConfig(version).baseUrl, + 'https://api.example.test/api/v1', + ); + apiConfig.developmentEnvironment = 'online'; + assert.equal(resolveApiConfig('develop').environment, 'online'); + apiConfig.onlineBaseUrl = 'http://api.example.test/api/v1'; + assert.throws(() => resolveApiConfig('release'), /配置/); +}); + +test('请求层:HTTP、业务错误、畸形响应、网络错误均拒绝,不当作空成功', async () => { + useRows([]); + assert.deepEqual(await apiRequest('/categories'), categories); + assert.equal(requests[0].url, 'http://127.0.0.1:8800/api/v1/categories'); + for (const result of [ + { statusCode: 404 }, + { statusCode: 503 }, + { raw: { code: 1, data: [] } }, + { raw: { code: 0 } }, + { raw: 'error' }, + ]) { + handler = () => result; + await assert.rejects(apiRequest('/categories'), ApiRequestError); + } + handler = () => { + throw new Error('offline'); + }; + await assert.rejects(apiRequest('/categories'), /连接失败/); +}); + +test('完整分页:121 条无遗漏,计数字符串保留精度,空数据正常结束', async () => { + const rows = Array.from({ length: 121 }, (_, index) => + worksheet(`item-${index}`, { downloads: '9223372036854775906' }), + ); + useRows(rows); + const actual = await contentApi.listAllWorksheets(); + assert.deepEqual(actual, rows); + assert.equal(requests.length, 2); + assert.equal(actual[120].downloads, '9223372036854775906'); + useRows([]); + assert.deepEqual(await contentApi.listAllWorksheets(), []); +}); + +test('分页变化、重复记录、缺页及非法计数拒绝返回部分内容', async () => { + for (const result of [ + pageOf([worksheet('one'), worksheet('one')]), + pageOf([worksheet('one')], 1, 2), + pageOf([worksheet('one', { likes: 9007199254740992 })]), + { ...pageOf([]), hasNext: true }, + ]) { + handler = () => ({ data: result }); + await assert.rejects(contentApi.listAllWorksheets()); + } + const rows = Array.from({ length: 100 }, (_, index) => + worksheet(`item-${index}`), + ); + handler = ({ data }) => ({ + data: + data.page === 1 + ? pageOf(rows, 1, 101) + : pageOf([worksheet('last')], 2, 102), + }); + await assert.rejects(contentApi.listAllWorksheets()); +}); + +test('展示模型:采用服务端分类、空图有图标、不恢复失联分类,榜单精确比较 64 位计数', () => { + const rows = [ + worksheet('one', { likes: '9223372036854775806', downloads: '5' }), + worksheet('two', { + likes: '9223372036854775807', + downloads: '9223372036854775906', + }), + worksheet('new', { likes: '1', updatedAt: '2026-10-01T00:00:00Z' }), + worksheet('hidden-category', { category: 'papers' }), + ]; + const dataset = model.buildCategoryDataset( + [...categories, { ...categories[0], id: 'craft', name: '创意手工' }], + rows, + ); + assert.deepEqual( + dataset.sidebarTabs.map((row) => row.id), + ['all', 'hot', 'top-downloads', 'math', 'craft'], + ); + const all = model.getCategoryItems(dataset, 'all'); + assert.equal(all.length, 3); + assert.equal(all[0].id, 'new'); + assert.equal(all[0].icon, '📐'); + assert.equal(all[0].previewImg, ''); + assert.equal(model.getCategoryItems(dataset, 'hot')[0].id, 'two'); + assert.equal(model.getCategoryItems(dataset, 'top-downloads')[0].id, 'two'); + assert.equal(model.getCategoryItems(dataset, 'math', 'new').length, 1); + assert.equal(model.compareCountDescending('0005', '5'), 0); +}); + +test('页面:接口成功展示真实数据,失败清空旧目录/内容,重试可恢复,失效分类回到全部', async () => { + const page = createCategoryPage(); + page.onLoad({ id: 'papers' }); + useRows([worksheet('real')]); + await page.loadWorksheets(); + assert.equal(page.data.activeCategoryId, 'all'); + assert.equal(page.data.displayItems[0].id, 'real'); + assert.ok(!page.data.sidebarTabs.some((tab) => tab.id === 'papers')); + handler = () => { + throw new Error('offline'); + }; + await page.loadWorksheets(); + assert.equal(page.data.displayItems.length, 0); + assert.equal(page.data.sidebarTabs.length, 3); + assert.ok(page.data.errorMessage); + useRows([worksheet('retry')]); + await page.loadWorksheets(); + assert.equal(page.data.errorMessage, ''); + assert.equal(page.data.displayItems[0].id, 'retry'); +}); + +test('页面:旧请求/离开页面后的响应不会覆盖新请求', async () => { + let finishOld; + let calls = 0; + const page = createCategoryPage({ + ...contentApi, + listCategories: () => + ++calls === 1 + ? new Promise((resolve) => { + finishOld = resolve; + }) + : Promise.resolve(categories), + listAllWorksheets: async () => [worksheet(calls === 1 ? 'old' : 'new')], + }); + const first = page.loadWorksheets(); + await page.loadWorksheets(); + assert.equal(page.data.displayItems[0].id, 'new'); + finishOld(categories); + await first; + assert.equal(page.data.displayItems[0].id, 'new'); + let finish; + const hiddenPage = createCategoryPage({ + ...contentApi, + listCategories: () => + new Promise((resolve) => { + finish = resolve; + }), + listAllWorksheets: async () => [], + }); + const pending = hiddenPage.loadWorksheets(); + hiddenPage.onHide(); + finish(categories); + await pending; + assert.equal(hiddenPage.data.loading, true); +}); + +test('页面:点击前检查详情;正常跳转,404 下架刷新并阻止进入本地页面', async () => { + const page = createCategoryPage(); + const row = worksheet('live'); + useRows([row]); + await page.loadWorksheets(); + await page.onTapItem({ currentTarget: { dataset: { id: 'live' } } }); + assert.deepEqual(navigations, [row.path]); + navigations.length = 0; + useRows([]); + await page.onTapItem({ currentTarget: { dataset: { id: 'live' } } }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(navigations.length, 0); + assert.equal(page.data.displayItems.length, 0); +}); + +test( + '本地联调:真实 8800 分类、全部题型、页面展示与点击详情一致', + { skip: process.env.TEST_LOCAL_API !== '1' }, + async () => { + const realRequest = globalThis.wx.request; + globalThis.wx.request = (options) => { + const url = new URL(options.url); + for (const [key, value] of Object.entries(options.data || {})) + url.searchParams.set(key, String(value)); + fetch(url, { signal: AbortSignal.timeout(5000) }) + .then(async (response) => + options.success({ + statusCode: response.status, + data: await response.json(), + }), + ) + .catch(() => options.fail({})); + }; + try { + const page = createCategoryPage(); + await page.loadWorksheets(); + assert.equal(page.data.errorMessage, ''); + const rows = await contentApi.listAllWorksheets(); + assert.ok(rows.length > 0); + assert.equal(page.data.displayItems.length, rows.length); + assert.ok(page.data.displayItems.every((row) => row.icon)); + await page.onTapItem({ + currentTarget: { + dataset: { id: page.data.displayItems[0].id }, + }, + }); + assert.equal(navigations.length, 1); + console.log( + `本地联调:${page.data.sidebarTabs.length - 3} 个分类、${rows.length} 个题型。`, + ); + } finally { + globalThis.wx.request = realRequest; + } + }, +);