小程序开放平台

文档中心
概览
代码编辑
开发辅助
AI助手(Beta)
快速开始
最佳实践
从零开发调酒小组件
下载

从0开发调酒小组件

开发
>
开发工具
>
AI助手(Beta)
>
最佳实践
>
从零开发调酒小组件
>
更新时间:2025-08-15 15:31:47

需求详情

需求设计稿

描述描述

需求描述

调酒偏好选择

  • 心情:「开心」「无感」「满足」「难过」「点击输入」点击输入,拉起键盘,最多 50 个字
  • 口味(单选):酸、甜、苦、辣
  • 酒精浓度(单选):低、高
  • 点击「🎲随机一下」,随机选择上面的心情、口味、酒精浓度
  • 偏好填写完成后,「生成酒单」由灰置为可点击态,点击后进入 loading。

酒单页

  • 酒名:emoji + name,每款鸡尾酒名称
  • 说明:描述这款酒的意境构成
  • 原材料:调制该酒所需的各类原材料和数量
  • 调酒步骤:具体操作流程,将口味、酒精浓度、心情传入 LLM,并格式化返回输出结果
  • 点击「重新生成」,跳转到首页,并重置所有结果
  • 点击「发布到小红书」,生成截图并拉起笔记发布器,自动挂载
    • 笔记标题:一杯 - AI 调酒
    • 笔记内容:推荐这个 AI 调酒小组件,来一杯吧~,
    • 笔记标签:#小组件 #AI 调酒
    • 图片:由
    • 组件:AI 调酒小组件

生成效果

效果展示

生成过程

代码提示词

第一步:实现初始UI、选中逻辑

图片输入

描述描述

提示词输入

UI图如上,你的任务是精心实现一个小程序的 UI 效果。直接在当前这个pages里进行代码覆盖,请认真、细致地阅读以下信息,并严格按照指示完成任务。

小程序的标题为:
<title>
AI 调酒
</title>

下面是对小程序 UI 的详尽描述:
### 背景色
背景是个渐变色,请使用"navigationStyle": "custom"来实现自定义header,头部和底部高度不需要太高

### 主标题
主标题固定显示为“AI 调酒”,它在页面上的位置应突出、显眼,能让用户快速识别小程序的核心功能。

### 选择卡片
#### 心情卡片
 - 卡片内包含「开心」「无感」「满足」「难过」「点击输入」这些选项。每个选项的样式应保持统一且清晰可辨,方便用户点击。
 - 当用户点击「点击输入」时,会自动拉起键盘。输入框应具备字数限制功能,最多允许用户输入 50 个字,当达到字数上限时,应给用户相应的提示。

#### 口味卡片(单选)
 - 此卡片提供酸、甜、苦、辣四种口味供用户选择。为了体现单选的特性,当用户选择某一种口味时,该选项应呈现出与其他未选选项不同的样式,以明确告知用户当前的选择状态。

#### 风格卡片
 - 卡片包含「不醉不归」「小酌一杯」两个选项。同样,选项的样式要简洁明了,便于用户操作。

### 页面底部按钮
#### 随机一下
 - 点击该按钮后,系统会在三个卡片中各自随机选择一个选项。在随机选择的过程中,要有一定的动画效果或者提示信息,让用户能直观地看到选择的结果。

#### 今日特调
 - 点击该按钮后,会执行调酒生成逻辑。在点击时,系统会对各个卡片是否已选择进行校验。
 - 若存在未选择的卡片,会弹出 toast 提示“请先选择”,该提示应在页面上清晰显示一段时间后自动消失。目前,后续的调酒生成逻辑先暂时空着。

### 第二张图是卡片的的选中态样式,请你仔细还原样式细节,选择的具体选项请你写成可配置化的代码

第二步:实现心情输入自定义,选择校验逻辑

图片输入

描述

提示词输入

### 请你完成心情card中“我的自定义心情”item的交互操作,整体设计稿如图所示

#### 1、点击会进入如上图这样的输入框半屏中,背景是模糊的,聚焦后键盘把底部输入框高度顶起
#### 2、底部固定一个输入框和完成按钮,placeholder为”输入此刻心情“
#### 3、每次唤起默认回填上一次的输入值,点击完成则完成输入,自定义心情更新到UI上,并选中这一项item

第三步:实现agent调用,页面loading逻辑

图片输入

描述

提示词输入

在现有代码的基础上接着进行功能实现,所有功能都在同一个页面上实现

### 1. 点击"今日特调"按钮的处理逻辑

#### 1.1 数据校验
- 用户点击"今日特调"按钮时,首先校验以下三个选项是否都已完成选择:
  - 用户心情(mood)
  - 酒量偏好(drinkLevel)  
  - 口味偏好(taste)
- 如果有任一项未选择,需要提示用户完成所有选择

#### 1.2 Prompt生成
校验通过后,基于用户选中的项生成以下格式的prompt提示词:
用户心情:${mood}
酒量偏好:${drinkLevel}
口味偏好:${taste}


### 2. AI智能体集成

#### 2.1 智能体配置
const agentConfig = {
  agentId: '962449bed2d043a68997b49e42de5e38',
  env: 'production',
};

#### 2.2 初始化流程
- 页面初始化时,使用上述配置调用 `xhs.cloud.AI.createAgent` 初始化智能体
- 确保智能体创建成功后再允许用户进行后续操作
- 其中accessToken的获取逻辑去掉不需要了

#### 2.3 消息发送与处理
- 用户点击"今日特调"且校验通过后,调用智能体发送生成的prompt消息
- 立即跳转到loading中间页面,页面的UI效果如图

### 3. Loading页面实现

#### 3.1 进度条展示
- 展示进度,按照 **1%/秒** 的速度模拟进度,实现小数点后两位的跳动
- 进度从0%开始,最多到99%(等待实际响应完成时到100%)

#### 3.2 页面状态
- 显示"给我一点时间,进度 xx%"等提示文案
- 禁用返回按钮或其他可能中断流程的操作

### 4. 消息接收处理

#### 4.1 成功处理
- 在 `onMessage` 事件中接收智能体返回的调酒推荐消息
- 收到完整响应后,进度条立即跳转到100%
- 跳转到结果展示页面,显示推荐的调酒内容

#### 4.2 错误处理机制
- **网络错误**:智能体调用失败时的处理
- **超时错误**:长时间无响应的处理  
- **响应异常**:智能体返回异常内容的处理
- **其他未知错误**:兜底错误处理

#### 4.3 错误处理流程
当任何错误发生时:
1. 立即停止loading进度条
2. 弹出toast提示:"生成失败,请重试"
3. 自动返回到第一个选择界面
4. **保留用户已选择的数据**(mood、drinkLevel、taste),无需重新选择
5. 用户可以直接再次点击"今日特调"重试

第四步:实现结果展示,法务协议唤起逻辑

图片输入

描述

提示词输入

在现有代码功能基础上继续开发,所有功能需在同一页面中实现,样式请你严格按照图片所示的设计稿进行还原

## 核心功能需求

### 1. SSE流式数据处理

#### 数据接收机制
- **触发回调**: `onMessage` 处理SSE流式返回
- **结束标识**: 收到消息体 `[DONE]` 表示流式返回完成
- **数据格式**: 每次消息体为JSON格式字符串

#### 原始数据结构
{
  "id": "644132641983741954",
  "log_id": "644132641983741953",
  "object": null,
  "message": null,
  "code": 0,
  "created": 1755083593483,
  "model": "qwen-plus",
  "choices": [
    {
      "index": 0,
      "message": {
        "id": "644132641983741954",
        "role": "assistant",
        "type": "answer",
        "reasoning_content": "",
        "content": "xxx",
        "knowledge_retrieval": [],
        "db_operations": [],
        "search_results": []
      },
      "finish_reason": "STOP"
    }
  ],
  "usage": {
    "prompt_tokens": 452,
    "completion_tokens": 200,
    "knowledge_tokens": 0,
    "reasoning_tokens": 0,
    "total_tokens": 652
  }
}

#### 数据处理逻辑
- **提取路径**: `choices[0].message.content`
- **处理方式**: 将所有收到的content字段累加组合
- **输出格式**: 组合后的content应为JSON格式字符串

### 2. 目标数据结构

组合完成后的JSON格式:
{
  "name": "酒名",
  "description": "鸡尾酒的描述和感受(50-100字)",
  "ingredients": [
    "配料1",
    "配料2", 
    "配料3",
    "配料4"
  ],
  "steps": [
    "制作步骤1",
    "制作步骤2",
    "制作步骤3", 
    "制作步骤4"
  ]
}

## UI设计要求

### 页面布局
- **背景**: 保持现有设计不变

### 内容区域
#### 上部卡片
- 展示内容: `title`为标题 、 `description`为下面的描述

#### 下部卡片  
- 展示内容: `ingredients`为原材料 、 `steps`为制作步骤

### 底部操作区
- **重新生成按钮**: 点击重新调用agent进行生成
- **发布到小红书按钮**: 逻辑预留,暂不实现

第五步:实现调酒海报生成、发笔记逻辑

提示词输入

# 鸡尾酒海报生成与发布功能开发

## 功能概述
基于现有逻辑,在一个页面中完成鸡尾酒海报的生成、上传和发布到小红书的完整流程。

## 核心需求

### 1. 海报生成阶段
- 使用 Canvas 绘制鸡尾酒海报,代码参考下方【海报绘制完整实现代码】
- 调用 `canvasToTempFilePath` 导出为临时文件路径
- 海报绘制逻辑严格按照下面的代码来实现

### 2. 文件上传阶段
- 使用 `uploadXhsFile` 上传海报图片,拿到的previewUrl是网络图片地址
- 上传配置参: 
// 上传的配置复用
const bizCode = 17;
const bizName = 'sns';
const scene = 'chatbox_img';

### 3. 发笔记链路,通过postNote发笔记,参数配置如下:

title: `一杯${name} - AI 调酒`,
content: `推荐这个 AI 调酒小组件,来一杯${name}吧~,${description}`,
tags: '小组件,AI调酒',
mediaInfo: JSON.stringify({
image_resources: [{ url }],
}),

海报绘制完整实现代码

/**
 * 鸡尾酒海报绘制工具函数
 */
 
// 画布配置
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来展示