|
|
@@ -58,36 +58,51 @@ export default {
|
|
|
async generatePoster() {
|
|
|
uni.showLoading({ title: '生成中...' });
|
|
|
try {
|
|
|
+ // 确保Canvas元素已渲染
|
|
|
+ await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
+
|
|
|
const ctx = uni.createCanvasContext('posterCanvas', this);
|
|
|
this.ctx = ctx;
|
|
|
|
|
|
// Setup dimensions
|
|
|
const sysInfo = uni.getSystemInfoSync();
|
|
|
- const dpr = sysInfo.pixelRatio || 2;
|
|
|
- // Multiply scale by dpr to ensure high-res drawing on the canvas backing store
|
|
|
- const scale = (sysInfo.windowWidth / 750) * dpr;
|
|
|
+ const baseScale = (sysInfo.windowWidth || 375) / 750;
|
|
|
+ // 清晰度与稳定性折中:按设备像素比绘制,但最多放大到 2 倍
|
|
|
+ const renderRatio = Math.min(Math.max(sysInfo.pixelRatio || 1, 1), 2);
|
|
|
+ const scale = baseScale * renderRatio;
|
|
|
|
|
|
// User request: Canvas 340px, Padding 10px.
|
|
|
// Mapping to rpx: 340px -> 680rpx, 10px -> 20rpx.
|
|
|
- const width = Math.floor(680 * scale);
|
|
|
- const height = Math.floor(1140 * scale);
|
|
|
+ const width = Math.floor(680 * scale);
|
|
|
+ const height = Math.floor(1140 * scale);
|
|
|
const padding = 20 * scale;
|
|
|
|
|
|
- this.canvasWidth = width;
|
|
|
- this.canvasHeight = height;
|
|
|
+ // 确保Canvas尺寸在合理范围内
|
|
|
+ const maxCanvasSize = 2000; // 保守上限,兼顾清晰度与稳定性
|
|
|
+ if (width > maxCanvasSize || height > maxCanvasSize) {
|
|
|
+ // 缩放Canvas尺寸到合理范围
|
|
|
+ const scaleRatio = Math.min(maxCanvasSize / width, maxCanvasSize / height, 1);
|
|
|
+ const scaledWidth = Math.floor(width * scaleRatio);
|
|
|
+ const scaledHeight = Math.floor(height * scaleRatio);
|
|
|
+ this.canvasWidth = scaledWidth;
|
|
|
+ this.canvasHeight = scaledHeight;
|
|
|
+ } else {
|
|
|
+ this.canvasWidth = width;
|
|
|
+ this.canvasHeight = height;
|
|
|
+ }
|
|
|
|
|
|
// 1. Draw Background (Gradient, Rounded)
|
|
|
// linear-gradient(0deg, #FBFFFF 0%, #BFFCFE 100%) -> Bottom to Top
|
|
|
- const grd = ctx.createLinearGradient(0, height, 0, 0);
|
|
|
+ const grd = ctx.createLinearGradient(0, this.canvasHeight, 0, 0);
|
|
|
grd.addColorStop(0, '#FBFFFF');
|
|
|
grd.addColorStop(1, '#BFFCFE');
|
|
|
|
|
|
- this.roundRect(ctx, 0, 0, width, height, 36 * scale, grd);
|
|
|
+ this.roundRect(ctx, 0, 0, this.canvasWidth, this.canvasHeight, 36 * scale, grd);
|
|
|
|
|
|
// 2. Draw Product Cover
|
|
|
- const coverPath = await this.getImageInfo(this.product.cover || '/static/img/1.png');
|
|
|
+ const coverPath = await this.getImageInfo(this.product.cover || '/static/img/1.png', '/static/img/1.png');
|
|
|
// Image width = Canvas width - 2 * padding
|
|
|
- const imgWidth = width - (2 * padding);
|
|
|
+ const imgWidth = this.canvasWidth - (2 * padding);
|
|
|
const coverHeight = imgWidth; // Square cover
|
|
|
const coverX = padding;
|
|
|
const coverY = padding;
|
|
|
@@ -97,7 +112,18 @@ export default {
|
|
|
// Prototype usually has rounded corners for the cover too.
|
|
|
this.roundRectPath(ctx, coverX, coverY, imgWidth, coverHeight, 24 * scale, true, true, true, true);
|
|
|
ctx.clip();
|
|
|
- ctx.drawImage(coverPath, coverX, coverY, imgWidth, coverHeight);
|
|
|
+ try {
|
|
|
+ if (coverPath) {
|
|
|
+ ctx.drawImage(coverPath, coverX, coverY, imgWidth, coverHeight);
|
|
|
+ } else {
|
|
|
+ throw new Error('coverPath is empty');
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error('Draw cover image error:', e);
|
|
|
+ // 绘制失败时使用默认颜色块
|
|
|
+ ctx.setFillStyle('#f0f0f0');
|
|
|
+ ctx.fillRect(coverX, coverY, imgWidth, coverHeight);
|
|
|
+ }
|
|
|
ctx.restore();
|
|
|
|
|
|
// 3. Draw User Info
|
|
|
@@ -116,12 +142,23 @@ export default {
|
|
|
|
|
|
// Avatar Image
|
|
|
const avatarUrl = this.userInfo.avatar || 'https://img.yzcdn.cn/vant/cat.jpeg';
|
|
|
- const avatarPath = await this.getImageInfo(avatarUrl);
|
|
|
+ const avatarPath = await this.getImageInfo(avatarUrl, '/static/img/1.png');
|
|
|
ctx.save();
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(avatarX + avatarSize/2, avatarY + avatarSize/2, avatarSize/2, 0, 2 * Math.PI);
|
|
|
ctx.clip();
|
|
|
- ctx.drawImage(avatarPath, avatarX, avatarY, avatarSize, avatarSize);
|
|
|
+ try {
|
|
|
+ if (avatarPath) {
|
|
|
+ ctx.drawImage(avatarPath, avatarX, avatarY, avatarSize, avatarSize);
|
|
|
+ } else {
|
|
|
+ throw new Error('avatarPath is empty');
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error('Draw avatar error:', e);
|
|
|
+ // 绘制失败时使用默认颜色块
|
|
|
+ ctx.setFillStyle('#f0f0f0');
|
|
|
+ ctx.fillRect(avatarX, avatarY, avatarSize, avatarSize);
|
|
|
+ }
|
|
|
ctx.restore();
|
|
|
|
|
|
// User Name & Recomm Text
|
|
|
@@ -131,16 +168,14 @@ export default {
|
|
|
|
|
|
ctx.setFillStyle('#1D1D1D');
|
|
|
ctx.setFontSize(28 * scale);
|
|
|
- ctx.font = `bold ${28 * scale}px sans-serif`;
|
|
|
ctx.setTextAlign('left');
|
|
|
- ctx.fillText(this.userInfo.nickname || '微信用户', textLeftX, userInfoY);
|
|
|
+ ctx.fillText(this.userInfo.nickName || '微信用户', textLeftX, userInfoY);
|
|
|
|
|
|
ctx.setFillStyle('#666666');
|
|
|
ctx.setFontSize(26 * scale);
|
|
|
- ctx.font = `normal ${26 * scale}px sans-serif`;
|
|
|
ctx.setTextAlign('right');
|
|
|
// Align to right padding edge
|
|
|
- ctx.fillText('推荐您一本好书', width - padding, userInfoY);
|
|
|
+ ctx.fillText('推荐您一本好书', this.canvasWidth - padding, userInfoY);
|
|
|
ctx.setTextAlign('left'); // Reset
|
|
|
|
|
|
// 4. Product Info
|
|
|
@@ -150,17 +185,15 @@ export default {
|
|
|
ctx.setFillStyle('#1D1D1D');
|
|
|
const titleFontSize = 34 * scale;
|
|
|
ctx.setFontSize(titleFontSize);
|
|
|
- ctx.font = `bold ${titleFontSize}px sans-serif`;
|
|
|
const title = this.product.bookName || '未知书名';
|
|
|
// Width constraint: Canvas width - 2*contentPadding
|
|
|
- const lastTitleY = this.drawText(ctx, title, contentPadding, currentY, width - 2 * contentPadding, titleFontSize * 1.5);
|
|
|
+ const lastTitleY = this.drawText(ctx, title, contentPadding, currentY, this.canvasWidth - 2 * contentPadding, titleFontSize * 1.5);
|
|
|
currentY = lastTitleY + 60 * scale;
|
|
|
|
|
|
// Author
|
|
|
ctx.setFillStyle('#666666');
|
|
|
const authorFontSize = 26 * scale;
|
|
|
ctx.setFontSize(authorFontSize);
|
|
|
- ctx.font = `normal ${authorFontSize}px sans-serif`;
|
|
|
ctx.fillText(`作者: ${this.product.author || '未知'}`, contentPadding, currentY);
|
|
|
currentY += 70 * scale;
|
|
|
|
|
|
@@ -168,42 +201,31 @@ export default {
|
|
|
ctx.setFillStyle('#D81A00');
|
|
|
const priceFontSize = 44 * scale;
|
|
|
ctx.setFontSize(priceFontSize);
|
|
|
- ctx.font = `bold ${priceFontSize}px sans-serif`;
|
|
|
const price = `¥ ${this.product.balanceMoney || '0.00'}`;
|
|
|
const priceWidth = ctx.measureText(price).width;
|
|
|
ctx.fillText(price, contentPadding, currentY);
|
|
|
|
|
|
- // Discount Tag
|
|
|
- // const discount = '5.3折';
|
|
|
- // const tagX = contentPadding + priceWidth + 16 * scale;
|
|
|
- // const tagY = currentY - 26 * scale;
|
|
|
-
|
|
|
- // ctx.setFillStyle('#D81A00');
|
|
|
- // this.roundRect(ctx, tagX, tagY, 70 * scale, 32 * scale, 4 * scale, '#D81A00');
|
|
|
-
|
|
|
- // ctx.setFillStyle('#FFFFFF');
|
|
|
- // ctx.setFontSize(20 * scale);
|
|
|
- // ctx.font = `normal ${20 * scale}px sans-serif`;
|
|
|
- // ctx.fillText(discount, tagX + 8 * scale, tagY + 22 * scale);
|
|
|
-
|
|
|
// Original Price
|
|
|
ctx.setFillStyle('#999999');
|
|
|
ctx.setFontSize(26 * scale);
|
|
|
- ctx.font = `normal ${26 * scale}px sans-serif`;
|
|
|
- const origPrice = `原价: ¥ ${this.product.price || '0.00'}`;
|
|
|
+ const origPrice = `原价: ¥ ${this.product.sellPrice || '0.00'}`;
|
|
|
ctx.fillText(origPrice, contentPadding + priceWidth + 20 * scale, currentY);
|
|
|
|
|
|
// Divider
|
|
|
const footerHeight = 180 * scale;
|
|
|
- const dividerY = height - footerHeight;
|
|
|
+ const dividerY = this.canvasHeight - footerHeight;
|
|
|
|
|
|
ctx.setStrokeStyle('#dddddd');
|
|
|
- ctx.setLineDash([8, 8]);
|
|
|
+ if (typeof ctx.setLineDash === 'function') {
|
|
|
+ ctx.setLineDash([8, 8]);
|
|
|
+ }
|
|
|
ctx.beginPath();
|
|
|
ctx.moveTo(padding, dividerY);
|
|
|
- ctx.lineTo(width - padding, dividerY);
|
|
|
+ ctx.lineTo(this.canvasWidth - padding, dividerY);
|
|
|
ctx.stroke();
|
|
|
- ctx.setLineDash([]);
|
|
|
+ if (typeof ctx.setLineDash === 'function') {
|
|
|
+ ctx.setLineDash([]);
|
|
|
+ }
|
|
|
|
|
|
// 5. Footer (Store & QR)
|
|
|
const footerContentY = dividerY + 50 * scale;
|
|
|
@@ -211,7 +233,6 @@ export default {
|
|
|
// Store Name
|
|
|
ctx.setFillStyle('#1D1D1D');
|
|
|
ctx.setFontSize(32 * scale);
|
|
|
- ctx.font = `bold ${32 * scale}px YouSheBiaoTiHei`;
|
|
|
const storeName = '书嗨循环书店';
|
|
|
const storeNameWidth = ctx.measureText(storeName).width;
|
|
|
ctx.fillText(storeName, padding, footerContentY + 10 * scale);
|
|
|
@@ -222,77 +243,117 @@ export default {
|
|
|
// Align vertically with text (text baseline is at footerContentY + 10*scale)
|
|
|
// Center icon relative to text cap height
|
|
|
const iconY = footerContentY + 10 * scale - (30 * scale) / 2 - iconSize / 2 + 40;
|
|
|
- const iconPath = await this.getImageInfo('/pages-sell/static/goods/icon-shuhai.png');
|
|
|
- ctx.drawImage(iconPath, iconX, iconY, iconSize, iconSize);
|
|
|
+ try {
|
|
|
+ const iconPath = await this.getImageInfo('/pages-sell/static/goods/icon-shuhai.png', '/pages-sell/static/goods/icon-shuhai.png');
|
|
|
+ if (iconPath) {
|
|
|
+ ctx.drawImage(iconPath, iconX, iconY, iconSize, iconSize);
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error('Draw icon error:', e);
|
|
|
+ }
|
|
|
|
|
|
ctx.setFillStyle('#666666');
|
|
|
ctx.setFontSize(22 * scale);
|
|
|
- ctx.font = `normal ${22 * scale}px sans-serif`;
|
|
|
ctx.fillText('不辜负每一个爱书的人', padding, footerContentY + 54 * scale);
|
|
|
|
|
|
// QR Code
|
|
|
const qrSize = 130 * scale;
|
|
|
- const qrX = width - padding - qrSize;
|
|
|
+ const qrX = this.canvasWidth - padding - qrSize;
|
|
|
const qrY = footerContentY - 20 * scale;
|
|
|
|
|
|
// Use gzh.png as the QR code (Figure 2)
|
|
|
- const qrPath = await this.getImageInfo('/pages-sell/static/goods/icon-shuhai.png');
|
|
|
- ctx.drawImage(qrPath, qrX, qrY, qrSize, qrSize);
|
|
|
+ try {
|
|
|
+ const qrPath = await this.getImageInfo('/pages-sell/static/goods/icon-shuhai.png', '/pages-sell/static/goods/icon-shuhai.png');
|
|
|
+ if (qrPath) {
|
|
|
+ ctx.drawImage(qrPath, qrX, qrY, qrSize, qrSize);
|
|
|
+ } else {
|
|
|
+ throw new Error('qrPath is empty');
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error('Draw QR code error:', e);
|
|
|
+ // 绘制失败时使用默认颜色块
|
|
|
+ ctx.setFillStyle('#f0f0f0');
|
|
|
+ ctx.fillRect(qrX, qrY, qrSize, qrSize);
|
|
|
+ }
|
|
|
|
|
|
- ctx.draw(false, () => {
|
|
|
- setTimeout(() => {
|
|
|
- uni.canvasToTempFilePath({
|
|
|
- canvasId: 'posterCanvas',
|
|
|
- destWidth: width,
|
|
|
- destHeight: height,
|
|
|
- fileType: 'png',
|
|
|
- quality: 1,
|
|
|
- success: (res) => {
|
|
|
- this.posterImage = res.tempFilePath;
|
|
|
- uni.hideLoading();
|
|
|
- },
|
|
|
- fail: (err) => {
|
|
|
- console.error(err);
|
|
|
- uni.hideLoading();
|
|
|
- uni.showToast({
|
|
|
- title: '生成失败',
|
|
|
- icon: 'none'
|
|
|
- });
|
|
|
- }
|
|
|
- }, this);
|
|
|
- }, 200);
|
|
|
+ // 使用Promise包装draw方法,确保绘制完成
|
|
|
+ await new Promise((resolve) => {
|
|
|
+ ctx.draw(false, () => {
|
|
|
+ // 延长等待时间,确保绘制完成
|
|
|
+ setTimeout(() => {
|
|
|
+ resolve();
|
|
|
+ }, 500);
|
|
|
+ });
|
|
|
});
|
|
|
|
|
|
+ // 转换Canvas为图片(增加重试,兼容部分机型导出时机问题)
|
|
|
+ const tempFilePath = await this.canvasToTempFilePathWithRetry({
|
|
|
+ canvasId: 'posterCanvas',
|
|
|
+ // 直接导出实际绘制尺寸,避免“导出阶段再放大”导致模糊
|
|
|
+ destWidth: this.canvasWidth,
|
|
|
+ destHeight: this.canvasHeight,
|
|
|
+ fileType: 'png',
|
|
|
+ quality: 0.9
|
|
|
+ }, 3);
|
|
|
+ this.posterImage = tempFilePath;
|
|
|
+ uni.hideLoading();
|
|
|
+
|
|
|
} catch (e) {
|
|
|
- console.error(e);
|
|
|
+ console.error('Generate poster error:', e);
|
|
|
uni.hideLoading();
|
|
|
uni.showToast({ title: '生成出错', icon: 'none' });
|
|
|
}
|
|
|
},
|
|
|
- getImageInfo(url) {
|
|
|
+ canvasToTempFilePathWithRetry(options, retries = 2) {
|
|
|
return new Promise((resolve, reject) => {
|
|
|
- if (!url) {
|
|
|
- // Return a 1x1 transparent png base64 or similar if no url, or reject
|
|
|
- resolve('/static/logo.png'); // Fallback
|
|
|
- return;
|
|
|
- }
|
|
|
- // Handle local paths directly
|
|
|
- if (url.startsWith('/') || url.startsWith('static/')) {
|
|
|
- resolve(url);
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- uni.downloadFile({
|
|
|
- url: url,
|
|
|
- success: (res) => {
|
|
|
- if (res.statusCode === 200) {
|
|
|
- resolve(res.tempFilePath);
|
|
|
- } else {
|
|
|
- reject(res);
|
|
|
+ const attempt = (left) => {
|
|
|
+ uni.canvasToTempFilePath({
|
|
|
+ ...options,
|
|
|
+ success: (res) => resolve(res.tempFilePath),
|
|
|
+ fail: (err) => {
|
|
|
+ if (left > 0) {
|
|
|
+ setTimeout(() => attempt(left - 1), 180);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ reject(err);
|
|
|
}
|
|
|
+ }, this);
|
|
|
+ };
|
|
|
+ attempt(retries);
|
|
|
+ });
|
|
|
+ },
|
|
|
+ getImageInfo(url, fallback = '') {
|
|
|
+ return new Promise((resolve) => {
|
|
|
+ const safeFallback = fallback || '/static/img/1.png';
|
|
|
+ const src = typeof url === 'string' ? url : '';
|
|
|
+ if (!src) {
|
|
|
+ resolve(safeFallback);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ // 本地资源直接返回
|
|
|
+ if (src.startsWith('/') || src.startsWith('static/')) {
|
|
|
+ resolve(src);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ // 优先使用 getImageInfo,兼容更多端图片解码
|
|
|
+ uni.getImageInfo({
|
|
|
+ src,
|
|
|
+ success: (res) => {
|
|
|
+ resolve(res.path || res.tempFilePath || safeFallback);
|
|
|
},
|
|
|
- fail: (err) => {
|
|
|
- reject(err);
|
|
|
+ fail: () => {
|
|
|
+ // 降级下载,失败时不抛异常,避免中断整张海报生成
|
|
|
+ uni.downloadFile({
|
|
|
+ url: src,
|
|
|
+ success: (downloadRes) => {
|
|
|
+ if (downloadRes.statusCode === 200 && downloadRes.tempFilePath) {
|
|
|
+ resolve(downloadRes.tempFilePath);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ resolve(safeFallback);
|
|
|
+ },
|
|
|
+ fail: () => resolve(safeFallback)
|
|
|
+ });
|
|
|
}
|
|
|
});
|
|
|
});
|