UniAPP-通过节点信息自动绘制海报
使用背景:直接使用节点渲染的某个弹窗海报,没有找到相关的控件,所以手动写了一个。
这个是node2canvas基础
UniNode2Canvas
// utils/plugins/node2canvas/index.ts
// UniApp 中通常不需要引入特定的 runtime 类型,直接使用 uni 全局对象
// 如果需要在 TS 中获得更好的提示,可以安装 @dcloudio/types
type Position = 'static' | 'absolute' | 'fixed';
interface ElementStyles {
// 唯一标识,用于关联父子关系或图片源等,如果原始数据中没有,调用方需确保数据中包含必要信息
id?: string;
// 如果是图片节点,可能需要额外存储 src,因为 computedStyle 中通常不包含 src
src?: string;
// 如果是文本节点,存储文本内容
text?: string;
// 节点类型标识: 'view', 'text', 'image' 等,用于判断绘制逻辑
type?: 'view' | 'text' | 'image';
position: Position;
left: number;
top: number;
width: string;
height: string;
color: string;
font: string;
fontFamily: string;
fontSize: string;
fontStyle: string;
fontWeight: string;
lineHeight: string;
textAlign: CanvasTextAlign;
verticalAlign: CanvasTextBaseline;
background: string;
backgroundImage: string;
backgroundSize: string; // 确保存在
backgroundPosition: string; // 确保存在
backgroundRepeat: string; // 新增
backgroundColor: string;
backgroundClip: string;
padding: string;
paddingTop: string;
paddingRight: string;
paddingBottom: string;
paddingLeft: string;
margin: string;
marginTop: string;
marginRight: string;
marginBottom: string;
marginLeft: string;
border: string;
borderWidth: string;
borderColor: string;
borderStyle: string;
borderRadius: string;
textDecoration: string;
opacity: number;
zIndex: number;
boxShadow: string;
boxSizing: string;
// 可选:如果有子节点,可以嵌套,或者保持扁平由调用方排序
children?: ElementStyles[];
}
/**
* TextRenderConfig
*/
type TextRenderConfig = {
x: number;
y: number;
text: string;
width?: number;
textWidth: number;
fontSize: number;
textDecoration?: string;
}
/**
* UniNode2CanvasOptions
*/
export interface UniNode2CanvasOptions {
/** 必传: Canvas 实例 (在 UniApp H5/APP/小程序中获取方式不同,通常通过 canvas-id 或 type="2d" 获取) */
canvas: any; // UniApp 中 Canvas 实例类型可能因平台而异,这里用 any 兼容,建议根据实际平台定义
/** 必传: 已经获取好样式的节点数据数组 */
nodes: ElementStyles[];
/** 当前组件实例 (可选,用于某些特定上下文) */
component?: any;
/** 绘制完成后回调 */
onDone?: (this: UniNode2Canvas) => void;
/** 绘制进度监听 */
onProgress?: (_this: UniNode2Canvas, progress: number) => void;
}
export default class UniNode2Canvas {
private ctx: CanvasRenderingContext2D;
private canvas: any;
private nodes: ElementStyles[];
private component?: any;
private rootRect?: ElementStyles;
private onDone?: (_this: UniNode2Canvas) => void;
private onProgress?: (_this: UniNode2Canvas, progress: number) => void;
/**
* 设备DPR
*/
private dpr: number = 1;
/**
* 绘制状态
*/
private status: 'pending' | 'running' | 'done' = 'pending';
/**
* 总的绘制节点数量
*/
private nodeTotalCount: number = 0;
/**
* 当前的绘制节点数量
*/
private _nodeCurrentCount: number = 0;
get nodeCurrentCount(): number {
return this._nodeCurrentCount;
}
set nodeCurrentCount(value: number) {
this._nodeCurrentCount = value;
// 防止除以0
const total = this.nodeTotalCount || 1;
this.progress = this._nodeCurrentCount / total;
if (typeof this.onProgress === 'function') this.onProgress(this, this.progress);
if (this.nodeCurrentCount >= this.nodeTotalCount) {
this.status = 'done';
this.onDone && this.onDone(this);
}
}
private progress: number = 0;
/**
* 初始化操作
* @param options UniNode2CanvasOptions
*/
constructor(options: UniNode2CanvasOptions) {
if (!options.canvas || !options.nodes) throw new Error('canvas and nodes are required');
this.canvas = options.canvas;
this.nodes = options.nodes;
this.component = options.component;
// 在 UniApp 中,获取 Context 的方式可能略有不同,特别是小程序端
// 假设传入的 canvas 是已经初始化好的 Canvas 实例 (如 type="2d")
this.ctx = this.canvas.getContext('2d') as CanvasRenderingContext2D;
this.onDone = options.onDone;
this.onProgress = options.onProgress;
// 初始化
this.initCanvasBoard();
}
/**
* 初始化画布板
* @description 获取设备DPR并设置画布尺寸
*/
initCanvasBoard() {
// UniApp 获取系统信息
const sysInfo = uni.getSystemInfoSync();
this.dpr = sysInfo.pixelRatio || 1;
// 寻找根节点(通常是数组第一个,或者具有特定标识的节点)
// 假设 nodes[0] 是容器根节点,或者我们需要遍历找到最大的那个作为根
if (this.nodes.length > 0) {
// 简单策略:取第一个节点作为参考,或者取面积最大的
// 这里假设调用方传入的 nodes 顺序正确,或者第一个是根容器
const root = this.nodes[0];
// 设置画布实际像素大小
this.canvas.width = this.format(this.getValueByUnit(root.width));
this.canvas.height = this.format(this.getValueByUnit(root.height));
this.rootRect = root;
this.nodeTotalCount = this.nodes.length;
}
}
/**
* 开始绘制
*/
async draw() {
this.status = 'running';
this.nodeCurrentCount = 0; // 重置计数
// 清空画布
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// 遍历节点进行绘制
// 注意:如果 nodes 是扁平数组,需要确保绘制顺序(z-index 或 DOM 顺序)
// 如果 nodes 包含 children 树形结构,则需要递归
// 这里假设传入的是扁平化且已排序好的数组,或者简单的树形结构
for (const node of this.nodes) {
await this.renderNode(node);
}
}
/**
* 渲染单个节点
*/
async renderNode(rect: ElementStyles) {
// 1. 渲染背景
await this.renderBackgroundNode(rect);
// 2. 渲染边框
this.renderBorderNode(rect);
// 3. 根据类型渲染内容
if (rect.type === 'image') {
await this.drawImage(rect);
} else if (rect.type === 'text' || rect.text) {
this.drawText(rect);
} else if (rect.children && rect.children.length > 0) {
// 如果有子节点,递归渲染
// 注意:子节点的坐标通常是相对于父节点的,或者是绝对坐标。
// 原代码中 getRootRectRelationPosition 是基于绝对坐标计算的。
// 如果传入的数据已经是绝对坐标(left/top 相对于页面或容器),则直接绘制即可。
// 这里假设传入的 left/top 已经是相对于 Canvas 根容器的绝对坐标。
for (const child of rect.children) {
await this.renderNode(child);
}
}
// 更新进度
this.nodeCurrentCount++;
}
/**
* 绘制圆角矩形辅助函数
* @description 与原生 roundRect 保持一致,会将每个角的半径钳制为不超过 `min(width, height) / 2`,
* 避免圆角半径大于宽高一半时圆弧中心落在矩形外、导致裁剪路径错乱的问题。
*/
drawRoundRect(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number | number[]) {
const radii = Array.isArray(radius) ? radius : [radius, radius, radius, radius];
// CSS 规范:任意圆角半径最大不超过短边的一半,否则按 min(width, height) / 2 钳制
const maxRadius = Math.max(0, Math.min(width, height) / 2);
const [r1, r2, r3, r4] = radii.map(r => Math.min(Math.max(0, r), maxRadius));
ctx.beginPath();
ctx.arc(x + r1, y + r1, r1, Math.PI, Math.PI * 1.5);
ctx.lineTo(x + width - r2, y);
ctx.arc(x + width - r2, y + r2, r2, Math.PI * 1.5, Math.PI * 2);
ctx.lineTo(x + width, y + height - r3);
ctx.arc(x + width - r3, y + height - r3, r3, 0, Math.PI * 0.5);
ctx.lineTo(x + r4, y + height);
ctx.arc(x + r4, y + height - r4, r4, Math.PI * 0.5, Math.PI);
ctx.closePath();
}
/**
* 创建圆角矩形路径(自动 beginPath)
* @description 原生 roundRect 与 rect 一样不会隐式清空当前路径,只会向当前路径追加子路径。
* 若不在每次调用前 beginPath,多个节点的路径会跨节点累积,导致后续节点 clip() 时
* 裁剪区域被前序节点的形状污染(典型表现:重叠的圆角节点只有第一个生效)。
* @param x 左上角 X(物理像素,已 DPR 缩放)
* @param y 左上角 Y(物理像素,已 DPR 缩放)
* @param w 宽度(物理像素,已 DPR 缩放)
* @param h 高度(物理像素,已 DPR 缩放)
* @param radii 四角半径数组 [左上, 右上, 右下, 左下](物理像素,已 DPR 缩放)
*/
beginRoundRectPath(x: number, y: number, w: number, h: number, radii: number[]) {
this.ctx.beginPath();
if (typeof this.ctx.roundRect === 'function') {
this.ctx.roundRect(x, y, w, h, radii);
} else {
this.drawRoundRect(this.ctx, x, y, w, h, radii);
}
}
/**
* 将 rgb/rgba 颜色字符串转换为十六进制格式,以兼容小程序 Canvas addColorStop
* @param colorStr 颜色字符串,如 'rgb(255, 65, 117)' 或 'rgba(255, 65, 117, 0.5)'
* @returns 十六进制颜色字符串,如 '#FF4175' 或 '#FF417580'
*/
private formatColorToHex(colorStr: string): string {
const str = colorStr.trim();
// 匹配 rgb 或 rgba
const rgbaMatch = str.match(/^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*([01]?\.?\d*)\s*)?\)$/i);
if (rgbaMatch) {
const r = parseInt(rgbaMatch[1], 10);
const g = parseInt(rgbaMatch[2], 10);
const b = parseInt(rgbaMatch[3], 10);
const a = rgbaMatch[4] !== undefined ? parseFloat(rgbaMatch[4]) : 1;
const toHex = (num: number) => Math.min(255, Math.max(0, num)).toString(16).padStart(2, '0');
const hexColor = `#${toHex(r)}${toHex(g)}${toHex(b)}`;
// 如果存在透明度且小于 1,则拼接 alpha 的两位十六进制
if (a < 1) {
const alphaHex = Math.round(a * 255).toString(16).padStart(2, '0');
return `${hexColor}${alphaHex}`;
}
return hexColor;
}
// 如果不是 rgb 格式(可能本身就是 hex),直接返回
return str;
}
/**
* 绘制渐变色背景
*/
async drawGradient(
rect: any,
x: number,
y: number,
width: number,
height: number,
gradientStr: string
): Promise<void> {
const ctx = this.ctx;
// 1. 解析渐变字符串
const gradientData = this.parseGradient(gradientStr);
if (!gradientData || gradientData.colorStops.length === 0) return;
// 2. 计算渐变起止坐标(逻辑坐标)
const { angle, colorStops } = gradientData;
const radians = (angle - 90) * (Math.PI / 180); // CSS角度转数学弧度 (CSS中0deg是从下到上)
// 计算渐变线在矩形上的交点(确保渐变覆盖整个矩形)
const halfDiagonal = Math.abs(width * Math.cos(radians)) + Math.abs(height * Math.sin(radians));
const centerX = x + width / 2;
const centerY = y + height / 2;
const x1 = centerX - (Math.cos(radians) * halfDiagonal) / 2;
const y1 = centerY - (Math.sin(radians) * halfDiagonal) / 2;
const x2 = centerX + (Math.cos(radians) * halfDiagonal) / 2;
const y2 = centerY + (Math.sin(radians) * halfDiagonal) / 2;
// 3. 创建 Canvas 线性渐变
// 注意:ctx 的坐标空间是物理像素(canvas 尺寸已按 DPR 缩放),
// 这里所有坐标必须 format(),否则高 DPR 下渐变只绘制在左上角一小块,表现为"渐变没生效"
const gradient = ctx.createLinearGradient(this.format(x1), this.format(y1), this.format(x2), this.format(y2));
// 4. 添加颜色断点 (此时 color 已经由 parseGradient 转换为 Hex 格式)
colorStops.forEach((stop: { offset: number; color: string }) => {
gradient.addColorStop(stop.offset, stop.color); // color已经被formatColorToHex统一转为合法的Hex格式
});
// 5. 绘制(物理像素)
ctx.save();
ctx.fillStyle = gradient;
ctx.fillRect(this.format(x), this.format(y), this.format(width), this.format(height));
ctx.restore();
}
/**
* 解析 CSS 渐变字符串
*/
parseGradient(gradientStr: string): { angle: number; colorStops: { offset: number; color: string }[] } | null {
const match = gradientStr.match(/(linear|radial)-gradient\(([\s\S]+)$/);
if (!match) return null;
// 注意:rgba 里面包含逗号,直接 split(',') 会把 rgba 拆坏
// 这里使用更严谨的正则,直接提取 deg 和 颜色节点
const innerStr = match[2];
let angle = 180; // 默认从上到下
const colorStops: { offset: number; color: string }[] = [];
// 提取角度
const degMatch = innerStr.match(/^(\d+\.?\d*)deg/);
if (degMatch) {
angle = parseFloat(degMatch[1]);
}
// 提取颜色和偏移量 (兼容 rgb, rgba, hex)
// 匹配模式:颜色 + 可选的百分比
const colorReg = /(rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(?:,\s*[01]?\.?\d*\s*)?\)|#[0-9a-fA-F]{3,8})\s*([0-9]*\.?[0-9]+%)?/g;
let colorMatch;
while ((colorMatch = colorReg.exec(innerStr)) !== null) {
const rawColor = colorMatch[1];
const rawOffset = colorMatch[2];
// 核心:在这里将 rgb 转换为 Hex
const hexColor = this.formatColorToHex(rawColor);
const offset = rawOffset ? parseFloat(rawOffset) / 100 : -1; // -1 表示未指定偏移量
colorStops.push({ offset, color: hexColor });
}
// 处理未指定偏移量的颜色节点,均匀分配位置
if (colorStops.length > 0) {
let lastExplicitOffset = -1;
for (let i = 0; i < colorStops.length; i++) {
if (colorStops[i].offset === -1) {
// 找到下一个有明确偏移量的节点
let nextExplicitOffset = 1;
for (let j = i + 1; j < colorStops.length; j++) {
if (colorStops[j].offset !== -1) {
nextExplicitOffset = colorStops[j].offset;
break;
}
}
// 计算中间有多少个未知节点
let unknownCount = 0;
for (let k = i; k < colorStops.length; k++) {
if (colorStops[k].offset === -1) unknownCount++;
else break;
}
const step = (nextExplicitOffset - (lastExplicitOffset !== -1 ? lastExplicitOffset : 0)) / (unknownCount + 1);
colorStops[i].offset = (lastExplicitOffset !== -1 ? lastExplicitOffset : 0) + step * (unknownCount > 1 ? 1 : 1); // 简化步长计算
}
lastExplicitOffset = colorStops[i].offset;
}
// 兜底:如果第一个不是0,最后一个不是1,强行修正
if (colorStops[0].offset > 0) colorStops[0].offset = 0;
if (colorStops[colorStops.length - 1].offset < 1) colorStops[colorStops.length - 1].offset = 1;
}
return { angle, colorStops };
}
/**
* 统一绘制背景内容(自动区分渐变与背景图)
* @description 供 renderBackgroundNode 在圆角裁剪区域内外统一调用,
* 避免无圆角分支/降级兜底分支遗漏渐变背景,导致渐变背景不生效。
*/
private async drawBackgroundContent(rect: ElementStyles, x: number, y: number, width: number, height: number) {
const bgValue = rect.backgroundImage.trim();
// 判断是否为渐变色
if (bgValue.startsWith('linear-gradient') || bgValue.startsWith('radial-gradient')) {
await this.drawGradient(rect, x, y, width, height, bgValue);
} else {
// 否则按普通背景图片处理
await this.drawBackgroundImage(rect, x, y, width, height);
}
}
/**
* 渲染背景
*/
async renderBackgroundNode(rect: ElementStyles) {
if (rect.type === 'text') return; // 文本节点通常不单独绘制背景块,除非有特定需求
let width = this.getValueByUnit(rect.width);
let height = this.getValueByUnit(rect.height);
/// 这里需要针对width和height做处理 boxSizing 模式 如果是content-box需要加上padding距离 如果是border-box则使用默认宽度
if (rect.boxSizing === 'content-box') {
// width += Number(rect.paddingLeft) + Number(rect.paddingRight);
// height += Number(rect.paddingTop) + Number(rect.paddingBottom);
const { top, right, bottom, left } = this.getPadding(rect);
/// 开始累加宽度到绘制宽度中
if (top.unit == 'px') {
height += top.value;
}
if (bottom.unit == 'px') {
height += bottom.value
}
if (left.unit == 'px') {
width += left.value;
}
if (right.unit == 'px') {
width += right.value
}
// 结束累计
}
if (width <= 0 || height <= 0) return;
const { x, y } = this.getPosition(rect);
const radii = this.getBorderRadius(rect);
// 判断是否有有效的圆角 (数组非空且至少有一个值大于0)
const hasRadius = radii.length > 0 && radii.some(r => r > 0);
if (hasRadius) {
this.ctx.save();
try {
// 创建圆角裁剪路径
// 注意:roundRect 的半径参数也需要 format 以适配 DPR
const formattedRadii = radii.map(r => this.format(r));
this.beginRoundRectPath(this.format(x), this.format(y), this.format(width), this.format(height), formattedRadii);
this.ctx.clip();
// 清空当前路径,防止跨节点累积导致后续 clip 区域被污染
this.ctx.beginPath();
// 在裁剪区域内绘制背景色
if (rect.backgroundColor) {
this.ctx.fillStyle = rect.backgroundColor;
this.ctx.fillRect(this.format(x), this.format(y), this.format(width), this.format(height));
}
// 在裁剪区域内绘制背景(自动区分渐变与背景图)
if (rect.backgroundImage && rect.backgroundImage !== 'none') {
await this.drawBackgroundContent(rect, x, y, width, height);
}
} catch (e) {
// 兼容性兜底:roundRect/clip 失败时降级为不带圆角绘制,避免背景丢失
console.warn('背景圆角裁剪失败,已降级为直接绘制:', e);
if (rect.backgroundColor) {
this.ctx.fillStyle = rect.backgroundColor;
this.ctx.fillRect(this.format(x), this.format(y), this.format(width), this.format(height));
}
if (rect.backgroundImage && rect.backgroundImage !== 'none') {
await this.drawBackgroundContent(rect, x, y, width, height);
}
} finally {
// 无论如何都要恢复 ctx 状态,防止影响后续节点绘制
this.ctx.restore();
}
} else {
// --- 修复点:没有圆角时,直接使用 fillRect,不要调用 roundRect ---
if (rect.backgroundColor) {
this.ctx.fillStyle = rect.backgroundColor;
this.ctx.fillRect(this.format(x), this.format(y), this.format(width), this.format(height));
}
if (rect.backgroundImage && rect.backgroundImage !== 'none') {
await this.drawBackgroundContent(rect, x, y, width, height);
}
}
}
/**
* 解析 background-size 属性
* @param sizeStr background-size 字符串 (e.g., 'cover', 'contain', '100px 50%', 'auto')
* @param containerWidth 容器宽度
* @param containerHeight 容器高度
* @param imgWidth 图片原始宽度
* @param imgHeight 图片原始高度
* @returns { width: number, height: number } 计算后的绘制宽高
*/
parseBackgroundSize(
sizeStr: string,
containerWidth: number,
containerHeight: number,
imgWidth: number,
imgHeight: number
): { width: number; height: number } {
if (!sizeStr || sizeStr === 'auto') {
return { width: imgWidth, height: imgHeight };
}
const parts = sizeStr.trim().split(/\s+/);
let wPart = parts[0];
let hPart = parts[1] || wPart; // 如果只提供一个值,第二个值默认为 auto (但在 cover/contain 中逻辑不同,这里简化处理)
// 处理关键字
if (wPart === 'cover' || wPart === 'contain') {
const ratio = imgWidth / imgHeight;
const containerRatio = containerWidth / containerHeight;
let finalW, finalH;
if (wPart === 'cover') {
// 覆盖:保持比例,填满容器,可能裁剪
if (ratio > containerRatio) {
finalH = containerHeight;
finalW = finalH * ratio;
} else {
finalW = containerWidth;
finalH = finalW / ratio;
}
} else { // contain
// 包含:保持比例,完全显示在容器内,可能有留白
if (ratio > containerRatio) {
finalW = containerWidth;
finalH = finalW / ratio;
} else {
finalH = containerHeight;
finalW = finalH * ratio;
}
}
return { width: finalW, height: finalH };
}
// 处理具体数值或百分比
const parseVal = (val: string, base: number): number => {
if (val === 'auto') return 0; // 0 表示由另一边比例决定
if (val.includes('%')) {
return (parseFloat(val) / 100) * base;
}
return this.getValueByUnit(val);
};
let calcW = parseVal(wPart, containerWidth);
let calcH = parseVal(hPart, containerHeight);
// 如果一边是 auto,根据图片原始比例计算
if (calcW === 0 && calcH !== 0) {
calcW = (imgWidth / imgHeight) * calcH;
} else if (calcH === 0 && calcW !== 0) {
calcH = (imgHeight / imgWidth) * calcW;
} else if (calcW === 0 && calcH === 0) {
// 都是 auto,使用原始尺寸
calcW = imgWidth;
calcH = imgHeight;
}
return { width: calcW, height: calcH };
}
/**
* 解析 background-position 属性
* @param posStr background-position 字符串 (e.g., 'center', '10px 20%', 'left top')
* @param containerWidth 容器宽度
* @param containerHeight 容器高度
* @param bgWidth 背景图绘制宽度
* @param bgHeight 背景图绘制高度
* @returns { x: number, y: number } 绘制起始坐标
*/
parseBackgroundPosition(
posStr: string,
containerWidth: number,
containerHeight: number,
bgWidth: number,
bgHeight: number
): { x: number; y: number } {
if (!posStr) posStr = '0% 0%'; // 默认左上角
// 简单映射关键字
const keywordMap: Record<string, string> = {
'left': '0%', 'center': '50%', 'right': '100%',
'top': '0%', 'bottom': '100%'
};
let parts = posStr.trim().split(/\s+/);
// 如果只有一个关键字,另一个默认为 center
if (parts.length === 1) {
if (['left', 'right', 'center'].includes(parts[0])) {
parts = [parts[0], 'center'];
} else if (['top', 'bottom'].includes(parts[0])) {
parts = ['center', parts[0]];
} else {
parts = [parts[0], '50%'];
}
}
let xPart = parts[0];
let yPart = parts[1];
// 替换关键字为百分比
if (keywordMap[xPart]) xPart = keywordMap[xPart];
if (keywordMap[yPart]) yPart = keywordMap[yPart];
const parsePos = (val: string, containerSize: number, bgSize: number): number => {
if (val.includes('%')) {
const percent = parseFloat(val) / 100;
// 公式:position = (containerSize - bgSize) * percent
return (containerSize - bgSize) * percent;
}
// 具体像素值
return this.getValueByUnit(val);
};
const x = parsePos(xPart, containerWidth, bgWidth);
const y = parsePos(yPart, containerHeight, bgHeight);
return { x, y };
}
/**
* 处理背景重复绘制
*/
drawRepeatedImage(
img: any,
x: number, y: number,
bgWidth: number, bgHeight: number,
containerWidth: number, containerHeight: number,
repeat: string
) {
const shouldRepeatX = repeat === 'repeat' || repeat === 'repeat-x';
const shouldRepeatY = repeat === 'repeat' || repeat === 'repeat-y';
if (!shouldRepeatX && !shouldRepeatY) {
// no-repeat
this.ctx.drawImage(img, this.format(x), this.format(y), this.format(bgWidth), this.format(bgHeight));
return;
}
// 计算需要绘制的行列数
// 注意:如果背景图比容器大,且 no-repeat 方向上不需要重复,则只画一次
// 这里简化处理:只要允许重复,就铺满容器
const startX = shouldRepeatX ? x : Math.max(0, x); // 如果 x 为负(position导致),可能需要调整逻辑,这里假设 position 计算已处理边界
const startY = shouldRepeatY ? y : Math.max(0, y);
// 确定绘制范围
// 如果 position 导致图片在容器外,我们需要从容器边缘开始画
// 简化:我们遍历所有可能覆盖容器的瓦片
let currentX = startX;
// 如果 X 不重复,但 position 导致图片在容器左侧外,我们可能需要调整 currentX 到 0 或者只画一次
if (!shouldRepeatX) {
// 仅绘制一次,位置由 parseBackgroundPosition 决定
this.ctx.drawImage(img, this.format(currentX), this.format(startY), this.format(bgWidth), this.format(bgHeight));
return; // 单次绘制后直接返回,因为 Y 也不重复或者 X 不重复逻辑已覆盖
}
// X 方向重复逻辑
while (currentX < containerWidth) {
let currentY = startY;
if (!shouldRepeatY) {
// Y 不重复,只画一行
this.ctx.drawImage(img, this.format(currentX), this.format(currentY), this.format(bgWidth), this.format(bgHeight));
break;
}
// Y 方向重复逻辑
while (currentY < containerHeight) {
this.ctx.drawImage(img, this.format(currentX), this.format(currentY), this.format(bgWidth), this.format(bgHeight));
currentY += bgHeight;
}
currentX += bgWidth;
}
}
/**
* 绘制背景图片
*/
async drawBackgroundImage(rect: ElementStyles, x: number, y: number, width: number, height: number) {
// 解析 backgroundImage,通常格式为 url("...")
let url = rect.backgroundImage;
if (url.startsWith('url(')) {
url = url.slice(4, -1).replace(/["']/g, '');
}
if (!url) return;
return new Promise((resolve) => {
const img = this.canvas.createImage();
img.onload = () => {
// 1. 获取图片原始尺寸
const imgWidth = img.width;
const imgHeight = img.height;
// 2. 解析 background-size
// 默认值为 'auto',即原始尺寸
const bgSizeStr = rect.backgroundSize || 'auto';
const { width: bgDrawWidth, height: bgDrawHeight } = this.parseBackgroundSize(
bgSizeStr,
width,
height,
imgWidth,
imgHeight
);
// 3. 解析 background-position
// 默认值为 '0% 0%' (左上角)
const bgPosStr = rect.backgroundPosition || '0% 0%';
const { x: bgX, y: bgY } = this.parseBackgroundPosition(
bgPosStr,
width,
height,
bgDrawWidth,
bgDrawHeight
);
// 最终绘制坐标相对于容器左上角 (x, y) 的偏移
const finalX = x + bgX;
const finalY = y + bgY;
// 4. 解析 background-repeat
// 默认值为 'repeat'
const repeat = rect.backgroundRepeat || 'repeat';
// 5. 执行绘制
// 注意:如果使用了 clip (圆角),ctx 状态已经保存并裁剪,直接绘制即可
this.drawRepeatedImage(
img,
finalX,
finalY,
bgDrawWidth,
bgDrawHeight,
width,
height,
repeat
);
resolve(true);
};
img.onerror = (err: any) => {
console.error('Background Image load failed:', url, err);
resolve(false);
};
img.src = url + '?' + new Date().getTime() + Math.random();
});
}
/**
* 渲染边框
*/
renderBorderNode(rect: ElementStyles) {
if (rect.borderStyle === 'none' || !rect.borderWidth || parseInt(rect.borderWidth) <= 0) {
return;
}
const width = this.getValueByUnit(rect.width);
const height = this.getValueByUnit(rect.height);
const borderWidth = this.getValueByUnit(rect.borderWidth);
const { x, y } = this.getPosition(rect);
this.ctx.strokeStyle = rect.borderColor || '#000';
this.ctx.lineWidth = this.format(borderWidth);
const radii = this.getBorderRadius(rect).map(r => this.format(r));
if (radii.length > 0) {
// 绘制带圆角的边框路径(radii 需与 x/y/width/height 一样做 DPR 缩放,否则高 DPR 下圆角错位)
this.beginRoundRectPath(this.format(x), this.format(y), this.format(width), this.format(height), radii);
} else {
this.ctx.beginPath();
this.ctx.rect(this.format(x), this.format(y), this.format(width), this.format(height));
}
this.ctx.stroke();
}
/**
* 获取圆角数值
* @description 支持 px 与百分比两种写法。百分比按 CSS 规范相对元素宽高计算,
* 如 `border-radius: 50%` 返回短边的一半(接近全圆角/胶囊效果)。
* 返回数组顺序为 [左上, 右上, 右下, 左下],逻辑像素单位(未做 DPR 缩放)。
*/
getBorderRadius(rect: ElementStyles): number[] {
const borderRadius = rect.borderRadius;
if (!borderRadius || borderRadius === '0px' || borderRadius === 'auto') {
return [];
}
if (typeof borderRadius === 'string') {
const width = this.getValueByUnit(rect.width);
const height = this.getValueByUnit(rect.height);
const shortSide = Math.min(width, height);
// 解析单个圆角值,支持 px / 百分比
const parseRadius = (val: string): number => {
if (val.includes('%')) {
const percent = parseFloat(val) / 100;
return shortSide * percent;
}
return this.getValueByUnit(val);
};
const parts = borderRadius.split(' ').map(parseRadius);
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
if (parts.length === 4) return parts;
}
return [];
}
/**
* 获取相对于根容器的坐标
* 由于传入的是预处理数据,假设 left/top 已经是相对于 Canvas 容器的坐标
* 如果原始数据是相对于页面的,则需要减去 rootRect 的 offset
*/
getPosition(rect: ElementStyles) {
// 如果 rootRect 存在,且传入的坐标是页面绝对坐标,则转换
// 如果调用方已经处理好了相对坐标,则直接使用
// 这里保留原逻辑的转换思路,但依赖于 rootRect 是否被正确设置
if (this.rootRect) {
// 假设 rect.left 是页面绝对位置,rootRect.left 也是
const x = rect.left - this.rootRect.left;
const y = rect.top - this.rootRect.top;
return { x, y };
}
// 否则直接使用
return { x: rect.left, y: rect.top };
}
/**
* DPR 适配
*/
format(val: number) {
return val * this.dpr;
}
/**
* 去除单位转数字
*/
getValueByUnit(val: string | number): number {
if (typeof val === 'number') return val;
if (!val) return 0;
return Number(val.replace(/[a-zA-Z%]/g, '')) || 0;
}
/**
* 解析单个 padding 值
* @param val 单个 padding 值字符串,如 '10px'、'5%'、'16rpx'
* @param baseWidth 元素宽度(逻辑像素),用于百分比换算
* @returns { value, unit } 数值与单位;百分比已按宽度换算为实际像素值(unit 标记为 'px')
*/
private parsePaddingValue(val: string, baseWidth: number): { value: number; unit: string } {
const trimmed = (val || '').trim();
// 匹配「数字 + 可选单位」,支持小数与负数
const match = trimmed.match(/^(-?\d+(?:\.\d+)?)([a-zA-Z%]*)$/);
if (!match) return { value: 0, unit: 'px' };
const num = parseFloat(match[1]);
const unit = match[2] || 'px';
if (unit === '%') {
// CSS 规范:padding 百分比相对包含块(元素)宽度计算,含 top/bottom
return { value: (num / 100) * baseWidth, unit: 'px' };
}
// 其他单位(px/rpx/em 等)正常提取数字与单位返回
return { value: num, unit };
}
/**
* 解析元素的 padding 值
* @description 支持简写(1/2/3/4 值)与四方向独立属性(paddingTop 等)。
* 独立方向属性优先于简写;百分比 padding 按元素宽度换算为实际像素值。
* @param rect 元素样式
* @returns 按 top/right/bottom/left 嵌套返回,每个方向包含数值与单位
* @example
* rect.padding = '10px 20%' → {
* top: { value: 10, unit: 'px' },
* right: { value: 20, unit: 'px' }, // 已按宽度换算
* bottom: { value: 10, unit: 'px' },
* left: { value: 20, unit: 'px' },
* }
*/
getPadding(rect: ElementStyles): {
top: { value: number; unit: string };
right: { value: number; unit: string };
bottom: { value: number; unit: string };
left: { value: number; unit: string };
} {
const baseWidth = this.getValueByUnit(rect.width);
const resolve = (val?: string) => this.parsePaddingValue(val || '', baseWidth);
// 1. 独立方向属性优先(如 paddingTop),比简写更精确
if (rect.paddingTop || rect.paddingRight || rect.paddingBottom || rect.paddingLeft) {
return {
top: resolve(rect.paddingTop),
right: resolve(rect.paddingRight),
bottom: resolve(rect.paddingBottom),
left: resolve(rect.paddingLeft),
};
}
// 2. 处理简写 padding
const parts = (rect.padding || '0px').trim().split(/\s+/).map(resolve);
if (parts.length === 1) {
const [v] = parts;
return { top: v, right: v, bottom: v, left: v };
}
if (parts.length === 2) {
const [v, h] = parts;
return { top: v, right: h, bottom: v, left: h };
}
if (parts.length === 3) {
const [t, h, b] = parts;
return { top: t, right: h, bottom: b, left: h };
}
// 4 值:上 右 下 左
const [t, r, b, l] = parts;
return { top: t, right: r, bottom: b, left: l };
}
/**
* 绘制文本
*/
drawText(rect: ElementStyles) {
if (!rect.text) return;
const { x, y } = this.getPosition(rect);
const fontSize = this.getValueByUnit(rect.fontSize);
let width = this.getValueByUnit(rect.width);
let height = this.getValueByUnit(rect.height);
/// 判断是否为渐变字
const isGradientText = rect.backgroundImage?.includes('linear-gradient') && rect.backgroundClip?.includes('text');
// 1. 设置字体样式 (必须在 measureText 之前设置,否则测量宽度不准)
this.ctx.font = `${rect.fontWeight || 'normal'} ${this.format(fontSize)}px ${rect.fontFamily || 'sans-serif'}`;
// 2. 处理宽高为 0 的情况 (兜底)
if (width <= 0) {
const metrics = this.ctx.measureText(rect.text);
width = metrics.width / this.dpr;
}
if (height <= 0) {
height = fontSize; // 或者根据 lineHeight 计算
}
// 3. 关键:确定绘制的参考坐标和对齐方式
const textAlign = rect.textAlign || 'left';
this.ctx.textAlign = textAlign;
this.ctx.textBaseline = 'middle'; // 垂直方向我们手动控制基线
// 4. 计算水平绘制的起始 X 坐标
let drawX = x;
if (textAlign === 'center') {
drawX = x + width / 2;
} else if (textAlign === 'right') {
drawX = x + width;
}
// 5. 计算垂直绘制的 Y 坐标 (垂直居中)
const centerY = y + height / 2;
// 6. 处理多行文本或自动换行
const lineHeight = this.getValueByUnit(rect.lineHeight) || fontSize * 1.2;
// 以 DOM 真实高度推算的行数作为换行上限,避免不同机型测量误差多切出一行
const maxLines = this.getMaxLines(rect, lineHeight);
const lines = this.getLines(rect.text, width, maxLines, fontSize);
const totalTextHeight = lines.length * lineHeight;
// 重新计算起始 Y,使得整个文本块在 height 中垂直居中
let startY = centerY - totalTextHeight / 2 + lineHeight / 2;
/// ================= 核心逻辑:处理渐变文字 =================
if (isGradientText) {
// 解析渐变字符串
const gradientData = this.parseGradient(rect.backgroundImage);
if (gradientData && gradientData.colorStops.length > 0) {
const { angle, colorStops } = gradientData;
const radians = (angle - 90) * (Math.PI / 180); // CSS角度转数学弧度
// 测量完整文本块的宽高,用于确定渐变色的覆盖范围
// 注意:渐变范围应基于整个文本块,而不是单行,否则每行渐变会重新开始
const maxLineWidth = Math.max(...lines.map(line => this.ctx.measureText(line).width / this.dpr));
const gradientWidth = maxLineWidth;
const gradientHeight = totalTextHeight;
// 计算渐变线在文本块上的交点
const halfDiagonal = Math.abs(gradientWidth * Math.cos(radians)) + Math.abs(gradientHeight * Math.sin(radians));
const centerX = drawX; // 渐变中心 X 基于 textAlign 的 drawX
const centerYForGradient = startY - lineHeight / 2 + totalTextHeight / 2; // 整个文本块的垂直中心
const x1 = centerX - (Math.cos(radians) * halfDiagonal) / 2;
const y1 = centerYForGradient - (Math.sin(radians) * halfDiagonal) / 2;
const x2 = centerX + (Math.cos(radians) * halfDiagonal) / 2;
const y2 = centerYForGradient + (Math.sin(radians) * halfDiagonal) / 2;
// 创建 Canvas 线性渐变对象
const gradient = this.ctx.createLinearGradient(x1, y1, x2, y2);
// 添加颜色断点 (已在 parseGradient 中转为 Hex 格式)
colorStops.forEach((stop: { offset: number; color: string }) => {
gradient.addColorStop(stop.offset, stop.color);
});
// 将渐变对象赋值给 fillStyle
this.ctx.fillStyle = gradient;
} else {
// 解析失败兜底
this.ctx.fillStyle = rect.color || '#000';
}
} else {
// 普通单色文字
this.ctx.fillStyle = rect.color || '#000';
}
/// ================= 核心逻辑结束 =================
// 7. 遍历绘制文本行
lines.forEach((line, index) => {
const currentY = startY + index * lineHeight;
this.ctx.fillText(line, this.format(drawX), this.format(currentY), this.format(width));
});
// 8. 绘制装饰线
if (rect.textDecoration && rect.textDecoration !== 'none') {
const firstLineWidth = this.ctx.measureText(lines[0]).width / this.dpr;
let lineX = x;
if (textAlign === 'center') lineX = x + (width - firstLineWidth) / 2;
if (textAlign === 'right') lineX = x + width - firstLineWidth;
this.ctx.fillStyle = rect.color || '#000';
this.drawTextDecoration({
x: lineX,
y: startY,
text: lines[0],
textWidth: firstLineWidth,
fontSize
}, rect.textDecoration);
}
}
/**
* 辅助函数:简单的文本换行处理
* @param text 待换行文本
* @param maxWidth 单行可用的最大宽度(逻辑像素)
* @param maxLines 允许的最大行数(由 DOM 真实高度推算,见 getMaxLines)
* @param fontSize 字号(逻辑像素),用于放大容差
* @description 不同机型(字体回退、亚像素渲染、DPR 取整)下 measureText 的测量结果
* 与浏览器/CSS 实际排版宽度存在微小差异,且字号越大、字重越粗(加粗)
* 差异越明显。若严格按 > maxWidth 判断,会导致本应单行的文本被误切成两行,
* 进而使文本块垂直居中错乱。这里做三重防护:
* 1. 引入随字号、宽度变化的容差;
* 2. 整段文本能放进一行时直接作为一行,避免逐字符累加的误差;
* 3. 行数不超过 maxLines,避免多切出一行。
*/
getLines(text: string, maxWidth: number, maxLines: number = Infinity, fontSize: number = 0): string[] {
const lines: string[] = [];
// 容差:至少 1px,同时随宽度和字号放大,用于吸收不同机型的测量误差
const tolerance = Math.max(1, maxWidth * 0.02, fontSize * 0.1);
const paragraphs = text.split('\n'); // 先按显式换行符分割
paragraphs.forEach(paragraph => {
if (paragraph === '') {
lines.push('');
return;
}
// 整段文本能放进一行时直接返回,避免逐字符测量累加带来的误差
if (this.measureTextWidth(paragraph) <= maxWidth + tolerance) {
lines.push(paragraph);
return;
}
let currentLine = '';
// 使用 for...of 按码点遍历,避免 emoji 等代理对被拆开
for (const char of Array.from(paragraph)) {
const testLine = currentLine + char;
const testWidth = this.measureTextWidth(testLine);
// 已到达 DOM 实际行数上限时不再换行,防止测量误差导致多出一行
if (testWidth > maxWidth + tolerance && currentLine !== '' && lines.length < maxLines) {
lines.push(currentLine);
currentLine = char;
} else {
currentLine = testLine;
}
}
lines.push(currentLine);
});
return lines;
}
/**
* 根据 DOM 真实渲染高度推算文本所占的行数上限
* @description 节点的 height 是浏览器/CSS 真实排版的结果,比 canvas 的 measureText 可靠。
* 对于单行文本 height 约等于一个 lineHeight,据此可判定其只占一行,
* 从而不受 canvas 测量误差影响。推算偏大时仅相当于放宽上限,不会产生副作用。
*/
getMaxLines(rect: ElementStyles, lineHeight: number): number {
const height = this.getValueByUnit(rect.height);
if (height <= 0 || lineHeight <= 0) return Infinity;
return Math.max(1, Math.round(height / lineHeight));
}
/**
* 测量文本宽度(转换为逻辑像素)
*/
measureTextWidth(text: string): number {
return this.ctx.measureText(text).width / this.dpr;
}
/**
* 绘制文本装饰线
*/
drawTextDecoration(config: TextRenderConfig, textDecoration: string) {
// 简单解析 decoration,例如 "underline red" 或 "line-through"
const parts = textDecoration.split(' ');
const type = parts[0];
const color = parts[1] || config.textWidth > 0 ? '#000' : '#000'; // 默认黑色
this.ctx.beginPath();
this.ctx.strokeStyle = color;
this.ctx.lineWidth = this.format(1); // 默认1px
let lineY = 0;
if (type === 'underline') {
lineY = config.y + config.fontSize;
} else if (type === 'line-through') {
lineY = config.y + config.fontSize / 2;
} else if (type === 'overline') {
lineY = config.y;
}
if (lineY) {
this.ctx.moveTo(this.format(config.x), this.format(lineY));
this.ctx.lineTo(this.format(config.x + config.textWidth), this.format(lineY));
this.ctx.stroke();
}
}
/**
* 将 Base64 图片转换为本地临时文件路径
* @param base64 Base64 字符串 (支持带 data:image/xxx;base64, 前缀)
* @returns Promise<string> 本地临时文件路径
*/
base64ToTempFilePath(base64: string): Promise<string> {
return new Promise((resolve, reject) => {
if (!base64) {
reject(new Error('Base64 is empty'));
return;
}
// 1. 去除 Base64 前缀 (data:image/png;base64,)
const base64Data = base64.replace(/^data:image\/\w+;base64,/, '');
// 2. 生成唯一的文件名
const fileName = `${Date.now()}_${Math.random().toString(36).slice(2)}.png`;
const filePath = `${uni.env.USER_DATA_PATH}/${fileName}`;
// 3. 将 Base64 写入本地文件
uni.getFileSystemManager().writeFile({
filePath: filePath,
data: base64Data,
encoding: 'base64',
success: () => {
resolve(filePath);
},
fail: (err) => {
console.error('Base64 to file failed:', err);
reject(err);
}
});
});
}
/**
* 绘制图片
*/
async drawImage(rect: ElementStyles) {
if (!rect.src) return;
let src = rect.src;
const { x, y } = this.getPosition(rect);
const width = this.getValueByUnit(rect.width);
const height = this.getValueByUnit(rect.height);
// 获取圆角信息
const radii = this.getBorderRadius(rect);
const hasRadius = radii.length > 0 && radii.some(r => r > 0);
// 针对base64图片做处理 - 部分机型无法识别
if (rect.src.startsWith('data:image/')) {
try {
src = await this.base64ToTempFilePath(rect.src)
} catch {
console.warn('base64图片处理失败')
}
} else {
// 处理网络图片
src = src + '?' + new Date().getTime() + Math.random();
}
return new Promise((resolve) => {
const img = this.canvas.createImage();
img.onload = () => {
// 如果存在圆角,需要进行裁剪处理
if (hasRadius) {
this.ctx.save();
try {
// 创建圆角裁剪路径
const formattedRadii = radii.map(r => this.format(r));
const formattedX = this.format(x);
const formattedY = this.format(y);
const formattedW = this.format(width);
const formattedH = this.format(height);
this.beginRoundRectPath(formattedX, formattedY, formattedW, formattedH, formattedRadii);
this.ctx.clip();
// 清空当前路径,防止跨节点累积导致后续 clip 区域被污染
this.ctx.beginPath();
// 在裁剪区域内绘制图片
this.ctx.drawImage(img as any, formattedX, formattedY, formattedW, formattedH);
} catch (e) {
// 兼容性兜底:roundRect/clip 失败时降级为不带圆角直接绘制,避免整图丢失
console.warn('图片圆角裁剪失败,已降级为直接绘制:', e);
this.ctx.drawImage(img as any, this.format(x), this.format(y), this.format(width), this.format(height));
} finally {
// 无论如何都要恢复 ctx 状态,防止影响后续节点绘制
this.ctx.restore();
}
} else {
// 无圆角,直接绘制
this.ctx.drawImage(img as any, this.format(x), this.format(y), this.format(width), this.format(height));
}
resolve(true);
};
img.onerror = (err: any) => {
console.error('Image load failed:', src, err);
resolve(false);
};
img.src = src;
});
}
/**
* 导出为临时文件
*/
toTempFilePath(options?: any): Promise<string> {
return new Promise((resolve, reject) => {
// UniApp 标准 API
uni.canvasToTempFilePath({
canvas: this.canvas,
...options,
success: (res) => {
resolve(res.tempFilePath);
},
fail: (err) => {
reject(err);
}
}, this.component); // 传递 component 上下文,特别是在小程序中
});
}
}
组件封装layout-snapshot
LayoutSnapshot
<template>
<view ref="snapshotRef" id="snapshot">
<slot></slot>
</view>
</template>
<script lang="ts">
import UniNode2Canvas from '@/utils/plugins/node2canvas';
import { defineComponent, getCurrentInstance, ref } from 'vue';
type TakeSnapshotOptions = {
getNodes: () => Promise<any>;
autoSave?: boolean;
}
export default defineComponent({
name: 'LayoutSnapshot',
setup(props, _ctx) {
const snapshotRef = ref(null);
const instance = getCurrentInstance()?.proxy;
const takeSnapshot = (options: TakeSnapshotOptions) => {
// 创建离屏canvas
const canvas = uni.createOffscreenCanvas({
type: '2d',
compInst: instance,
})
return new Promise((resolve, reject) => {
// 执行获取节点信息
options.getNodes().then(nodes => {
const _nodes = nodes.map((node: any) => {
return {
...node,
...node.dataset
}
})
const snapshot = new UniNode2Canvas({
nodes: _nodes,
canvas,
onDone: function() {
console.log('done');
// 获取图片
this.toTempFilePath().then(res => {
console.log(res);
if (options.autoSave) {
// 如果需要保存图片
uni.saveImageToPhotosAlbum({
filePath: res,
success: function () {
console.log('保存成功');
},
fail: function (err) {
console.log(err);
}
})
}
resolve(res)
})
}
})
snapshot.draw();
})
})
}
_ctx.expose({
takeSnapshot
})
return {
snapshotRef,
takeSnapshot,
}
}
})
</script>
<style lang="scss" scoped>
#snapshot-canvas {
width: 622rpx;
height: 900rpx;
position: absolute;
}
</style>
提供辅助函数useSnapshotHelper.ts
useSnapshotHelper
export const useSnapshotHelper = () => {
const getNodes = (instance: any, nodeClass: string = '.snapshot') => {
return new Promise((resolve, reject) => {
uni.createSelectorQuery().in(instance).selectAll(nodeClass).fields({
dataset: true,
size: true,
rect: true,
computedStyle: ['width', 'height', 'font', 'fontSize', 'fontFamily', 'fontWeight', 'fontStyle', 'textAlign',
'color', 'lineHeight', 'border', 'borderColor', 'borderStyle', 'borderWidth', 'borderRadius', 'verticalAlign', 'boxShadow', 'boxSizing',
'background', 'backgroundColor', 'backgroundRepeat', 'backgroundImage', 'backgroundPosition', 'backgroundSize', 'paddingLeft', 'paddingTop',
'paddingRight', 'paddingBottom', 'textDecoration', 'backgroundClip'
],
properties: ['src']
}, (res) => {
// 这里获取到的子元素一定是数组
resolve(res);
}).exec()
})
}
return {
getNodes
}
};
如何使用?
使用useSnapshotHelper提供的getNodes方法 传入当前组件的instance和className名称。
比如
演示代码
<layout-snapshot ref="snapshotRef" >
<view class="snapshot container-content-head">
<view class="snapshot container-content-head-icon">
<image data-type="image" class="snapshot container-content-head-icon-image" :src="data.brandFileId" />
</view>
<view class="snapshot container-content-head-info">
<text data-type="text" :data-text="data.brandName" class="snapshot container-content-head-info-title">{{ data.brandName }}</text>
<text data-type="text" data-text="我是文字" class="snapshot container-content-head-info-desc">我是文字</text>
</view>
<view class="snapshot container-content-head-image">
<image data-type="image" class="snapshot container-content-head-image-image" :src="data.carSeriesCoverFileId" />
</view>
</view>
</layout-snapshot>
snapshotRef.value.takeSnapshot({
getNodes: getNodes,
autoSave: false
}).then((res: any) => {
console.log(res)
tipRef.value.open({
title: '提示',
content: '海报生成成功,点击确认即可分享到微信好友',
confirm: (_close: () => void) => {
_close();
}
})
}).catch(() => {
tipInstace.hideLoading();
tipInstace.showToast('海报生成失败,请再试一次');
})
文本和图片需要为其添加data-type、data-text属性
浙公网安备 33010602011771号