import { PAPER_SIZE } from '../constants/colors'; import { downloadPrint, tryUnlockWithRewardedAd } from '../utils/downloadPrint'; import { shouldShowShareGuide, shouldUnlockWithAd } from '../utils/shareGuide'; import { BaseDrawService } from '../core/draw/baseDraw'; import tracker from '../utils/tracker'; import { defaultShareConfig } from '../config/config'; import { buildWorksheetPreviewCloudPath, isDebugPublishEnabled, type DebugPublishConfirmDetail, type DebugPublishMeta, } from '../utils/debugPublish'; /** * Canvas 相关的页面实例属性 */ export interface PageCanvasInstance { data: CanvasDataState; canvas: Canvas | null; ctx: RenderingContext | null; boxHeight: number; boxWidth: number; drawService: BaseDrawService | null; setData(data: any, callback?: () => void): void; route: string; getShareOptions(): ShareOptions; selectComponent(selector: string): any; getPublishMeta?(): DebugPublishMeta; getWorksheetStatsId?(): string; } /** * Canvas 数据状态接口 */ export interface CanvasDataState { hasContent: boolean; showShareDialog: boolean; boxWidth: number; boxHeight: number; functionId: string; currentMode?: string; pageTitle: string; subTitle?: string; data: any; setData(data: any, callback?: () => void): void; route: string; isDevEnv?: boolean; debugPublishVisible?: boolean; debugPublishLoading?: boolean; debugPublishMeta?: DebugPublishMeta | null; } /** * Canvas 初始化选项 */ export interface InitCanvasOptions { /** * 创建绘制服务的工厂函数 */ createDrawService: ( canvas: Canvas, ctx: RenderingContext, options?: Record, ) => BaseDrawService; /** * 绘制服务的选项 */ drawServiceOptions?: Record; /** * Canvas 初始化完成后回调 */ onCanvasReady?: () => void; } /** * 分享配置选项(文案/图统一,path 为当前页) */ export interface ShareOptions { path: string; title: string; imageUrl: string; } type SharePathData = { functionId?: string; worksheetId?: string; currentMode?: string; }; type SharePathPage = { route?: string; data?: SharePathData; }; /** 禁止右上角转发/分享到朋友圈的页面 route(不含开头 /) */ export const SHARE_DISABLED_ROUTES = new Set([ 'pages/favorites/favorites', 'pages/profile/profile', 'supportPages/settings/settings', ]); /** 关闭当前页分享菜单(收藏、我的、设置等) */ export function disablePageShareMenu() { wx.hideShareMenu({ menus: ['shareAppMessage', 'shareTimeline'], }); } /** 根据页面 route 与 data 生成分享 path */ export function buildPageSharePath(page?: SharePathPage): string { const route = page?.route || ''; if (!route) { return defaultShareConfig.path; } const normalized = route.startsWith('/') ? route : `/${route}`; const data = page?.data || {}; const functionId = (data.functionId || data.worksheetId) as | string | undefined; const currentMode = data.currentMode as string | undefined; if (!functionId) { return normalized; } const modeQuery = currentMode && currentMode !== functionId ? `&mode=${currentMode}` : ''; return `${normalized}?id=${functionId}${modeQuery}`; } /** 朋友圈分享 query(对应当前页 path 的查询串) */ export function buildPageShareTimelineQuery(page?: SharePathPage): string { const path = buildPageSharePath(page); const qIndex = path.indexOf('?'); return qIndex >= 0 ? path.slice(qIndex + 1) : ''; } /** 全站统一分享文案与图;path 默认当前页 */ export function getAppShareAppMessage( trackerLabel?: string, page?: SharePathPage, ) { if (trackerLabel) { tracker.reportShare(trackerLabel); } return { title: defaultShareConfig.title, path: buildPageSharePath(page), imageUrl: defaultShareConfig.imageUrl, }; } /** 全站统一朋友圈分享;query 对应当前页 */ export function getAppShareTimeline( trackerLabel?: string, page?: SharePathPage, ) { if (trackerLabel) { tracker.reportShare(trackerLabel); } return { title: defaultShareConfig.title, query: buildPageShareTimelineQuery(page), imageUrl: defaultShareConfig.imageUrl, }; } /** 供 Page({}) 直接展开:统一转发 / 分享到朋友圈(path 为当前页) */ export const appSharePageMethods = { onShareAppMessage(this: SharePathPage) { return getAppShareAppMessage(undefined, this); }, onShareTimeline(this: SharePathPage) { return getAppShareTimeline(undefined, this); }, }; /** * 页面信息接口 */ export interface PageInfo { title: string; desc?: string; } /** * 页面信息查找函数类型 */ export type PageInfoLookup = (functionId: string) => PageInfo | undefined; /** * 页面公共方法配置 */ export interface PageCommonMethodsConfig { /** * 分享配置 */ /** 可选;默认使用 config 中的 defaultShareConfig(统一首页 path / 文案 / 分享图) */ shareConfig?: { title: string; path: string; imageUrl: string; }; /** * 页面信息查找函数(可选) * 如果提供,initPageInfo 方法会使用它来查找页面信息 */ pageInfoLookup?: PageInfoLookup; } /** * 获取页面公共方法 * 这些方法可以在所有Canvas绘制页面中复用 * @param config 配置选项 */ export function getPageCommonMethods(config: PageCommonMethodsConfig = {}) { const { shareConfig = defaultShareConfig, pageInfoLookup } = config; return { /** * 初始化 Canvas */ initCanvas(this: PageCanvasInstance, options: InitCanvasOptions) { const query = wx.createSelectorQuery(); query .select('#canvasWrapper') .boundingClientRect((rect) => { if (!rect) return; const { width, height } = PAPER_SIZE['A4']; const boxWidth = rect.width; const boxHeight = boxWidth / (width / height); this.boxHeight = boxHeight; this.boxWidth = boxWidth; this.setData({ boxWidth, boxHeight }); const canvas = wx .createSelectorQuery() .select('#canvasContent'); canvas.fields({ node: true, size: true }).exec((res) => { if (res[0]) { const canvasNode = res[0].node; const ctx = canvasNode.getContext('2d'); const dpr = wx.getSystemInfoSync().pixelRatio; canvasNode.width = boxWidth * dpr; canvasNode.height = boxHeight * dpr; ctx.scale(dpr, dpr); this.canvas = canvasNode; this.ctx = ctx; // 创建绘制服务 const drawServiceOptions = { title: this.data.pageTitle, subTitle: this.data.subTitle || '', ...options.drawServiceOptions, }; this.drawService = options.createDrawService( canvasNode, ctx, drawServiceOptions, ); // 执行初始化完成回调 if (options.onCanvasReady) { options.onCanvasReady.call(this); } } }); }) .exec(); }, /** * 从 preview-card 组件的 canvas-ready 事件初始化 Canvas * 用于已将 canvas 节点下沉到 preview-card 组件的页面 */ initCanvasFromComponent( this: PageCanvasInstance, detail: { canvas: Canvas; ctx: RenderingContext; width: number; height: number; }, options: InitCanvasOptions, ) { this.canvas = detail.canvas; this.ctx = detail.ctx; this.boxWidth = detail.width; this.boxHeight = detail.height; this.setData({ boxWidth: detail.width, boxHeight: detail.height, }); const drawServiceOptions = { title: this.data.pageTitle, subTitle: this.data.subTitle || '', ...options.drawServiceOptions, }; this.drawService = options.createDrawService( detail.canvas, detail.ctx, drawServiceOptions, ); if (options.onCanvasReady) { options.onCanvasReady.call(this); } }, /** * 导出打印 */ async exportToPrint(this: PageCanvasInstance) { const worksheetId = typeof this.getWorksheetStatsId === 'function' ? this.getWorksheetStatsId() : this.data.functionId; const downloadOptions = { errorToast: '请先生成内容', trackerName: this.data.pageTitle, trackerMode: this.data.currentMode, worksheetId, }; // 第 2 次及以后满批:直接拉激励广告 if (shouldUnlockWithAd()) { const unlocked = await tryUnlockWithRewardedAd(); if (!unlocked) { return; } } // 当日首次满批:仅分享解锁,弹窗引导 if (shouldShowShareGuide()) { this.setData({ showShareDialog: true }); return; } await downloadPrint(this.canvas, downloadOptions); }, getShareOptions(this: PageCanvasInstance): ShareOptions { return { title: shareConfig.title, path: buildPageSharePath(this), imageUrl: shareConfig.imageUrl, }; }, /** * 分享小程序(统一文案与分享图,path 为当前页) */ onShareAppMessage(this: PageCanvasInstance) { return getAppShareAppMessage(this.data.pageTitle, this); }, /** * 分享到朋友圈(统一文案与分享图,query 为当前页参数) */ onShareTimeline(this: PageCanvasInstance) { return getAppShareTimeline(this.data.pageTitle, this); }, /** * 关闭分享引导弹窗 */ onCloseShareDialog(this: PageCanvasInstance) { this.setData({ showShareDialog: false }); }, /** * 分享成功回调 */ async onShareSuccess(this: PageCanvasInstance) { this.setData({ showShareDialog: false }); if (this.canvas) { const worksheetId = typeof this.getWorksheetStatsId === 'function' ? this.getWorksheetStatsId() : this.data.functionId; await downloadPrint(this.canvas, { errorToast: '请先生成内容', trackerName: this.data.pageTitle, trackerMode: this.data.currentMode, worksheetId, }); } }, syncDebugPublishEnv(this: PageCanvasInstance) { this.setData({ isDevEnv: isDebugPublishEnabled() }); }, onOpenDebugPublish(this: PageCanvasInstance) { if (!isDebugPublishEnabled()) return; if (typeof this.getPublishMeta !== 'function') { wx.showToast({ title: '页面未实现发布元数据', icon: 'none', }); return; } try { const meta = this.getPublishMeta(); this.setData({ isDevEnv: true, debugPublishVisible: true, debugPublishMeta: meta, }); } catch (error) { wx.showToast({ title: error instanceof Error ? error.message : '准备发布数据失败', icon: 'none', }); } }, onCloseDebugPublish(this: PageCanvasInstance) { this.setData({ debugPublishVisible: false }); }, /** * 确认发布 */ async onConfirmDebugPublish( this: PageCanvasInstance, e: WechatMiniprogram.CustomEvent, ) { const previewCard = this.selectComponent('#previewCard'); const debugPublishTools = this.selectComponent('#debugPublishTools'); const baseMeta = this.data.debugPublishMeta || (typeof this.getPublishMeta === 'function' ? this.getPublishMeta() : null); if (!previewCard?.exportToTempFile) { wx.showToast({ title: '预览组件不可用', icon: 'none', }); return; } if (!debugPublishTools?.processImage) { wx.showToast({ title: '发布工具不可用', icon: 'none', }); return; } if (!baseMeta) { wx.showToast({ title: '缺少发布元数据', icon: 'none', }); return; } const detail = e.detail; const publishMeta: DebugPublishMeta = { ...baseMeta, title: detail.meta.title, subtitle: detail.meta.subtitle, tags: detail.meta.tags, status: detail.meta.status, }; this.setData({ debugPublishLoading: true }); wx.showLoading({ title: '发布中...' }); try { const sourcePath = await previewCard.exportToTempFile({ fileType: 'jpg', quality: 1, }); const processed = await debugPublishTools.processImage({ sourcePath, settings: detail.settings, }); const cloudPath = buildWorksheetPreviewCloudPath( publishMeta.category, publishMeta.id, ); const uploadRes = await wx.cloud.uploadFile({ cloudPath, filePath: processed.tempFilePath, }); const cloudCall = (await wx.cloud.callFunction({ name: 'worksheetsPublish', data: { ...publishMeta, previewImg: uploadRes.fileID, }, })) as { result?: { success?: boolean; message?: string }; }; if (!cloudCall.result?.success) { throw new Error( cloudCall.result?.message || '云端写入失败', ); } this.setData({ debugPublishVisible: false, debugPublishMeta: { ...publishMeta, previewImg: uploadRes.fileID, }, }); wx.showToast({ title: `发布成功 ${Math.round(processed.size / 1024)}KB`, icon: 'success', }); } catch (error) { console.log('onConfirmDebugPublish error', error); wx.showToast({ title: error instanceof Error ? error.message : '发布失败', icon: 'none', }); } finally { wx.hideLoading(); this.setData({ debugPublishLoading: false }); } }, /** * 初始化页面信息 * * 支持两种调用方式: * 1. initPageInfo(functionId, defaultTitle) - 如果有 pageInfoLookup,会尝试查找;否则使用 defaultTitle * 2. initPageInfo({ title, desc, functionId }) - 直接提供页面信息 */ initPageInfo( this: PageCanvasInstance, functionIdOrOptions: | string | { title: string; desc?: string; functionId: string }, defaultTitle?: string, ) { let functionId: string; let title: string; let desc: string = ''; // 判断调用方式 if (typeof functionIdOrOptions === 'string') { // 方式1: initPageInfo(functionId, defaultTitle) functionId = functionIdOrOptions; title = defaultTitle || ''; // 如果有 pageInfoLookup,尝试查找 if (pageInfoLookup) { const pageInfo = pageInfoLookup(functionId); if (pageInfo) { title = pageInfo.title; desc = pageInfo.desc || ''; } } } else { // 方式2: initPageInfo({ title, desc, functionId }) functionId = functionIdOrOptions.functionId; title = functionIdOrOptions.title; desc = functionIdOrOptions.desc || ''; } this.setData({ pageTitle: title, subTitle: desc, functionId, }); wx.setNavigationBarTitle({ title }); }, }; } /** * 应用页面公共方法的辅助函数 * 自动混入公共方法,简化页面代码 * @param pageOptions 页面选项 * @param config 公共方法配置 * @returns 合并后的页面选项 */ export function applyPageMixin( pageOptions: any, config: PageCommonMethodsConfig = {}, ) { const commonMethods = getPageCommonMethods(config); // 提取 pageOptions 中的 data(如果有) const pageData = pageOptions.data || {}; // 合并页面选项和公共方法 // 注意:pageOptions 放在后面,这样页面可以覆盖公共方法 const mergedOptions: any = { ...commonMethods, ...pageOptions, // 处理 data 的合并(需要特殊处理,避免覆盖) data: { ...pageData, }, }; return mergedOptions; } /** * 创建页面的便捷函数 * 自动应用公共方法并注册为页面 * @param pageOptions 页面选项 * @param config 公共方法配置 */ export function createPage( pageOptions: any, config: PageCommonMethodsConfig = {}, ) { Page(applyPageMixin(pageOptions, config)); }