Files
2026-05-25 18:45:28 +08:00

640 lines
19 KiB
TypeScript

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<string, any>,
) => BaseDrawService;
/**
* 绘制服务的选项
*/
drawServiceOptions?: Record<string, any>;
/**
* 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<DebugPublishConfirmDetail>,
) {
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));
}