/**
* 鸡尾酒海报绘制工具函数
*/
// 画布配置
const CANVAS_CONFIG = {
width: 750,
height: 1350,
padding: 32,
cardWidth: 632,
cardMargin: 32,
emojiSize: 80,
nameFontSize: 36,
descFontSize: 26,
titleFontSize: 28,
contentFontSize: 24,
legalFontSize: 24
};
// 颜色配置
const COLORS = {
background: {
start: '#A6D7FF',
end: '#FFFFFF'
},
card: '#FFFFFF',
name: 'rgba(0, 0, 0, 0.8)',
description: 'rgba(0, 0, 0, 0.8)',
content: 'rgba(0, 0, 0, 0.62)',
decoration: '#EFDFFF',
titleBg: 'rgba(48, 48, 52, 0.99)',
titleText: '#FFFFFF',
legal: 'rgba(0, 0, 0, 0.53)'
};
/**
* 绘制渐变背景
*/
function drawBackground(ctx) {
const gradient = ctx.createLinearGradient(0, 0, 0, CANVAS_CONFIG.height);
gradient.addColorStop(0, COLORS.background.start);
gradient.addColorStop(1, COLORS.background.end);
ctx.setFillStyle(gradient);
ctx.fillRect(0, 0, CANVAS_CONFIG.width, CANVAS_CONFIG.height);
}
/**
* 绘制卡片背景 - 白色圆角矩形背景
*/
function drawCard(ctx, x, y, width, height) {
// 卡片背景 - 绘制圆角矩形
const radius = 60; // 圆角半径 60rpx
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.arcTo(x + width, y, x + width, y + radius, radius);
ctx.lineTo(x + width, y + height - radius);
ctx.arcTo(x + width, y + height, x + width - radius, y + height, radius);
ctx.lineTo(x + radius, y + height);
ctx.arcTo(x, y + height, x, y + height - radius, radius);
ctx.lineTo(x, y + radius);
ctx.arcTo(x, y, x + radius, y, radius);
// 设置卡片背景颜色 - 白色
ctx.setFillStyle(COLORS.card);
ctx.fill();
}
/**
* 绘制章节标题(黑色圆角背景)
*/
function drawSectionTitle(ctx, title, x, y, titlePadding = 20) {
// 确保标题文本不为空
const safeTitle = title || '章节';
// 估算文字宽度,确定背景宽度
ctx.setFontSize(CANVAS_CONFIG.titleFontSize);
// 使用估算方法计算文本宽度,而不是使用measureText
const averageCharWidth = 28; // 假设28号字体下,每个字符平均宽度为28px
const textWidth = safeTitle.length * averageCharWidth;
// 绘制黑色圆角背景
const bgWidth = textWidth + titlePadding * 2;
const bgHeight = 56; // 高度56rpx
ctx.beginPath();
const radius = 28; // 圆角半径,使其呈现为胶囊形状
ctx.moveTo(x + radius, y - 28);
ctx.lineTo(x + bgWidth - radius, y - 28);
ctx.arcTo(x + bgWidth, y - 28, x + bgWidth, y - 28 + radius, radius);
ctx.arcTo(x + bgWidth, y + 28, x + bgWidth - radius, y + 28, radius);
ctx.lineTo(x + radius, y + 28);
ctx.arcTo(x, y + 28, x, y + 28 - radius, radius);
ctx.arcTo(x, y - 28, x + radius, y - 28, radius);
ctx.setFillStyle(COLORS.titleBg);
ctx.fill();
// 绘制白色文字
ctx.setFillStyle(COLORS.titleText);
ctx.setTextAlign('center');
ctx.fillText(safeTitle, x + bgWidth / 2, y + 10);
ctx.setTextAlign('left');
}
/**
* 文字换行处理
*/
function wrapText(text, maxCharsPerLine) {
if (!text || typeof text !== 'string') return [''];
const lines = [];
let currentLine = '';
// 考虑中文和英文字符宽度差异
for (let i = 0; i < text.length; i++) {
const char = text[i];
// 如果是换行符,立即换行
if (char === '\n') {
lines.push(currentLine);
currentLine = '';
continue;
}
// 计算当前字符占用的宽度权重
// 中文字符、全角符号等占用较多宽度,英文字母、数字等占用较少宽度
const charWidth = /[\u4e00-\u9fa5,。!?;:""''()【】「」『』、《》]/.test(char) ? 2 : 1;
// 如果加上当前字符会超出最大宽度,则换行
if (calculateLineWidth(currentLine + char) > maxCharsPerLine) {
lines.push(currentLine);
currentLine = char;
} else {
currentLine += char;
}
}
// 处理最后一行
if (currentLine.length > 0) {
lines.push(currentLine);
}
return lines.length > 0 ? lines : [''];
}
/**
* 计算一行文本的实际宽度(考虑中英文混合)
*/
function calculateLineWidth(line) {
let width = 0;
for (let i = 0; i < line.length; i++) {
const char = line[i];
// 中文字符占用约2倍于英文字符的宽度
width += /[\u4e00-\u9fa5,。!?;:""''()【】「」『』、《》]/.test(char) ? 2 : 1;
}
return width;
}
/**
* 绘制旋转的Emoji
*/
function drawRotatedEmoji(ctx, emoji, x, y, rotation = 0) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(rotation);
ctx.setFontSize(CANVAS_CONFIG.emojiSize);
ctx.setTextAlign('center');
ctx.fillText(emoji, 0, 0);
ctx.restore();
}
/**
* 绘制第一个卡片(鸡尾酒信息)
*/
function drawFirstCard(ctx, result, cardY) {
const contentX = (CANVAS_CONFIG.width - CANVAS_CONFIG.cardWidth) / 2 + CANVAS_CONFIG.padding;
let contentY = cardY + CANVAS_CONFIG.padding;
const startContentY = contentY;
// 跳过emoji占据的高度(100px字体 + 上下边距)
const nameY = contentY + 40;
// 鸡尾酒名称占据的高度
const cocktailName = result.name || '经典鸡尾酒';
// 计算紫色装饰条的位置
const decorationY = nameY + 4;
// 标题与内容之间的间距为24rpx (12px),从紫色装饰条底部算起
contentY = decorationY + 16 + 24;
// 描述文字占据的高度
const availableWidth = CANVAS_CONFIG.cardWidth - CANVAS_CONFIG.padding * 2;
const maxCharsPerLine = Math.floor(availableWidth / 13); // 考虑到中英文混合情况
const description = result.description || '一款经典的鸡尾酒,口感平衡,回味悠长。';
const descLines = wrapText(description, maxCharsPerLine);
contentY += descLines.length * 40;
// 计算第一个卡片的实际高度(根据内容自动调整)
const firstCardHeight = contentY - startContentY + CANVAS_CONFIG.padding * 2;
// 绘制第一个卡片背景
drawCard(ctx, (CANVAS_CONFIG.width - CANVAS_CONFIG.cardWidth) / 2, cardY, CANVAS_CONFIG.cardWidth, firstCardHeight);
// 绘制第一个卡片内容
contentY = cardY + CANVAS_CONFIG.padding;
// 心情emoji - 右上角,确保3/4都在卡片内
const cardRight = (CANVAS_CONFIG.width + CANVAS_CONFIG.cardWidth) / 2; // 卡片右边缘
const cardTop = cardY; // 卡片上边缘
const emojiX = cardRight - (CANVAS_CONFIG.emojiSize * 1) / 2; // 距离右侧四分之一宽度
const emojiY = cardTop + (CANVAS_CONFIG.emojiSize * 3) / 4; // 距离上侧四分之一宽度
drawRotatedEmoji(ctx, result.moodEmoji || '🍸', emojiX, emojiY, (15 * Math.PI) / 180);
// 鸡尾酒名称
ctx.setTextAlign('left');
ctx.setFillStyle(COLORS.name);
ctx.setFontSize(CANVAS_CONFIG.nameFontSize);
// 绘制名称
ctx.fillText(cocktailName, contentX, nameY);
// 通过重复绘制文本实现加粗效果
ctx.fillText(cocktailName, contentX + 0.5, nameY);
ctx.fillText(cocktailName, contentX, nameY + 0.5);
ctx.fillText(cocktailName, contentX + 0.5, nameY + 0.5);
// 计算实际文本宽度
const actualNameWidth = cocktailName.length * 30; // 按每个字符30px计算
// 紫色底部装饰,与文字同宽
ctx.setFillStyle(COLORS.decoration);
ctx.fillRect(contentX, decorationY, actualNameWidth, 16);
// 描述文本
ctx.setTextAlign('left');
ctx.setFillStyle(COLORS.description);
ctx.setFontSize(CANVAS_CONFIG.descFontSize);
// 描述文本位置:从紫色装饰条底部算起,间距为24rpx (12px)
const descY = decorationY + 16 + 48;
descLines.forEach((line, index) => {
ctx.fillText(line, contentX, descY + index * 40);
});
return firstCardHeight;
}
/**
* 绘制第二个卡片(原材料和制作步骤)
*/
function drawSecondCard(ctx, result, cardY) {
const contentX = (CANVAS_CONFIG.width - CANVAS_CONFIG.cardWidth) / 2 + CANVAS_CONFIG.padding;
let contentY = cardY + CANVAS_CONFIG.padding;
const startContentY = contentY;
// 计算maxCharsPerLine
const availableWidth = CANVAS_CONFIG.cardWidth - CANVAS_CONFIG.padding * 2;
const maxCharsPerLine = Math.floor(availableWidth / 13);
// 计算内容高度 - 与原代码保持一致
contentY += 60; // 跳过emoji占据的高度
contentY += 40; // 标题高度
contentY += 10; // 原材料内容高度 - 标题与内容间距为10px
const ingredients = result.ingredients || [];
if (ingredients.length > 0) {
contentY += ingredients.length * 40; // 每行40px高度
} else {
contentY += 40; // 一行"暂无原材料信息"
}
contentY += 24; // 两个块之间的间距是24px
contentY += 40; // 制作步骤标题高度
contentY += 10; // 制作步骤内容高度 - 标题与内容间距为10px
const steps = result.steps || [];
if (steps.length > 0) {
// 计算每个步骤可能的多行文本
let totalStepLines = 0;
steps.forEach(step => {
if (step) {
const stepLines = wrapText(step, maxCharsPerLine);
totalStepLines += stepLines.length;
totalStepLines += 0.25; // 每个步骤之间的额外间距
}
});
contentY += totalStepLines * 40;
} else {
contentY += 40; // 一行"暂无制作步骤信息"
}
const secondCardHeight = contentY - startContentY + CANVAS_CONFIG.padding * 2;
// 绘制第二个卡片背景
drawCard(ctx, (CANVAS_CONFIG.width - CANVAS_CONFIG.cardWidth) / 2, cardY, CANVAS_CONFIG.cardWidth, secondCardHeight);
// 绘制第二个卡片内容
contentY = cardY + CANVAS_CONFIG.padding; // 重置为卡片顶部 + padding
// 酒量emoji - 右上角,与第一个卡片对齐
const secondCardRight = (CANVAS_CONFIG.width + CANVAS_CONFIG.cardWidth) / 2; // 卡片右边缘
const secondCardTop = cardY; // 卡片上边缘
const secondCardEmojiX = secondCardRight - (CANVAS_CONFIG.emojiSize * 1) / 2; // 与第一个卡片对齐
const secondCardEmojiY = secondCardTop + (CANVAS_CONFIG.emojiSize * 3) / 4; // 与第一个卡片对齐
drawRotatedEmoji(ctx, result.drinkLevelEmoji || '🍹', secondCardEmojiX, secondCardEmojiY, (-15 * Math.PI) / 180);
// 第一个title内边距16px
contentY += 32;
// 原材料标题 - 内部左右padding是10px
drawSectionTitle(ctx, '📍原材料', contentX, contentY, 10);
contentY += 40; // 标题高度
contentY += 20; // 标题与内容间距为10px
// 原材料列表
ctx.setFillStyle(COLORS.content);
ctx.setFontSize(CANVAS_CONFIG.contentFontSize);
ctx.setTextAlign('left');
if (ingredients.length > 0) {
ingredients.forEach((ingredient, index) => {
// 处理可能的长文本换行
const ingredientLines = wrapText(ingredient, maxCharsPerLine - 2); // 减少序号占用的宽度影响
// 绘制第一行(带序号)
ctx.fillText(`${index + 1}: ${ingredientLines[0]}`, contentX, contentY);
contentY += 40;
// 如果有多行,继续绘制剩余行
for (let i = 1; i < ingredientLines.length; i++) {
ctx.fillText(` ${ingredientLines[i]}`, contentX, contentY);
contentY += 40;
}
});
} else {
ctx.fillText('暂无原材料信息', contentX, contentY);
contentY += 40;
}
// 两个块之间的间距是24px
contentY += 48;
// 制作步骤标题 - 内部左右padding是10px
drawSectionTitle(ctx, '🔢 制作步骤', contentX, contentY, 10);
contentY += 40; // 标题高度
contentY += 20; // 标题与内容间距为10px
// 制作步骤列表
ctx.setFillStyle(COLORS.content);
ctx.setFontSize(CANVAS_CONFIG.contentFontSize);
ctx.setTextAlign('left');
if (steps.length > 0) {
steps.forEach((step, index) => {
if (!step) return;
// 使用与第一个卡片相同的文本换行逻辑
const stepLines = wrapText(step, maxCharsPerLine - 2); // 减少序号占用的宽度影响
// 绘制第一行(带序号)
ctx.fillText(`${index + 1}: ${stepLines[0]}`, contentX, contentY);
contentY += 40;
// 如果有多行,继续绘制剩余行
for (let i = 1; i < stepLines.length; i++) {
ctx.fillText(` ${stepLines[i]}`, contentX, contentY);
contentY += 40;
}
// 步骤之间添加少量间距
if (index < steps.length - 1) {
contentY += 10;
}
});
} else {
ctx.fillText('暂无制作步骤信息', contentX, contentY);
contentY += 40;
}
return secondCardHeight;
}
/**
* 绘制法律声明
*/
function drawLegalNotice(ctx) {
ctx.setFillStyle(COLORS.legal);
ctx.setFontSize(CANVAS_CONFIG.legalFontSize);
ctx.setTextAlign('center');
ctx.fillText('*内容由AI生成,仅供参考', CANVAS_CONFIG.width / 2, CANVAS_CONFIG.height - 200);
}
/**
* 生成鸡尾酒海报
*/
function generateCocktailPoster(ctx, pageData) {
const { result } = pageData;
if (!result) {
throw new Error('结果数据不完整');
}
// 绘制背景
drawBackground(ctx);
// 绘制第一个卡片
const firstCardY = 48;
const firstCardHeight = drawFirstCard(ctx, result, firstCardY);
// 绘制第二个卡片
const secondCardY = firstCardY + firstCardHeight + CANVAS_CONFIG.cardMargin;
drawSecondCard(ctx, result, secondCardY);
// 绘制法律声明
drawLegalNotice(ctx);
console.log('海报内容绘制完成');
}
module.exports = {
generateCocktailPoster
};
## 技术要求
### 用户体验
- 全流程通过一个loading来控制
- 提供清晰的进度提示(生成海报 → 上传图片 → 发布笔记)
- 成功后显示发布结果和预览
### 错误处理机制
- **海报生成失败**:提示用户重新生成,记录错误日志
- **图片上传失败**:提示网络问题,支持重试机制
- **笔记发布失败**:提示发布失败原因,允许重新发布
- 每个步骤都要有独立的错误捕获和用户友好的错误提示
### 代码结构要求
- 使用 async/await 处理异步操作
- 合理的函数拆分,每个阶段独立封装
- 添加适当的日志输出便于调试
- 保持代码的可读性和可维护性
### 实现流程
1. **初始化阶段**:准备canvas上下文和数据
2. **海报生成**:调用 `generateCocktailPoster` 绘制海报
3. **导出临时文件**:使用 `canvasToTempFilePath` 获取临时路径
4. **上传文件**:调用 `uploadXhsFile` 上传到服务器
5. **发布笔记**:调用 `postNote` 发布到小红书
## 实现约束
- 所有功能在同一个页面组件中完成
- 必须复用提供的完整海报绘制逻辑
- 确保在小红书小程序环境下的兼容性
- 保持良好的用户体验和错误处理,报错用toast来展示