From 98dc338848c2e198ac1ca0ecca58944c5c55a38b Mon Sep 17 00:00:00 2001 From: R524809 Date: Fri, 23 Oct 2026 16:53:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E4=BD=9C=E4=B8=9A=E7=BA=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../debug-publish-tools.ts | 7 +- .../debug-publish-tools.wxml | 2 +- miniprogram/config/config.ts | 2 +- miniprogram/config/worksheets/papers.ts | 90 +++-- miniprogram/pages/home/home.data.ts | 2 +- miniprogram/pages/home/home.less | 182 ++++++----- miniprogram/pages/home/home.ts | 13 + miniprogram/pages/home/home.wxml | 111 ++++--- .../paperSheet/paperSheet.config.ts | 81 ++--- .../papersPages/paperSheet/paperSheet.ts | 196 ++++++++--- .../papersPages/paperSheet/paperSheet.wxml | 5 +- miniprogram/services/content-api.ts | 2 + package.json | 3 +- tests/paper-sheet.test.mjs | 308 ++++++++++++++++++ 14 files changed, 757 insertions(+), 247 deletions(-) create mode 100644 tests/paper-sheet.test.mjs diff --git a/miniprogram/components3.0/debug-publish-tools/debug-publish-tools.ts b/miniprogram/components3.0/debug-publish-tools/debug-publish-tools.ts index d5cf766..60debb4 100644 --- a/miniprogram/components3.0/debug-publish-tools/debug-publish-tools.ts +++ b/miniprogram/components3.0/debug-publish-tools/debug-publish-tools.ts @@ -41,6 +41,11 @@ Component({ type: Object, value: {}, }, + /** 页面可指定发布弹窗的默认裁剪模式,未指定时沿用仅裁头部。 */ + defaultCropMode: { + type: String, + value: 'header-only', + }, }, data: { @@ -83,7 +88,7 @@ Component({ formSubtitle: meta?.subtitle ?? '', formTags: meta?.tags?.join(', ') ?? '', formStatus: meta?.status ?? 'draft', - formCropMode: 'header-only', // 默认裁剪头部 + formCropMode: this.properties.defaultCropMode, formQuality: DEBUG_PUBLISH_DEFAULT_QUALITY, formWidth: DEBUG_PUBLISH_DEFAULT_WIDTH, }); diff --git a/miniprogram/components3.0/debug-publish-tools/debug-publish-tools.wxml b/miniprogram/components3.0/debug-publish-tools/debug-publish-tools.wxml index 56db7f6..807bbc1 100644 --- a/miniprogram/components3.0/debug-publish-tools/debug-publish-tools.wxml +++ b/miniprogram/components3.0/debug-publish-tools/debug-publish-tools.wxml @@ -152,7 +152,7 @@ bindchange="onWidthChange" /> - 默认按文档规则裁掉页眉和页脚,并尽量压缩到 {{maxSizeKB}}KB 内。 + 按所选裁剪模式处理图片,并尽量压缩到 {{maxSizeKB}}KB 内。 diff --git a/miniprogram/config/config.ts b/miniprogram/config/config.ts index 4ddc73e..cfdd683 100644 --- a/miniprogram/config/config.ts +++ b/miniprogram/config/config.ts @@ -7,7 +7,7 @@ // }; /** 当前应用版本号;profile 页和首页公告共用 */ -export const APP_VERSION = '3.4.0'; +export const APP_VERSION = '3.6.0'; export const defaultPrintConfig: PrintConfig = { // header: 'LogoImage', diff --git a/miniprogram/config/worksheets/papers.ts b/miniprogram/config/worksheets/papers.ts index 37d9b52..657afba 100644 --- a/miniprogram/config/worksheets/papers.ts +++ b/miniprogram/config/worksheets/papers.ts @@ -1,26 +1,72 @@ import type { WorksheetDefinition } from './types'; -export const PAPER_SHEET_WORKSHEET_DEFINITIONS = [ +/** 业务条目与绘制方式分离;颜色等用户参数不另建 worksheet。 */ +export const PAPER_TYPES = [ + { id: 'tian', name: '田字格', desc: '汉字练习' }, + { id: 'mi', name: '米字格', desc: '书法练习' }, + { id: 'square', name: '方格', desc: '书法练字纸' }, + { id: 'four-line', name: '四线三格', desc: '拼音 / 字母' }, + { id: 'letter', name: '信纸', desc: '横线信笺' }, + { id: 'horizontal', name: '横线', desc: '横线本' }, + { id: 'vertical', name: '竖线', desc: '竖排书写' }, + { id: 'dictation-hanzi', name: '汉字听写', desc: '田字格 + 订正栏' }, { - id: 'paper-sheet', - icon: 'draw-o', - title: '作业纸', - subtitle: '田字格 / 方格 / 信纸等多种作业纸一键打印', - ageMin: 3, - ageMax: 12, - difficulty: 1, - tags: [ - '作业纸', - '田字格', - '米字格', - '方格', - '四线三格', - '信纸', - '横线', - '竖线', - '听写', - '作业登记表', - ], - sortOrder: 50, + id: 'dictation-pinyin', + name: '拼音/英语听写', + desc: '四线三格 + 订正栏', }, -] as const satisfies ReadonlyArray; + { id: 'homework-log', name: '作业登记表', desc: '分科登记' }, +] as const; + +export type PaperType = (typeof PAPER_TYPES)[number]['id']; + +/** 原综合入口始终保留,不重分配历史收藏和统计。 */ +export const PAPER_SHEET_WORKSHEET_ID = 'paper-sheet'; + +export interface PaperSheetDefinition extends WorksheetDefinition { + rendererKey: 'paper-sheet'; + renderConfig: { paperType?: PaperType }; +} + +export const PAPER_SHEET_WORKSHEET_DEFINITIONS: ReadonlyArray = + [ + { + id: PAPER_SHEET_WORKSHEET_ID, + rendererKey: 'paper-sheet', + renderConfig: {}, + icon: 'draw-o', + title: '作业纸', + subtitle: '田字格 / 方格 / 信纸等多种作业纸一键打印', + ageMin: 3, + ageMax: 12, + difficulty: 1, + tags: [ + '作业纸', + '田字格', + '米字格', + '方格', + '四线三格', + '信纸', + '横线', + '竖线', + '听写', + '作业登记表', + ], + sortOrder: 50, + }, + ...PAPER_TYPES.map( + (paper, index): PaperSheetDefinition => ({ + id: `paper-${paper.id}`, + rendererKey: 'paper-sheet', + renderConfig: { paperType: paper.id }, + icon: 'draw-o', + title: paper.name, + subtitle: `${paper.desc},一键生成打印`, + ageMin: 3, + ageMax: 12, + difficulty: 1, + tags: ['作业纸', paper.name, paper.desc], + sortOrder: 51 + index, + }), + ), + ]; diff --git a/miniprogram/pages/home/home.data.ts b/miniprogram/pages/home/home.data.ts index 9af85ed..1607987 100644 --- a/miniprogram/pages/home/home.data.ts +++ b/miniprogram/pages/home/home.data.ts @@ -77,7 +77,7 @@ export const HOME_NOTICES: HomeNotice[] = [ }, { id: `update-${APP_VERSION}`, - text: `${APP_VERSION} 上线:新增幼儿认识时钟,连线练习题`, + text: `${APP_VERSION} 上线:新增作业纸`, path: '/mathPages/clockReading/clockReading?id=clock-connect', }, ]; diff --git a/miniprogram/pages/home/home.less b/miniprogram/pages/home/home.less index dbac91f..9cec2dc 100644 --- a/miniprogram/pages/home/home.less +++ b/miniprogram/pages/home/home.less @@ -134,18 +134,17 @@ } } -.home-hero-placeholder { - margin: @section-gap @page-padding-x 0; - height: 450rpx; - border-radius: 40rpx; - background: rgba(50, 46, 37, 0.04); +/** 紧凑预览轮播:按发布时仅裁头部后的比例展示,标题悬浮于图内。 */ +.home-hero-gallery { + margin-top: 16rpx; + background: radial-gradient(ellipse at center, #f3e8cc 0%, @bg-page 70%); } -/** 首页顶部「精选」swiper 轮播 */ .home-hero-swiper { - margin-top: 16rpx; width: 100%; - height: 450rpx; + // 单张宽 750 - 156 * 2 - 16 * 2 = 406rpx;裁头后预览约高 500rpx。 + // 包含上下阴影留白,不再给副标题、年龄等信息预留高度。 + height: 548rpx; } .home-hero-swiper-item { @@ -153,115 +152,134 @@ } .home-hero-card-wrap { - padding: 24rpx; + padding: 16rpx 16rpx 28rpx; box-sizing: border-box; - transition: transform 0.45s cubic-bezier(0.25, 0.46, 0.45, 0.94), - opacity 0.45s cubic-bezier(0.25, 0.46, 0.45, 0.94); - transform: scale(0.9); - opacity: 0.5; + transform-origin: center center; + transform: translateY(12rpx) scale(0.88); + opacity: 0.55; + transition: + transform 0.45s ease, + opacity 0.45s ease; } .home-hero-card-wrap--active { - transform: scale(1); + transform: translateY(0) scale(1); opacity: 1; } .home-hero-card { width: 100%; - border-radius: 32rpx; - overflow: visible; + position: relative; + border-radius: 16rpx; + overflow: hidden; background: #ffffff; - box-shadow: 0 10rpx 28rpx rgba(50, 46, 37, 0.08); + box-shadow: + 0 12rpx 30rpx rgba(96, 75, 33, 0.12), + 0 2rpx 6rpx rgba(96, 75, 33, 0.06); } .home-hero-card__top { - height: 260rpx; + width: 100%; + height: 0; + // 与 debugPublish 的 header-only 一致:595 × (842 - 110)。 + padding-top: 123.02521%; position: relative; - display: flex; - align-items: center; - justify-content: center; - /* 顶部背景用自身圆角裁切,替代 card 的 overflow: hidden */ - border-top-left-radius: 32rpx; - border-top-right-radius: 32rpx; overflow: hidden; - border-bottom: 1rpx solid rgba(50, 46, 37, 0.08); - background: #f5edd8; + background: #ffffff; } .home-hero-card__cover { + position: absolute; + left: 0; + top: 0; width: 100%; height: 100%; display: block; - object-fit: cover; + object-fit: contain; +} + +.home-hero-card__fallback { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 24rpx; + background: #fffdf7; } .home-hero-card__icon { - font-size: 96rpx; - line-height: 1; + font-size: 100rpx; + line-height: 1.2; } -.home-hero-card__badge { +.home-hero-card__title-overlay { position: absolute; - top: 24rpx; - right: 24rpx; - padding: 8rpx 18rpx; - border-radius: 999rpx; - background: rgba(50, 46, 37, 0.12); - font-size: 22rpx; - color: @text-title; - font-weight: 600; -} - -.home-hero-card__body { - padding: 28rpx @page-padding-inner-x 30rpx; -} - -.home-hero-card__title-row { - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - gap: 16rpx; - /* 允许 flex 子项在文本过长时正确截断 */ - min-width: 0; + left: 12rpx; + right: 12rpx; + bottom: 12rpx; + padding: 10rpx 16rpx; + border-radius: 12rpx; + background: rgba(255, 252, 244, 0.94); + box-shadow: 0 2rpx 12rpx rgba(50, 46, 37, 0.08); + pointer-events: none; } .home-hero-card__title { - font-size: 34rpx; - line-height: 1.35; + display: block; color: @text-title; + font-size: 28rpx; + line-height: 40rpx; font-weight: 700; - margin: 0; - flex: 1; - min-width: 0; - /* 标题与 meta 同行:超出后省略号(避免高度抖动) */ overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } -.home-hero-card__sub { - margin-top: 10rpx; - font-size: 24rpx; - line-height: 1.4; - color: @text-secondary; - /* 第二行:超出后省略号 */ - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; +.home-hero-pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 16rpx; + height: 32rpx; } +.home-hero-pagination__dots { + display: flex; + align-items: center; +} -.home-hero-card__meta { - margin: 0; - font-size: 22rpx; +.home-hero-pagination__target { + min-width: 32rpx; + height: 40rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.home-hero-pagination__dot { + width: 8rpx; + height: 8rpx; + border-radius: 999rpx; + background: #d5c9ad; + transition: + width 0.3s ease, + background 0.3s ease; +} + +.home-hero-pagination__dot--active { + width: 28rpx; + background: @text-title; +} + +.home-hero-pagination__count { color: @text-gray; - /* meta 也做单行截断,防止极端长文本把布局挤爆 */ - flex: 0 1 auto; - min-width: 0; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; + font-size: 20rpx; + line-height: 28rpx; } .home-section-panel { @@ -477,7 +495,6 @@ display: block; } - .home-page .category-tabs { background: @bg-page; } @@ -512,11 +529,11 @@ pointer-events: none; .home-hero-card__top { + background: #f3efe5; animation: home-skeleton-pulse 1.5s ease-in-out infinite; } - .home-hero-card__title, - .home-hero-card__sub { + .home-hero-card__title { background: #e8e2d5; border-radius: 8rpx; color: transparent; @@ -527,11 +544,6 @@ width: 50%; height: 36rpx; } - - .home-hero-card__sub { - width: 70%; - height: 26rpx; - } } .home-hot-card--skeleton { @@ -558,4 +570,4 @@ width: 65%; height: 24rpx; } -} \ No newline at end of file +} diff --git a/miniprogram/pages/home/home.ts b/miniprogram/pages/home/home.ts index 12e05ba..031c66a 100644 --- a/miniprogram/pages/home/home.ts +++ b/miniprogram/pages/home/home.ts @@ -279,6 +279,19 @@ Page({ this.setData({ heroSwiperCurrent: e.detail.current }); }, + /** 指示点与手势共用 swiper current,单张内容不自动轮播。 */ + onTapHeroDot(e: WechatMiniprogram.TouchEvent) { + const index = Number(e.currentTarget.dataset.index); + if ( + this.data.loading || + !Number.isInteger(index) || + index < 0 || + index >= this.data.featuredItems.length + ) + return; + this.setData({ heroSwiperCurrent: index }); + }, + onTabChange(e: WechatMiniprogram.CustomEvent) { const id = e.detail.id as string | undefined; if (!id) return; diff --git a/miniprogram/pages/home/home.wxml b/miniprogram/pages/home/home.wxml index 5362f75..fd728f4 100644 --- a/miniprogram/pages/home/home.wxml +++ b/miniprogram/pages/home/home.wxml @@ -59,63 +59,76 @@ - - - + + + + - - - {{item.icon}} - {{item.badge}} - - - + class="home-hero-card-wrap {{heroSwiperCurrent === index ? 'home-hero-card-wrap--active' : ''}}"> + + + + + {{item.icon}} + + + {{item.title}} - {{item.ageBand}}·{{item.difficulty}} - {{item.subtitle}} + + + + + + + - - + {{heroSwiperCurrent + 1}} / {{featuredItems.length}} + + diff --git a/miniprogram/papersPages/paperSheet/paperSheet.config.ts b/miniprogram/papersPages/paperSheet/paperSheet.config.ts index 89cdb7a..3f14db8 100644 --- a/miniprogram/papersPages/paperSheet/paperSheet.config.ts +++ b/miniprogram/papersPages/paperSheet/paperSheet.config.ts @@ -1,53 +1,42 @@ import type { DebugPublishMeta } from '../../utils/debugPublish'; import { inferGradeFromAge } from '../../utils/debugPublish'; -import { PAPER_SHEET_WORKSHEET_DEFINITIONS } from '../../config/worksheets/papers'; +import { + PAPER_SHEET_WORKSHEET_DEFINITIONS, + PAPER_SHEET_WORKSHEET_ID, + PAPER_TYPES, + type PaperType, +} from '../../config/worksheets/papers'; -type PaperSheetWorksheetRow = - (typeof PAPER_SHEET_WORKSHEET_DEFINITIONS)[number]; +export { PAPER_SHEET_WORKSHEET_ID, type PaperType }; +export const PAPER_TYPE_OPTIONS = PAPER_TYPES; -const WORKSHEET_BY_ID = Object.fromEntries( - PAPER_SHEET_WORKSHEET_DEFINITIONS.map((m) => [m.id, m]), -) as Record; +const WORKSHEET_BY_ID = new Map( + PAPER_SHEET_WORKSHEET_DEFINITIONS.map((entry) => [entry.id, entry]), +); -export const PAPER_SHEET_WORKSHEET_ID = PAPER_SHEET_WORKSHEET_DEFINITIONS[0].id; - -/** 纸张类型 id */ -export type PaperType = - | 'tian' - | 'mi' - | 'square' - | 'four-line' - | 'letter' - | 'horizontal' - | 'vertical' - | 'dictation-hanzi' - | 'dictation-pinyin' - | 'homework-log'; - -export interface PaperTypeOption { - id: PaperType; - name: string; - /** 次要说明 */ - desc: string; +/** 只接收已注册条目;旧综合入口继续支持 type,并兼容分享中的 mode。 */ +export function resolvePaperEntry(options: { + id?: string; + type?: string; + mode?: string; +}) { + const id = options.id || PAPER_SHEET_WORKSHEET_ID; + const entry = WORKSHEET_BY_ID.get(id); + if (!entry) throw new Error('当前作业纸暂不可用,请更新小程序'); + const legacyType = options.type || options.mode; + const paperType = + entry.renderConfig.paperType || + (legacyType && isValidPaperType(legacyType) + ? legacyType + : DEFAULT_PAPER_TYPE); + return { entry, paperType }; } -/** 纸张类型选项(展示顺序与用户需求一致) */ -export const PAPER_TYPE_OPTIONS: PaperTypeOption[] = [ - { id: 'tian', name: '田字格', desc: '汉字练习' }, - { id: 'mi', name: '米字格', desc: '书法练习' }, - { id: 'square', name: '方格', desc: '书法练字纸' }, - { id: 'four-line', name: '四线三格', desc: '拼音 / 字母' }, - { id: 'letter', name: '信纸', desc: '横线信笺' }, - { id: 'horizontal', name: '横线', desc: '横线本' }, - { id: 'vertical', name: '竖线', desc: '竖排书写' }, - { id: 'dictation-hanzi', name: '汉字听写', desc: '田字格 + 订正栏' }, - { - id: 'dictation-pinyin', - name: '拼音/英语听写', - desc: '四线三格 + 订正栏', - }, - { id: 'homework-log', name: '作业登记表', desc: '分科登记' }, -]; +export function getWorksheetIdForPaperType(paperType: PaperType): string { + return PAPER_SHEET_WORKSHEET_DEFINITIONS.find( + (entry) => entry.renderConfig.paperType === paperType, + )!.id; +} export interface PaperColorOption { id: string; @@ -75,16 +64,16 @@ export function isValidPaperType(id: string): id is PaperType { } export function getModeInfo(id: string) { - const m = WORKSHEET_BY_ID[id]; + const m = WORKSHEET_BY_ID.get(id); return m ? { title: m.title, desc: m.subtitle } : undefined; } export function isValidMode(id: string): boolean { - return id in WORKSHEET_BY_ID; + return WORKSHEET_BY_ID.has(id); } export function getPublishMetaByMode(id: string): DebugPublishMeta | null { - const m = WORKSHEET_BY_ID[id]; + const m = WORKSHEET_BY_ID.get(id); if (!m) return null; return { id: m.id, diff --git a/miniprogram/papersPages/paperSheet/paperSheet.ts b/miniprogram/papersPages/paperSheet/paperSheet.ts index 9397448..63f5de8 100644 --- a/miniprogram/papersPages/paperSheet/paperSheet.ts +++ b/miniprogram/papersPages/paperSheet/paperSheet.ts @@ -8,9 +8,16 @@ import { getModeInfo, getPublishMetaByMode, isValidPaperType, + resolvePaperEntry, + getWorksheetIdForPaperType, type PaperType, } from './paperSheet.config'; -import { createPage, type CanvasDataState } from '../../base/pageMixin'; +import { + createPage, + getPageCommonMethods, + type CanvasDataState, +} from '../../base/pageMixin'; +import { getWorksheet } from '../../services/content-api'; import { defaultShareConfig } from '../../config/config'; import type { DebugPublishMeta } from '../../utils/debugPublish'; import { @@ -20,6 +27,10 @@ import { } from '../../utils/favorites'; const pageInfoLookup = getModeInfo; +const commonMethods = getPageCommonMethods({ + pageInfoLookup, + shareConfig: defaultShareConfig, +}); type PageData = CanvasDataState & { worksheetId: string; @@ -28,6 +39,8 @@ type PageData = CanvasDataState & { selectedPaperType: PaperType; selectedColor: string; isPreviewFavorite: boolean; + isWorksheetReady: boolean; + favoriteLoading: boolean; isDevEnv: boolean; debugPublishVisible: boolean; debugPublishLoading: boolean; @@ -42,6 +55,8 @@ createPage( boxWidth: 0, drawService: null as PaperDrawService | null, _favoritedMap: {} as Record, + _entryRequest: 0, + _favoriteVersions: {} as Record, data: { pageTitle: '作业纸', @@ -54,28 +69,77 @@ createPage( selectedPaperType: DEFAULT_PAPER_TYPE, selectedColor: DEFAULT_PAPER_COLOR, isPreviewFavorite: false, + isWorksheetReady: false, + favoriteLoading: false, isDevEnv: false, debugPublishVisible: false, debugPublishLoading: false, debugPublishMeta: null, } as unknown as PageData, - onLoad(options: { id?: string; type?: string }) { + async onLoad(options: { id?: string; type?: string; mode?: string }) { this.syncDebugPublishEnv(); - - const initialType = - options.type && isValidPaperType(options.type) - ? (options.type as PaperType) - : DEFAULT_PAPER_TYPE; - - this.setData({ - selectedPaperType: initialType, - }); - - this.initPageInfo(PAPER_SHEET_WORKSHEET_ID, '作业纸'); + await this.openEntry(options); this.loadFavoritedMap(); }, + onUnload() { + this._entryRequest++; + }, + + /** 开发版直接预览已注册配置以制作封面;体验/正式版切换前校验公开状态。 */ + async openEntry(options: { + id?: string; + type?: string; + mode?: string; + }) { + const request = ++this._entryRequest; + this.setData({ + isWorksheetReady: false, + hasContent: false, + // 换纸张即丢弃旧发布表单,防止旧条目元数据配上新封面。 + debugPublishVisible: false, + debugPublishMeta: null, + }); + try { + const { entry, paperType } = resolvePaperEntry(options); + // 草稿在公开详情中固定为 404;开发版使用本地注册配置,不通过失败请求判断预览权限。 + if (!this.data.isDevEnv) { + const remote = await getWorksheet(entry.id); + if ( + entry.id !== PAPER_SHEET_WORKSHEET_ID && + (remote.rendering?.rendererKey !== entry.rendererKey || + remote.rendering?.paperType !== paperType) + ) { + throw new Error('作业纸配置暂不可用,请更新小程序'); + } + } + if (request !== this._entryRequest) return; + this.setData({ + worksheetId: entry.id, + selectedPaperType: paperType, + // 综合入口分享保留所选纸张;独立条目由 ID 唯一确定纸张类型。 + currentMode: + entry.id === PAPER_SHEET_WORKSHEET_ID + ? paperType + : entry.id, + isPreviewFavorite: this._favoritedMap[entry.id] === true, + isWorksheetReady: true, + }); + this.initPageInfo(entry.id, entry.title); + await this.drawCanvas(); + } catch (error) { + if (request !== this._entryRequest) return; + wx.showToast({ + title: + error instanceof Error + ? error.message + : '作业纸加载失败', + icon: 'none', + }); + } + }, + onCanvasReady(e: WechatMiniprogram.CustomEvent) { this.initCanvasFromComponent(e.detail, { createDrawService: ( @@ -93,23 +157,35 @@ createPage( }, async drawCanvas() { - if (!this.drawService) return; + if (!this.drawService || !this.data.isWorksheetReady) return; + const request = this._entryRequest; try { await (this.drawService as PaperDrawService).draw( this.data.selectedPaperType, this.data.selectedColor, ); - this.setData({ hasContent: true }); + if ( + request === this._entryRequest && + this.data.isWorksheetReady + ) + this.setData({ hasContent: true }); } catch (err) { console.error('paperSheet draw failed', err); - this.setData({ hasContent: false }); + if (request === this._entryRequest) + this.setData({ hasContent: false }); } }, onSelectPaperType(e: WechatMiniprogram.TouchEvent) { - const id = e.currentTarget.dataset.id as PaperType | undefined; - if (!id || id === this.data.selectedPaperType) return; - this.setData({ selectedPaperType: id }, () => this.drawCanvas()); + const id = e.currentTarget.dataset.id as string | undefined; + if (!id || !isValidPaperType(id)) return; + const worksheetId = getWorksheetIdForPaperType(id); + if ( + worksheetId === this.data.worksheetId && + this.data.isWorksheetReady + ) + return; + return this.openEntry({ id: worksheetId }); }, onSelectColor(e: WechatMiniprogram.TouchEvent) { @@ -124,33 +200,77 @@ createPage( onShare() {}, - async onPreviewFavorite() { - const next = !this.data.isPreviewFavorite; - this.setData({ isPreviewFavorite: next }); - const id = this.data.worksheetId; - if (id) { - this._favoritedMap[id] = next; - if (next) { - addFavorite(id); - } else { - removeFavorite(id); - } + async exportToPrint() { + if (!this.data.isWorksheetReady || !this.data.hasContent) return; + return commonMethods.exportToPrint.call(this); + }, + + async onShareSuccess() { + if (!this.data.isWorksheetReady || !this.data.hasContent) { + this.setData({ showShareDialog: false }); + return; + } + return commonMethods.onShareSuccess.call(this); + }, + + /** 请求失败不显示收藏成功,且响应只更新操作时的条目。 */ + async onPreviewFavorite() { + if (!this.data.isWorksheetReady || this.data.favoriteLoading) + return; + const id = this.data.worksheetId; + const next = !this.data.isPreviewFavorite; + this._favoriteVersions[id] = (this._favoriteVersions[id] || 0) + 1; + this.setData({ favoriteLoading: true }); + try { + const ok = await (next ? addFavorite(id) : removeFavorite(id)); + if (ok) { + this._favoritedMap[id] = next; + if (this.data.worksheetId === id) + this.setData({ isPreviewFavorite: next }); + } + wx.showToast({ + title: ok + ? next + ? '收藏成功' + : '已取消收藏' + : '收藏操作失败,请重试', + icon: 'none', + }); + } finally { + this.setData({ favoriteLoading: false }); } - wx.showToast({ - title: next ? '收藏成功' : '已取消收藏', - icon: 'none', - }); }, async loadFavoritedMap() { - const ids = [PAPER_SHEET_WORKSHEET_ID]; - this._favoritedMap = await batchCheckFavorited(ids); - if (this._favoritedMap[this.data.worksheetId]) { - this.setData({ isPreviewFavorite: true }); + const ids = [ + PAPER_SHEET_WORKSHEET_ID, + ...PAPER_TYPE_OPTIONS.map((paper) => + getWorksheetIdForPaperType(paper.id), + ), + ]; + const versions = { ...this._favoriteVersions }; + try { + const map = await batchCheckFavorited(ids); + // 慢速初次查询不能覆盖查询期间完成的收藏操作。 + for (const id of ids) { + if (versions[id] === this._favoriteVersions[id]) + this._favoritedMap[id] = map[id] === true; + } + this.setData({ + isPreviewFavorite: + this._favoritedMap[this.data.worksheetId] === true, + }); + } catch { + // 收藏加载失败不阻止预览;后续操作仍以服务端写入结果为准。 } }, + getWorksheetStatsId(): string { + return this.data.worksheetId; + }, + getPublishMeta(): DebugPublishMeta { + if (!this.data.isWorksheetReady) throw new Error('请先加载作业纸'); const meta = getPublishMetaByMode(this.data.worksheetId); if (!meta) { throw new Error('当前题型配置不存在'); diff --git a/miniprogram/papersPages/paperSheet/paperSheet.wxml b/miniprogram/papersPages/paperSheet/paperSheet.wxml index c3fe428..00353e6 100644 --- a/miniprogram/papersPages/paperSheet/paperSheet.wxml +++ b/miniprogram/papersPages/paperSheet/paperSheet.wxml @@ -1,11 +1,11 @@ - + { + let resolve; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +}; + +/** 使用真实页面和公共分享/导出方法,仅隔离微信、网络、绘图与埋点。 */ +function createPage({ + getWorksheet, + favorite = async () => true, + check = async () => ({}), +} = {}) { + const toasts = [], + downloads = [], + favorites = []; + const wx = { + getAccountInfoSync: () => ({ miniProgram: { envVersion: 'release' } }), + setNavigationBarTitle() {}, + showToast: (options) => toasts.push(options.title), + }; + const load = (file, modules) => { + const exports = {}; + const source = readFileSync(new URL(file, import.meta.url), 'utf8'); + vm.runInNewContext( + ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + }, + }).outputText, + { + exports, + wx, + console, + Error, + require(id) { + assert.ok(id in modules, id); + return modules[id]; + }, + }, + ); + return exports; + }; + const defaultShareConfig = { + title: '分享', + path: '/pages/index/index', + imageUrl: '', + }; + const mixin = load('../miniprogram/base/pageMixin.ts', { + '../services/member-api': {}, + '../services/api-request': {}, + '../constants/colors': {}, + '../core/draw/baseDraw': {}, + '../utils/downloadPrint': { + downloadPrint: async (_canvas, options) => downloads.push(options), + }, + '../utils/shareGuide': { + shouldUnlockWithAd: () => false, + shouldShowShareGuide: () => false, + }, + '../utils/tracker': { default: new Proxy({}, { get: () => () => {} }) }, + '../config/config': { defaultShareConfig }, + '../utils/debugPublish': { + ...debugPublish, + isDebugPublishEnabled: () => + wx.getAccountInfoSync().miniProgram.envVersion === 'develop', + }, + }); + let options; + load('../miniprogram/papersPages/paperSheet/paperSheet.ts', { + './draw/paperDrawService': {}, + './paperSheet.config': config, + '../../base/pageMixin': { + ...mixin, + createPage(value, settings) { + options = mixin.applyPageMixin(value, settings); + }, + }, + '../../config/config': { defaultShareConfig }, + '../../services/api-request': { ApiRequestError }, + '../../services/content-api': { + getWorksheet: + getWorksheet || + (async (id) => ({ + id, + rendering: { + rendererKey: 'paper-sheet', + paperType: config.resolvePaperEntry({ id }).paperType, + }, + })), + }, + '../../utils/favorites': { + addFavorite: async (id) => { + favorites.push(['add', id]); + return favorite(id); + }, + removeFavorite: async (id) => { + favorites.push(['remove', id]); + return favorite(id); + }, + batchCheckFavorited: check, + }, + }); + const page = { + ...options, + route, + data: structuredClone(options.data), + setData(value, callback) { + Object.assign(this.data, value); + callback?.(); + }, + drawService: { async draw() {} }, + canvas: {}, + }; + return { page, toasts, downloads, favorites, wx }; +} + +test('十种独立条目复用绘制器,旧入口/type 分享兼容,独立 ID 不受额外 type 干扰', () => { + assert.equal(PAPER_SHEET_WORKSHEET_DEFINITIONS.length, 11); + for (const paper of config.PAPER_TYPE_OPTIONS) { + const id = config.getWorksheetIdForPaperType(paper.id); + const { entry, paperType } = config.resolvePaperEntry({ + id, + type: 'unknown', + }); + assert.equal(paperType, paper.id); + assert.equal(entry.rendererKey, 'paper-sheet'); + assert.equal( + config.getPublishMetaByMode(id).path, + `/${route}?id=${id}`, + ); + } + assert.equal(config.resolvePaperEntry({}).entry.id, 'paper-sheet'); + assert.equal( + config.resolvePaperEntry({ id: 'paper-sheet', type: 'mi' }).paperType, + 'mi', + ); + assert.equal( + config.resolvePaperEntry({ id: 'paper-sheet', mode: 'vertical' }) + .paperType, + 'vertical', + ); + assert.throws(() => config.resolvePaperEntry({ id: 'paper-unknown' })); +}); + +test('切换后页面身份、收藏、分享、发布和实际导出统计均属于当前条目', async () => { + const { page, downloads, favorites } = createPage(); + await page.onLoad({ id: 'paper-mi' }); + assert.equal(page.data.pageTitle, '米字格'); + assert.equal(page.data.functionId, 'paper-mi'); + assert.equal(page.data.selectedPaperType, 'mi'); + assert.equal(page.onShareAppMessage().path, `/${route}?id=paper-mi`); + await page.onPreviewFavorite(); + assert.deepEqual(favorites, [['add', 'paper-mi']]); + await page.exportToPrint(); + assert.equal(downloads[0].worksheetId, 'paper-mi'); + page.setData({ + debugPublishVisible: true, + debugPublishMeta: page.getPublishMeta(), + }); + await page.onSelectPaperType({ + currentTarget: { dataset: { id: 'tian' } }, + }); + assert.equal(page.data.debugPublishVisible, false); + assert.equal(page.data.debugPublishMeta, null); + assert.equal(page.data.functionId, 'paper-tian'); + assert.equal(page.data.isPreviewFavorite, false); + assert.equal(page.getPublishMeta().id, 'paper-tian'); + assert.equal(page.onShareTimeline().query, 'id=paper-tian'); + await page.onSelectPaperType({ currentTarget: { dataset: { id: 'mi' } } }); + assert.equal(page.data.isPreviewFavorite, true); + await page.onPreviewFavorite(); + assert.deepEqual(favorites[1], ['remove', 'paper-mi']); + await page.onLoad({ id: 'paper-sheet', type: 'vertical' }); + assert.equal( + page.onShareAppMessage().path, + `/${route}?id=paper-sheet&mode=vertical`, + ); +}); + +test('快速切换丢弃旧响应,加载期间禁止导出,未知条目/下架/配置错误不生成其他纸张', async () => { + const old = deferred(); + const { page, downloads, toasts } = createPage({ + getWorksheet: (id) => + id === 'paper-mi' + ? old.promise + : Promise.resolve({ + rendering: { + rendererKey: 'paper-sheet', + paperType: 'tian', + }, + }), + }); + const first = page.onLoad({ id: 'paper-mi' }); + await page.exportToPrint(); + assert.equal(downloads.length, 0); + await page.onSelectPaperType({ + currentTarget: { dataset: { id: 'tian' } }, + }); + old.resolve({ rendering: { rendererKey: 'paper-sheet', paperType: 'mi' } }); + await first; + assert.equal(page.data.worksheetId, 'paper-tian'); + await page.openEntry({ id: 'paper-unknown' }); + assert.equal(page.data.hasContent, false); + assert.ok(toasts.length); + for (const getWorksheet of [ + async () => { + throw new ApiRequestError('已下架', 404); + }, + async () => ({ + rendering: { rendererKey: 'paper-sheet', paperType: 'tian' }, + }), + ]) { + const blocked = createPage({ getWorksheet }).page; + await blocked.onLoad({ id: 'paper-mi' }); + assert.equal(blocked.data.hasContent, false); + } +}); + +test('收藏失败不报成功,慢速查询不能覆盖新收藏,切换时收藏响应不写错条目', async () => { + const pending = deferred(); + const { page, toasts } = createPage({ favorite: async () => false }); + await page.onLoad({ id: 'paper-mi' }); + await page.onPreviewFavorite(); + assert.equal(page.data.isPreviewFavorite, false); + assert.ok(toasts.some((title) => title.includes('失败'))); + const active = createPage({ check: () => pending.promise }).page; + await active.onLoad({ id: 'paper-mi' }); + await active.onPreviewFavorite(); + pending.resolve({ 'paper-mi': false }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(active.data.isPreviewFavorite, true); + const write = deferred(); + const switching = createPage({ favorite: () => write.promise }).page; + await switching.onLoad({ id: 'paper-mi' }); + const saving = switching.onPreviewFavorite(); + await switching.openEntry({ id: 'paper-tian' }); + write.resolve(true); + await saving; + assert.equal(switching.data.isPreviewFavorite, false); + assert.equal(switching._favoritedMap['paper-mi'], true); +}); + +test('开发版离线预览十种已注册纸张,切换不请求公开详情,未知 ID 仍不可用', async () => { + let requests = 0; + const { page, wx, toasts } = createPage({ + getWorksheet: async () => { + requests++; + throw new Error('开发预览不应请求公开详情'); + }, + }); + wx.getAccountInfoSync = () => ({ miniProgram: { envVersion: 'develop' } }); + await page.onLoad({ id: 'paper-sheet' }); + for (const paper of config.PAPER_TYPE_OPTIONS) { + await page.onSelectPaperType({ + currentTarget: { dataset: { id: paper.id } }, + }); + assert.equal(page.data.hasContent, true); + assert.equal(page.data.selectedPaperType, paper.id); + assert.equal( + page.getPublishMeta().id, + config.getWorksheetIdForPaperType(paper.id), + ); + } + assert.equal(requests, 0); + assert.equal(toasts.length, 0); + await page.openEntry({ id: 'paper-unknown' }); + assert.equal(page.data.hasContent, false); + assert.equal(requests, 0); + assert.equal(toasts.length, 1); +}); + +test('体验版与正式版均校验公开详情,404 和网络故障不能降级成本地预览', async () => { + for (const version of ['trial', 'release']) { + for (const failure of [ + new ApiRequestError('未发布', 404), + new ApiRequestError('网络故障', 0), + ]) { + let requests = 0; + const { page, wx } = createPage({ + getWorksheet: async () => { + requests++; + throw failure; + }, + }); + wx.getAccountInfoSync = () => ({ + miniProgram: { envVersion: version }, + }); + await page.onLoad({ id: 'paper-square' }); + assert.equal(requests, 1); + assert.equal(page.data.hasContent, false); + assert.equal(page.data.isWorksheetReady, false); + } + } +});