GLM-Image前端集成方案:Vue3实现实时图像预览

最近在做一个创意工具项目,需要让用户输入文字描述就能快速生成图片,并且能实时看到生成效果。试了几个方案后,发现GLM-Image在文字理解和图像质量上表现不错,特别是对中文描述的理解很到位。

不过官方提供的体验界面比较简单,如果要在自己的产品里集成,就需要自己动手做前端界面。今天就来分享一下,怎么用Vue3把GLM-Image的图像生成能力集成到你的项目里,实现一个用户友好、能实时预览的界面。

1. 为什么选择Vue3 + GLM-Image组合

先说说为什么这么搭配。Vue3现在的生态很成熟,响应式系统用起来特别顺手,尤其是做这种需要实时更新界面的应用。GLM-Image这边,它最大的优势是能准确理解文字指令,特别是中文描述,不会出现那种“画得漂亮但内容不对”的情况。

我试过几个场景,比如让生成“一个穿着汉服的程序员在写代码”,GLM-Image能准确理解“汉服”这个元素,生成的人物穿着确实是汉服风格,而不是随便套个古装。这种对语义的精准把握,对于实际应用来说很重要。

从技术角度看,Vue3的Composition API让代码组织更清晰,特别是处理异步请求和状态管理时,比Options API要灵活不少。GLM-Image的API接口也比较规范,返回的是标准的图片URL,前端处理起来不复杂。

2. 项目环境搭建与基础配置

开始动手前,先确保你的开发环境准备好了。我用的是Vue3 + TypeScript + Vite的组合,这个配置现在算是主流选择,开发体验和构建速度都不错。

2.1 创建Vue3项目

如果你还没有项目,可以用下面这个命令快速创建一个:

npm create vue@latest my-glm-image-app

创建过程中,记得选择TypeScript、Pinia(状态管理)、以及必要的工具链。创建完成后,进入项目目录安装依赖:

cd my-glm-image-app
npm install

2.2 安装必要的依赖

除了基础依赖,我们还需要安装几个专门用到的包:

npm install axios
npm install @vueuse/core
npm install element-plus

axios用来处理API请求,@vueuse/core提供了一些好用的组合式函数,element-plus是UI组件库,能让界面开发更快一些。如果你喜欢其他UI库,比如Ant Design Vue或者Naive UI,也可以按需选择。

2.3 配置GLM-Image API访问

GLM-Image需要通过API Key来访问,这个Key可以在智谱AI的开放平台申请。拿到Key后,不要在代码里硬编码,最好放在环境变量里。

在项目根目录创建.env.local文件:

VITE_GLM_API_KEY=你的API_Key
VITE_GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4

然后在代码里通过import.meta.env来读取这些配置。这样做的安全性更好,也方便不同环境切换配置。

3. 核心组件设计与实现

界面设计上,我打算做一个比较简洁但功能完整的图像生成工具。主要包含几个部分:文字输入区、参数设置区、生成按钮、以及图片预览区。

3.1 创建图像生成组件

先创建一个ImageGenerator.vue组件,这是整个功能的核心:

<template>
  <div class="image-generator">
    <div class="generator-container">
      <!-- 左侧输入区域 -->
      <div class="input-section">
        <h3>描述你想要生成的图像</h3>
        
        <el-input
          v-model="prompt"
          type="textarea"
          :rows="4"
          placeholder="例如:一只可爱的橘猫在阳光下睡觉,背景是温馨的客厅..."
          :maxlength="500"
          show-word-limit
        />
        
        <div class="params-section">
          <h4>生成参数</h4>
          
          <div class="param-row">
            <span class="param-label">图片尺寸:</span>
            <el-select v-model="selectedSize" size="small">
              <el-option
                v-for="size in sizeOptions"
                :key="size.value"
                :label="size.label"
                :value="size.value"
              />
            </el-select>
          </div>
          
          <div class="param-row">
            <span class="label">生成数量:</span>
            <el-slider
              v-model="imageCount"
              :min="1"
              :max="4"
              :step="1"
              show-stops
              style="width: 200px"
            />
            <span class="value">{{ imageCount }}张</span>
          </div>
          
          <div class="param-row">
            <span class="label">风格强度:</span>
            <el-slider
              v-model="styleStrength"
              :min="0.1"
              :max="1.0"
              :step="0.1"
              style="width: 200px"
            />
            <span class="value">{{ styleStrength.toFixed(1) }}</span>
          </div>
        </div>
        
        <div class="action-buttons">
          <el-button
            type="primary"
            :loading="isGenerating"
            :disabled="!prompt.trim()"
            @click="generateImages"
          >
            {{ isGenerating ? '生成中...' : '开始生成' }}
          </el-button>
          
          <el-button @click="clearAll">
            清空
          </el-button>
        </div>
      </div>
      
      <!-- 右侧预览区域 -->
      <div class="preview-section">
        <div v-if="generatedImages.length === 0" class="empty-preview">
          <div class="empty-icon">
            <svg width="64" height="64" viewBox="0 0 64 64">
              <!-- 简化的图片图标 -->
              <rect x="8" y="8" width="48" height="40" rx="4" fill="#f0f0f0"/>
              <circle cx="24" cy="24" r="6" fill="#d9d9d9"/>
              <polyline points="40,36 48,44 56,36" fill="none" stroke="#d9d9d9" stroke-width="2"/>
            </svg>
          </div>
          <p>输入描述并点击生成,图片将在这里显示</p>
        </div>
        
        <div v-else class="images-grid">
          <div
            v-for="(image, index) in generatedImages"
            :key="index"
            class="image-item"
          >
            <div class="image-wrapper">
              <img
                :src="image.url"
                :alt="`生成的图片 ${index + 1}`"
                @load="onImageLoad(index)"
              />
              <div v-if="image.loading" class="image-loading">
                <div class="loading-spinner"></div>
              </div>
            </div>
            <div class="image-info">
              <span>图片 {{ index + 1 }}</span>
              <el-button
                size="small"
                @click="downloadImage(image.url, index)"
              >
                下载
              </el-button>
            </div>
          </div>
        </div>
        
        <!-- 生成状态提示 -->
        <div v-if="isGenerating" class="generation-status">
          <div class="status-content">
            <div class="loading-spinner small"></div>
            <p>正在生成图像,请稍候...</p>
            <p class="status-tip">生成时间通常需要10-30秒</p>
          </div>
        </div>
      </div>
    </div>
    
    <!-- 历史记录 -->
    <div v-if="history.length > 0" class="history-section">
      <h3>生成历史</h3>
      <div class="history-list">
        <div
          v-for="(item, index) in history"
          :key="index"
          class="history-item"
          @click="loadFromHistory(item)"
        >
          <div class="history-prompt">{{ item.prompt }}</div>
          <div class="history-time">{{ formatTime(item.timestamp) }}</div>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import axios from 'axios'

// 类型定义
interface GeneratedImage {
  url: string
  loading: boolean
}

interface HistoryItem {
  prompt: string
  images: GeneratedImage[]
  timestamp: number
}

// 响应式数据
const prompt = ref('')
const selectedSize = ref('1024x1024')
const imageCount = ref(2)
const styleStrength = ref(0.7)
const isGenerating = ref(false)
const generatedImages = ref<GeneratedImage[]>([])
const history = ref<HistoryItem[]>([])

// 可选的图片尺寸
const sizeOptions = [
  { label: '1024x1024', value: '1024x1024' },
  { label: '768x768', value: '768x768' },
  { label: '512x512', value: '512x512' }
]

// 从localStorage加载历史记录
onMounted(() => {
  const savedHistory = localStorage.getItem('glmImageHistory')
  if (savedHistory) {
    try {
      history.value = JSON.parse(savedHistory)
    } catch (e) {
      console.error('加载历史记录失败:', e)
    }
  }
})

// 生成图片的主函数
const generateImages = async () => {
  if (!prompt.value.trim()) {
    ElMessage.warning('请输入图片描述')
    return
  }
  
  isGenerating.value = true
  generatedImages.value = []
  
  try {
    // 调用GLM-Image API
    const response = await axios.post(
      `${import.meta.env.VITE_GLM_BASE_URL}/images/generations`,
      {
        model: 'glm-image',
        prompt: prompt.value,
        n: imageCount.value,
        size: selectedSize.value,
        style: styleStrength.value
      },
      {
        headers: {
          'Authorization': `Bearer ${import.meta.env.VITE_GLM_API_KEY}`,
          'Content-Type': 'application/json'
        }
      }
    )
    
    // 处理返回的图片数据
    if (response.data.data && response.data.data.length > 0) {
      generatedImages.value = response.data.data.map((item: any) => ({
        url: item.url,
        loading: true
      }))
      
      // 保存到历史记录
      const historyItem: HistoryItem = {
        prompt: prompt.value,
        images: [...generatedImages.value],
        timestamp: Date.now()
      }
      
      history.value.unshift(historyItem)
      if (history.value.length > 10) {
        history.value = history.value.slice(0, 10)
      }
      
      // 保存到localStorage
      localStorage.setItem('glmImageHistory', JSON.stringify(history.value))
      
      ElMessage.success(`成功生成${generatedImages.value.length}张图片`)
    } else {
      ElMessage.warning('未生成图片,请重试')
    }
  } catch (error: any) {
    console.error('生成图片失败:', error)
    ElMessage.error(`生成失败: ${error.response?.data?.message || error.message}`)
  } finally {
    isGenerating.value = false
  }
}

// 图片加载完成时的处理
const onImageLoad = (index: number) => {
  generatedImages.value[index].loading = false
}

// 下载图片
const downloadImage = (url: string, index: number) => {
  const link = document.createElement('a')
  link.href = url
  link.download = `generated-image-${index + 1}-${Date.now()}.png`
  document.body.appendChild(link)
  link.click()
  document.body.removeChild(link)
  ElMessage.success('开始下载图片')
}

// 清空所有内容
const clearAll = () => {
  prompt.value = ''
  generatedImages.value = []
}

// 从历史记录加载
const loadFromHistory = (item: HistoryItem) => {
  prompt.value = item.prompt
  generatedImages.value = [...item.images]
}

// 格式化时间显示
const formatTime = (timestamp: number) => {
  const date = new Date(timestamp)
  return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
}
</script>

<style scoped>
.image-generator {
  max-width: 1200px;
  margin: 0 auto;
  padding: 24px;
}

.generator-container {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 32px;
  margin-bottom: 32px;
}

@media (max-width: 768px) {
  .generator-container {
    grid-template-columns: 1fr;
  }
}

.input-section h3 {
  margin-bottom: 16px;
  color: #333;
}

.params-section {
  margin: 24px 0;
  padding: 20px;
  background: #f8f9fa;
  border-radius: 8px;
}

.params-section h4 {
  margin-bottom: 16px;
  color: #555;
}

.param-row {
  display: flex;
  align-items: center;
  margin-bottom: 16px;
}

.param-row:last-child {
  margin-bottom: 0;
}

.param-label {
  min-width: 80px;
  color: #666;
}

.param-row .label {
  min-width: 80px;
  color: #666;
}

.param-row .value {
  margin-left: 12px;
  color: #333;
  font-weight: 500;
}

.action-buttons {
  display: flex;
  gap: 12px;
  margin-top: 24px;
}

.preview-section {
  min-height: 400px;
  border: 2px dashed #e0e0e0;
  border-radius: 12px;
  padding: 24px;
  background: #fff;
}

.empty-preview {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 400px;
  color: #999;
}

.empty-icon {
  margin-bottom: 16px;
}

.images-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 20px;
}

.image-item {
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  overflow: hidden;
  transition: transform 0.2s;
}

.image-item:hover {
  transform: translateY(-4px);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

.image-wrapper {
  position: relative;
  aspect-ratio: 1;
  background: #f5f5f5;
}

.image-wrapper img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.image-loading {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(255, 255, 255, 0.8);
  display: flex;
  align-items: center;
  justify-content: center;
}

.loading-spinner {
  width: 40px;
  height: 40px;
  border: 3px solid #f3f3f3;
  border-top: 3px solid #409eff;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

.loading-spinner.small {
  width: 20px;
  height: 20px;
  border-width: 2px;
}

@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}

.image-info {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 12px;
  background: #fff;
}

.generation-status {
  margin-top: 24px;
  padding: 20px;
  background: #f0f9ff;
  border-radius: 8px;
  border: 1px solid #bae0ff;
}

.status-content {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
}

.status-tip {
  font-size: 14px;
  color: #666;
}

.history-section {
  margin-top: 32px;
  padding-top: 24px;
  border-top: 1px solid #e0e0e0;
}

.history-section h3 {
  margin-bottom: 16px;
  color: #333;
}

.history-list {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.history-item {
  padding: 16px;
  background: #f8f9fa;
  border-radius: 8px;
  cursor: pointer;
  transition: background-color 0.2s;
}

.history-item:hover {
  background: #e9ecef;
}

.history-prompt {
  color: #333;
  margin-bottom: 8px;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.history-time {
  font-size: 14px;
  color: #999;
}
</style>

3.2 创建API服务封装

为了更好的代码组织和复用,我们把API调用逻辑单独封装成一个服务。创建src/services/glmImageService.ts

import axios from 'axios'

export interface ImageGenerationParams {
  prompt: string
  n?: number
  size?: string
  style?: number
  negative_prompt?: string
}

export interface GeneratedImage {
  url: string
  revised_prompt?: string
}

export interface GenerationResponse {
  data: GeneratedImage[]
  created: number
}

class GLMImageService {
  private baseURL: string
  private apiKey: string
  
  constructor() {
    this.baseURL = import.meta.env.VITE_GLM_BASE_URL || 'https://open.bigmodel.cn/api/paas/v4'
    this.apiKey = import.meta.env.VITE_GLM_API_KEY || ''
    
    if (!this.apiKey) {
      console.warn('GLM-Image API Key未配置,请检查环境变量')
    }
  }
  
  /**
   * 生成图片
   */
  async generateImages(params: ImageGenerationParams): Promise<GenerationResponse> {
    try {
      const response = await axios.post(
        `${this.baseURL}/images/generations`,
        {
          model: 'glm-image',
          prompt: params.prompt,
          n: params.n || 2,
          size: params.size || '1024x1024',
          style: params.style || 0.7,
          negative_prompt: params.negative_prompt || '',
          response_format: 'url'
        },
        {
          headers: {
            'Authorization': `Bearer ${this.apiKey}`,
            'Content-Type': 'application/json'
          },
          timeout: 60000 // 60秒超时,图片生成可能需要较长时间
        }
      )
      
      return response.data
    } catch (error: any) {
      console.error('GLM-Image API调用失败:', error)
      
      // 提供更友好的错误信息
      if (error.response) {
        const status = error.response.status
        const data = error.response.data
        
        switch (status) {
          case 401:
            throw new Error('API Key无效或已过期,请检查配置')
          case 429:
            throw new Error('请求过于频繁,请稍后重试')
          case 500:
            throw new Error('服务器内部错误,请稍后重试')
          default:
            throw new Error(data?.message || `请求失败: ${status}`)
        }
      } else if (error.request) {
        throw new Error('网络请求失败,请检查网络连接')
      } else {
        throw new Error(`请求配置错误: ${error.message}`)
      }
    }
  }
  
  /**
   * 批量生成图片(分批次处理,避免单次请求太大)
   */
  async batchGenerateImages(
    params: ImageGenerationParams,
    batchSize: number = 4
  ): Promise<GeneratedImage[]> {
    const totalImages = params.n || 2
    const batches = Math.ceil(totalImages / batchSize)
    const allImages: GeneratedImage[] = []
    
    for (let i = 0; i < batches; i++) {
      const currentBatchSize = Math.min(batchSize, totalImages - i * batchSize)
      
      if (currentBatchSize <= 0) break
      
      const batchParams = {
        ...params,
        n: currentBatchSize
      }
      
      try {
        const response = await this.generateImages(batchParams)
        allImages.push(...response.data)
        
        // 批次间稍微延迟,避免触发限流
        if (i < batches - 1) {
          await new Promise(resolve => setTimeout(resolve, 1000))
        }
      } catch (error) {
        console.error(`第${i + 1}批次生成失败:`, error)
        // 可以选择继续生成剩余批次,或者直接抛出错误
        throw error
      }
    }
    
    return allImages
  }
  
  /**
   * 验证API Key是否有效
   */
  async validateApiKey(): Promise<boolean> {
    try {
      // 发送一个简单的测试请求
      await axios.get(`${this.baseURL}/models`, {
        headers: {
          'Authorization': `Bearer ${this.apiKey}`
        }
      })
      return true
    } catch (error) {
      return false
    }
  }
}

// 导出单例实例
export const glmImageService = new GLMImageService()

3.3 创建状态管理

对于这种有多个组件需要共享状态的应用,用Pinia来做状态管理比较合适。创建src/stores/imageStore.ts

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { glmImageService, type ImageGenerationParams, type GeneratedImage } from '@/services/glmImageService'

export interface GenerationHistory {
  id: string
  prompt: string
  params: ImageGenerationParams
  images: GeneratedImage[]
  timestamp: number
  favorite?: boolean
}

export const useImageStore = defineStore('image', () => {
  // 状态
  const currentPrompt = ref('')
  const generationParams = ref<ImageGenerationParams>({
    prompt: '',
    n: 2,
    size: '1024x1024',
    style: 0.7
  })
  
  const isGenerating = ref(false)
  const generatedImages = ref<GeneratedImage[]>([])
  const generationHistory = ref<GenerationHistory[]>([])
  const error = ref<string | null>(null)
  
  // 计算属性
  const hasImages = computed(() => generatedImages.value.length > 0)
  const historyCount = computed(() => generationHistory.value.length)
  const favoriteHistory = computed(() => 
    generationHistory.value.filter(item => item.favorite)
  )
  
  // 生成图片
  const generateImages = async () => {
    if (!currentPrompt.value.trim()) {
      error.value = '请输入图片描述'
      return
    }
    
    isGenerating.value = true
    error.value = null
    
    try {
      const params = {
        ...generationParams.value,
        prompt: currentPrompt.value
      }
      
      const response = await glmImageService.generateImages(params)
      generatedImages.value = response.data
      
      // 保存到历史记录
      const historyItem: GenerationHistory = {
        id: Date.now().toString(),
        prompt: currentPrompt.value,
        params: { ...params },
        images: [...response.data],
        timestamp: Date.now()
      }
      
      generationHistory.value.unshift(historyItem)
      
      // 限制历史记录数量
      if (generationHistory.value.length > 50) {
        generationHistory.value = generationHistory.value.slice(0, 50)
      }
      
      // 保存到localStorage
      saveHistoryToStorage()
      
    } catch (err: any) {
      error.value = err.message || '生成图片失败'
      generatedImages.value = []
    } finally {
      isGenerating.value = false
    }
  }
  
  // 从历史记录加载
  const loadFromHistory = (historyItem: GenerationHistory) => {
    currentPrompt.value = historyItem.prompt
    generationParams.value = { ...historyItem.params }
    generatedImages.value = [...historyItem.images]
  }
  
  // 切换收藏状态
  const toggleFavorite = (historyId: string) => {
    const item = generationHistory.value.find(item => item.id === historyId)
    if (item) {
      item.favorite = !item.favorite
      saveHistoryToStorage()
    }
  }
  
  // 删除历史记录
  const deleteHistory = (historyId: string) => {
    generationHistory.value = generationHistory.value.filter(item => item.id !== historyId)
    saveHistoryToStorage()
  }
  
  // 清空当前生成结果
  const clearCurrent = () => {
    generatedImages.value = []
    currentPrompt.value = ''
    error.value = null
  }
  
  // 清空所有历史记录
  const clearAllHistory = () => {
    generationHistory.value = []
    localStorage.removeItem('imageGenerationHistory')
  }
  
  // 保存历史记录到localStorage
  const saveHistoryToStorage = () => {
    try {
      localStorage.setItem('imageGenerationHistory', JSON.stringify(generationHistory.value))
    } catch (e) {
      console.error('保存历史记录失败:', e)
    }
  }
  
  // 从localStorage加载历史记录
  const loadHistoryFromStorage = () => {
    try {
      const saved = localStorage.getItem('imageGenerationHistory')
      if (saved) {
        generationHistory.value = JSON.parse(saved)
      }
    } catch (e) {
      console.error('加载历史记录失败:', e)
    }
  }
  
  // 初始化时加载历史记录
  loadHistoryFromStorage()
  
  return {
    // 状态
    currentPrompt,
    generationParams,
    isGenerating,
    generatedImages,
    generationHistory,
    error,
    
    // 计算属性
    hasImages,
    historyCount,
    favoriteHistory,
    
    // 方法
    generateImages,
    loadFromHistory,
    toggleFavorite,
    deleteHistory,
    clearCurrent,
    clearAllHistory
  }
})

4. 高级功能实现

基础功能完成后,可以添加一些提升用户体验的高级功能。

4.1 实时预览优化

图片生成可能需要一段时间,这段时间里给用户一些反馈很重要。我们可以实现一个进度模拟和预览优化的功能。

创建一个useImagePreview组合式函数:

import { ref, computed, onUnmounted } from 'vue'

export function useImagePreview() {
  const previewProgress = ref(0)
  const estimatedTime = ref(30) // 预估30秒
  const progressInterval = ref<NodeJS.Timeout | null>(null)
  
  // 开始模拟进度
  const startProgressSimulation = () => {
    previewProgress.value = 0
    estimatedTime.value = 30
    
    progressInterval.value = setInterval(() => {
      // 非线性进度,前期快后期慢
      const increment = previewProgress.value < 70 ? 5 : 2
      previewProgress.value = Math.min(previewProgress.value + increment, 95)
      
      // 更新预估时间
      if (previewProgress.value < 50) {
        estimatedTime.value = Math.max(10, estimatedTime.value - 2)
      }
    }, 1000)
  }
  
  // 停止进度模拟
  const stopProgressSimulation = () => {
    if (progressInterval.value) {
      clearInterval(progressInterval.value)
      progressInterval.value = null
    }
    previewProgress.value = 100
    setTimeout(() => {
      previewProgress.value = 0
    }, 1000)
  }
  
  // 计算剩余时间
  const remainingTime = computed(() => {
    if (previewProgress.value >= 95) return '即将完成'
    
    const progressPerSecond = previewProgress.value / (30 - estimatedTime.value)
    const remainingSeconds = Math.round((100 - previewProgress.value) / progressPerSecond)
    
    return `${remainingSeconds}秒`
  })
  
  // 清理
  onUnmounted(() => {
    if (progressInterval.value) {
      clearInterval(progressInterval.value)
    }
  })
  
  return {
    previewProgress,
    estimatedTime,
    remainingTime,
    startProgressSimulation,
    stopProgressSimulation
  }
}

4.2 图片处理工具

生成图片后,用户可能还需要一些简单的处理功能。创建一个图片处理工具:

export class ImageProcessor {
  /**
   * 压缩图片
   */
  static async compressImage(
    imageUrl: string, 
    maxWidth: number = 1024, 
    quality: number = 0.8
  ): Promise<string> {
    return new Promise((resolve, reject) => {
      const img = new Image()
      img.crossOrigin = 'anonymous'
      
      img.onload = () => {
        const canvas = document.createElement('canvas')
        const ctx = canvas.getContext('2d')
        
        if (!ctx) {
          reject(new Error('无法创建canvas上下文'))
          return
        }
        
        // 计算新尺寸
        let width = img.width
        let height = img.height
        
        if (width > maxWidth) {
          height = (height * maxWidth) / width
          width = maxWidth
        }
        
        canvas.width = width
        canvas.height = height
        
        // 绘制并压缩
        ctx.drawImage(img, 0, 0, width, height)
        
        // 转换为Blob
        canvas.toBlob(
          (blob) => {
            if (blob) {
              const compressedUrl = URL.createObjectURL(blob)
              resolve(compressedUrl)
            } else {
              reject(new Error('图片压缩失败'))
            }
          },
          'image/jpeg',
          quality
        )
      }
      
      img.onerror = () => {
        reject(new Error('图片加载失败'))
      }
      
      img.src = imageUrl
    })
  }
  
  /**
   * 转换为Base64
   */
  static async toBase64(imageUrl: string): Promise<string> {
    const response = await fetch(imageUrl)
    const blob = await response.blob()
    
    return new Promise((resolve, reject) => {
      const reader = new FileReader()
      reader.onloadend = () => resolve(reader.result as string)
      reader.onerror = reject
      reader.readAsDataURL(blob)
    })
  }
  
  /**
   * 获取图片信息
   */
  static async getImageInfo(imageUrl: string): Promise<{
    width: number
    height: number
    size: number
    type: string
  }> {
    return new Promise((resolve, reject) => {
      const img = new Image()
      
      img.onload = async () => {
        try {
          const response = await fetch(imageUrl)
          const blob = await response.blob()
          
          resolve({
            width: img.width,
            height: img.height,
            size: blob.size,
            type: blob.type
          })
        } catch (error) {
          reject(error)
        }
      }
      
      img.onerror = () => {
        reject(new Error('无法加载图片'))
      }
      
      img.src = imageUrl
    })
  }
}

4.3 批量处理功能

如果需要批量生成图片,可以添加批量处理功能:

<template>
  <div class="batch-processor">
    <h3>批量图片生成</h3>
    
    <div class="batch-input">
      <el-input
        v-model="batchPrompts"
        type="textarea"
        :rows="6"
        placeholder="每行一个描述,例如:&#10;一只可爱的橘猫&#10;美丽的日落风景&#10;未来城市夜景"
      />
      
      <div class="batch-info">
        <span>共 {{ promptCount }} 个描述</span>
        <el-button
          size="small"
          @click="generateBatch"
          :loading="isBatchGenerating"
          :disabled="promptCount === 0"
        >
          批量生成
        </el-button>
      </div>
    </div>
    
    <div v-if="batchResults.length > 0" class="batch-results">
      <h4>生成结果</h4>
      
      <div class="result-list">
        <div
          v-for="(result, index) in batchResults"
          :key="index"
          class="result-item"
          :class="{ 'has-error': result.error }"
        >
          <div class="result-header">
            <span class="prompt">{{ result.prompt }}</span>
            <span class="status">
              <span v-if="result.status === 'pending'">等待中</span>
              <span v-else-if="result.status === 'generating'">生成中</span>
              <span v-else-if="result.status === 'success'">成功</span>
              <span v-else-if="result.status === 'error'" class="error">失败</span>
            </span>
          </div>
          
          <div v-if="result.images && result.images.length > 0" class="result-images">
            <img
              v-for="(image, imgIndex) in result.images"
              :key="imgIndex"
              :src="image.url"
              class="batch-image"
            />
          </div>
          
          <div v-if="result.error" class="error-message">
            {{ result.error }}
          </div>
        </div>
      </div>
      
      <div class="batch-actions">
        <el-button @click="downloadAll">下载全部</el-button>
        <el-button @click="clearBatchResults">清空结果</el-button>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { glmImageService } from '@/services/glmImageService'

interface BatchResult {
  prompt: string
  status: 'pending' | 'generating' | 'success' | 'error'
  images?: Array<{ url: string }>
  error?: string
}

const batchPrompts = ref('')
const isBatchGenerating = ref(false)
const batchResults = ref<BatchResult[]>([])

const promptCount = computed(() => {
  return batchPrompts.value
    .split('\n')
    .filter(prompt => prompt.trim().length > 0)
    .length
})

const generateBatch = async () => {
  const prompts = batchPrompts.value
    .split('\n')
    .filter(prompt => prompt.trim().length > 0)
  
  if (prompts.length === 0) {
    ElMessage.warning('请输入至少一个描述')
    return
  }
  
  isBatchGenerating.value = true
  batchResults.value = prompts.map(prompt => ({
    prompt,
    status: 'pending'
  }))
  
  // 限制并发数,避免触发API限流
  const concurrencyLimit = 3
  const results: BatchResult[] = []
  
  for (let i = 0; i < prompts.length; i += concurrencyLimit) {
    const batch = prompts.slice(i, i + concurrencyLimit)
    
    const batchPromises = batch.map(async (prompt, index) => {
      const resultIndex = i + index
      batchResults.value[resultIndex].status = 'generating'
      
      try {
        const response = await glmImageService.generateImages({
          prompt,
          n: 1,
          size: '1024x1024'
        })
        
        batchResults.value[resultIndex] = {
          prompt,
          status: 'success',
          images: response.data
        }
        
        return batchResults.value[resultIndex]
      } catch (error: any) {
        batchResults.value[resultIndex] = {
          prompt,
          status: 'error',
          error: error.message || '生成失败'
        }
        
        return batchResults.value[resultIndex]
      }
    })
    
    const batchResultsData = await Promise.all(batchPromises)
    results.push(...batchResultsData)
    
    // 批次间延迟
    if (i + concurrencyLimit < prompts.length) {
      await new Promise(resolve => setTimeout(resolve, 2000))
    }
  }
  
  isBatchGenerating.value = false
  
  // 统计结果
  const successCount = results.filter(r => r.status === 'success').length
  const errorCount = results.filter(r => r.status === 'error').length
  
  ElMessage.success(`批量生成完成:成功 ${successCount} 个,失败 ${errorCount} 个`)
}

const downloadAll = () => {
  // 实现批量下载逻辑
  ElMessage.info('批量下载功能开发中')
}

const clearBatchResults = () => {
  batchResults.value = []
}
</script>

<style scoped>
.batch-processor {
  margin-top: 32px;
  padding: 24px;
  background: #f8f9fa;
  border-radius: 12px;
}

.batch-input {
  margin-bottom: 24px;
}

.batch-info {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-top: 12px;
  color: #666;
}

.batch-results {
  margin-top: 24px;
}

.result-list {
  display: flex;
  flex-direction: column;
  gap: 16px;
  max-height: 500px;
  overflow-y: auto;
  padding-right: 8px;
}

.result-item {
  padding: 16px;
  background: white;
  border-radius: 8px;
  border: 1px solid #e0e0e0;
}

.result-item.has-error {
  border-color: #f56c6c;
  background: #fef0f0;
}

.result-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 12px;
}

.result-header .prompt {
  flex: 1;
  color: #333;
  font-weight: 500;
}

.result-header .status {
  margin-left: 16px;
  font-size: 14px;
}

.result-header .status .error {
  color: #f56c6c;
}

.result-images {
  display: flex;
  gap: 12px;
  flex-wrap: wrap;
}

.batch-image {
  width: 100px;
  height: 100px;
  object-fit: cover;
  border-radius: 4px;
  border: 1px solid #e0e0e0;
}

.error-message {
  margin-top: 8px;
  padding: 8px;
  background: #fef0f0;
  border-radius: 4px;
  color: #f56c6c;
  font-size: 14px;
}

.batch-actions {
  display: flex;
  gap: 12px;
  margin-top: 20px;
}
</style>

5. 性能优化与最佳实践

在实际使用中,还需要注意一些性能优化和最佳实践。

5.1 图片懒加载

当生成大量图片时,实现懒加载可以显著提升页面性能:

import { useIntersectionObserver } from '@vueuse/core'

export function useLazyImage() {
  const imageRef = ref<HTMLImageElement>()
  const isLoaded = ref(false)
  const isLoading = ref(false)
  
  const { stop } = useIntersectionObserver(
    imageRef,
    ([{ isIntersecting }]) => {
      if (isIntersecting && !isLoaded.value && !isLoading.value) {
        loadImage()
      }
    },
    {
      rootMargin: '50px'
    }
  )
  
  const loadImage = () => {
    if (!imageRef.value) return
    
    isLoading.value = true
    const img = new Image()
    
    img.onload = () => {
      if (imageRef.value) {
        imageRef.value.src = img.src
        isLoaded.value = true
        isLoading.value = false
      }
    }
    
    img.onerror = () => {
      isLoading.value = false
    }
    
    img.src = imageRef.value.dataset.src || ''
  }
  
  onUnmounted(() => {
    stop()
  })
  
  return {
    imageRef,
    isLoaded,
    isLoading
  }
}

5.2 请求缓存

为了避免重复生成相同的图片,可以添加请求缓存:

class RequestCache {
  private cache = new Map<string, {
    data: any
    timestamp: number
    expiresIn: number
  }>()
  
  constructor(private defaultExpiresIn: number = 5 * 60 * 1000) {}
  
  set(key: string, data: any, expiresIn?: number) {
    this.cache.set(key, {
      data,
      timestamp: Date.now(),
      expiresIn: expiresIn || this.defaultExpiresIn
    })
  }
  
  get(key: string): any | null {
    const item = this.cache.get(key)
    
    if (!item) return null
    
    // 检查是否过期
    if (Date.now() - item.timestamp > item.expiresIn) {
      this.cache.delete(key)
      return null
    }
    
    return item.data
  }
  
  delete(key: string) {
    this.cache.delete(key)
  }
  
  clear() {
    this.cache.clear()
  }
  
  // 生成缓存键
  static generateKey(params: any): string {
    return JSON.stringify(params)
  }
}

// 在GLMImageService中使用缓存
export class GLMImageServiceWithCache extends GLMImageService {
  private cache = new RequestCache(10 * 60 * 1000) // 10分钟缓存
  
  async generateImages(params: ImageGenerationParams): Promise<GenerationResponse> {
    const cacheKey = RequestCache.generateKey(params)
    const cached = this.cache.get(cacheKey)
    
    if (cached) {
      console.log('使用缓存结果')
      return cached
    }
    
    const result = await super.generateImages(params)
    this.cache.set(cacheKey, result)
    
    return result
  }
}

5.3 错误重试机制

网络请求可能会失败,添加重试机制可以提高成功率:

async function withRetry<T>(
  fn: () => Promise<T>,
  maxRetries: number = 3,
  delay: number = 1000
): Promise<T> {
  let lastError: Error
  
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await fn()
    } catch (error) {
      lastError = error as Error
      
      // 如果不是最后一次重试,等待后继续
      if (i < maxRetries - 1) {
        await new Promise(resolve => setTimeout(resolve, delay * Math.pow(2, i)))
      }
    }
  }
  
  throw lastError
}

// 使用示例
const generateWithRetry = async (params: ImageGenerationParams) => {
  return await withRetry(
    () => glmImageService.generateImages(params),
    3,
    1000
  )
}

6. 部署与配置

6.1 生产环境配置

在生产环境中,需要确保配置正确且安全:

# .env.production
VITE_GLM_API_KEY=你的生产环境API_Key
VITE_GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4
VITE_APP_TITLE=AI图像生成工具
VITE_API_TIMEOUT=60000

6.2 构建优化

vite.config.ts中添加构建优化配置:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'vendor': ['vue', 'vue-router', 'pinia'],
          'ui-library': ['element-plus'],
          'utils': ['axios', '@vueuse/core']
        }
      }
    },
    chunkSizeWarningLimit: 1000
  },
  server: {
    proxy: {
      '/api': {
        target: 'https://open.bigmodel.cn',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, '/api/paas/v4')
      }
    }
  }
})

6.3 部署脚本

可以创建一个简单的部署脚本:

#!/bin/bash
# deploy.sh

echo "开始构建..."
npm run build

echo "构建完成,开始部署..."

# 这里可以根据你的部署方式添加具体命令
# 例如上传到CDN、部署到服务器等

echo "部署完成!"

7. 实际应用中的注意事项

在实际项目中使用这个方案时,有几个点需要注意:

API调用成本:GLM-Image是按调用次数计费的,如果用户量比较大,成本会相应增加。可以考虑添加使用限制,比如每天免费生成次数,或者对高质量参数设置付费。

内容安全:用户可能会输入不适当的内容,需要在前后端都添加内容审核。前端可以做一些关键词过滤,后端调用API时也可以设置安全策略。

用户体验:图片生成需要时间,要给用户明确的反馈。除了进度条,还可以考虑添加预估时间、排队状态显示等。

错误处理:网络可能不稳定,API可能暂时不可用。要有完善的错误处理机制,给用户友好的错误提示,并在可能的情况下自动重试。

移动端适配:如果用户可能在手机上使用,要确保界面在移动设备上也能正常显示和操作。Element Plus本身响应式做得不错,但一些自定义样式可能需要额外调整。

性能监控:在生产环境中,可以添加一些性能监控,比如记录生成成功率、平均生成时间、用户常用参数等,这些数据对优化产品很有帮助。

8. 总结

整体用下来,Vue3 + GLM-Image的组合还是挺顺手的。Vue3的响应式系统和组合式API让前端开发效率很高,GLM-Image的API也比较稳定,图像质量在中文场景下表现不错。

这个方案比较适合需要快速集成AI图像生成能力的项目,比如内容创作工具、电商素材生成、教育应用等。代码结构清晰,功能模块划分明确,后续要扩展新功能或者调整现有功能都比较方便。

如果你也在考虑类似的需求,建议先从简单的版本开始,把核心的生成和预览功能跑通,然后再根据实际需要添加高级功能。过程中可能会遇到一些细节问题,比如图片加载优化、错误处理完善等,但整体框架是可行的。

最后,AI技术发展很快,新的模型和功能不断出现。保持代码的可扩展性很重要,这样未来要切换模型或者添加新特性时,改动成本会比较低。


获取更多AI镜像

想探索更多AI镜像和应用场景?访问 CSDN星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐