使用Vue构建Qwen-Image-Edit-F2P前端交互界面

使用Vue构建Qwen-Image-Edit-F2P前端交互界面

最近在玩AI图像生成,特别是Qwen-Image-Edit-F2P这个模型,它可以根据一张人脸照片生成风格各异的全身照,效果挺有意思的。不过,每次都要在ComfyUI里拖拽节点、调整参数,对普通用户来说门槛有点高。

我就想,能不能做个简单点的网页界面,让用户上传照片、输入描述,点个按钮就能看到效果?正好Vue用起来挺顺手的,就动手试了试。今天这篇文章,我就来分享一下怎么用Vue给这个AI模型搭建一个友好的Web交互界面,从组件设计到API调用,一步步带你实现。

如果你也想给自己的AI项目做个前端界面,或者想了解Vue怎么和AI后端配合,这篇文章应该能给你一些实用的参考。

1. 项目准备与环境搭建

开始之前,我们先明确一下要做什么。我们的目标是:做一个网页,用户可以在上面上传人脸照片,输入文字描述(比如“穿着红色礼服站在巴黎铁塔前”),然后点击生成,就能看到AI根据照片和描述生成的全身照。

整个项目会分成前端和后端两部分。前端用Vue 3,后端用Python的FastAPI来调用Qwen-Image-Edit-F2P模型。我们先从前端开始。

1.1 创建Vue项目

如果你还没装Vue,可以先装一下。打开终端,运行:

npm create vue@latest vue-ai-image-editor 

创建项目的时候,我选了下面这些选项:

  • TypeScript:选“是”,类型检查能让代码更可靠
  • JSX:选“否”,我们用模板语法就行
  • Vue Router:选“是”,虽然我们这个单页面应用可能用不上,但留着以后扩展方便
  • Pinia:选“是”,状态管理很有用
  • ESLint:选“是”,保持代码规范
  • Prettier:选“是”,代码格式化

项目创建好后,进入目录安装依赖:

cd vue-ai-image-editor npm install 

1.2 安装必要的UI库

为了快速搭建界面,我用了Element Plus,它组件丰富,文档也全。安装命令:

npm install element-plus @element-plus/icons-vue 

然后在main.ts里引入:

import { createApp } from 'vue' import App from './App.vue' import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' const app = createApp(App) app.use(ElementPlus) app.mount('#app') 

1.3 安装HTTP请求库

前端需要和后端API通信,我选了Axios,用起来简单:

npm install axios 

再创建一个src/utils/request.ts文件来配置Axios:

import axios from 'axios' const request = axios.create({ baseURL: 'http://localhost:8000', // 后端API地址 timeout: 300000, // 超时时间设长一点,AI生成图片比较慢 }) // 请求拦截器 request.interceptors.request.use( (config) => { // 可以在这里加token之类的 return config }, (error) => { return Promise.reject(error) } ) // 响应拦截器 request.interceptors.response.use( (response) => { return response.data }, (error) => { // 统一错误处理 console.error('请求出错:', error) return Promise.reject(error) } ) export default request 

环境差不多就准备好了,接下来我们设计一下页面结构。

2. 页面布局与组件设计

我想把界面做得简洁明了,主要功能都放在一个页面上。大概分成这几个区域:

  1. 顶部:标题和说明
  2. 左侧:输入区域(上传图片、输入描述、调整参数)
  3. 右侧:预览区域(显示原图和生成结果)
  4. 底部:生成按钮和状态显示

2.1 创建主页面组件

src/views目录下创建HomeView.vue,先搭个框架:

<template> <div> <!-- 顶部标题 --> <header> <h1>AI图像生成编辑器</h1> <p>基于Qwen-Image-Edit-F2P模型,上传人脸照片,生成风格各异的全身照</p> </header> <!-- 主要内容区域 --> <main> <div> <!-- 这里放输入相关的组件 --> </div> <div> <!-- 这里放预览相关的组件 --> </div> </main> <!-- 底部操作区域 --> <footer> <!-- 这里放生成按钮和状态显示 --> </footer> </div> </template> <script setup lang="ts"> // 这里写逻辑 </script> <style scoped> .home-container { max-width: 1400px; margin: 0 auto; padding: 20px; min-height: 100vh; display: flex; flex-direction: column; } .app-header { text-align: center; margin-bottom: 30px; padding-bottom: 20px; border-bottom: 1px solid #eee; } .app-header h1 { font-size: 2.5rem; color: #333; margin-bottom: 10px; } .subtitle { font-size: 1.1rem; color: #666; max-width: 800px; margin: 0 auto; line-height: 1.6; } .main-content { display: grid; grid-template-columns: 1fr 1fr; gap: 30px; flex: 1; margin-bottom: 30px; } .input-section, .preview-section { background: #fff; border-radius: 12px; padding: 25px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); border: 1px solid #eaeaea; } .action-footer { background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); border: 1px solid #eaeaea; } @media (max-width: 1024px) { .main-content { grid-template-columns: 1fr; gap: 20px; } .app-header h1 { font-size: 2rem; } } </style> 

2.2 图片上传组件

输入区域最重要的就是图片上传。我设计了一个组件,支持拖拽上传和点击上传,还能预览上传的图片。

创建src/components/ImageUpload.vue

<template> <div> <div :class="{ 'is-dragover': isDragover, 'has-image': imageUrl }" @dragover.prevent="handleDragover" @dragleave.prevent="handleDragleave" @drop.prevent="handleDrop" @click="triggerFileInput" > <!-- 有图片时显示预览 --> <div v-if="imageUrl"> <img :src="imageUrl" alt="上传的图片" /> <div> <el-icon :size="30"><Upload /></el-icon> <p>点击或拖拽更换图片</p> </div> </div> <!-- 没有图片时显示上传提示 --> <div v-else> <el-icon :size="60"><Upload /></el-icon> <p>点击或拖拽上传人脸照片</p> <p>建议使用正面清晰的人脸照片,背景简单为佳</p> </div> <!-- 隐藏的文件输入 --> <input ref="fileInput" type="file" accept="image/*" @change="handleFileChange" /> </div> <!-- 图片信息显示 --> <div v-if="imageFile"> <div> <span>文件名:</span> <span>{{ imageFile.name }}</span> </div> <div> <span>大小:</span> <span>{{ formatFileSize(imageFile.size) }}</span> </div> <div> <span>类型:</span> <span>{{ imageFile.type }}</span> </div> </div> <!-- 错误提示 --> <el-alert v-if="errorMessage" :title="errorMessage" type="error" :closable="false" show-icon /> </div> </template> <script setup lang="ts"> import { ref, computed } from 'vue' import { Upload } from '@element-plus/icons-vue' const props = defineProps<{ modelValue?: File | null }>() const emit = defineEmits<{ 'update:modelValue': [file: File | null] }>() const fileInput = ref<HTMLInputElement>() const isDragover = ref(false) const errorMessage = ref('') // 计算图片URL用于预览 const imageUrl = computed(() => { if (!props.modelValue) return '' return URL.createObjectURL(props.modelValue) }) // 计算图片文件 const imageFile = computed(() => props.modelValue) // 触发文件选择 const triggerFileInput = () => { fileInput.value?.click() } // 处理文件选择 const handleFileChange = (event: Event) => { const input = event.target as HTMLInputElement if (input.files && input.files[0]) { validateAndSetFile(input.files[0]) } } // 处理拖拽进入 const handleDragover = () => { isDragover.value = true } // 处理拖拽离开 const handleDragleave = () => { isDragover.value = false } // 处理拖拽放下 const handleDrop = (event: DragEvent) => { isDragover.value = false if (event.dataTransfer?.files && event.dataTransfer.files[0]) { validateAndSetFile(event.dataTransfer.files[0]) } } // 验证并设置文件 const validateAndSetFile = (file: File) => { errorMessage.value = '' // 检查文件类型 if (!file.type.startsWith('image/')) { errorMessage.value = '请上传图片文件' return } // 检查文件大小(限制5MB) if (file.size > 5 * 1024 * 1024) { errorMessage.value = '图片大小不能超过5MB' return } emit('update:modelValue', file) } // 格式化文件大小 const formatFileSize = (bytes: number): string => { if (bytes === 0) return '0 B' const k = 1024 const sizes = ['B', 'KB', 'MB', 'GB'] const i = Math.floor(Math.log(bytes) / Math.log(k)) return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] } </script> <style scoped> .image-upload { width: 100%; } .upload-area { border: 2px dashed #dcdfe6; border-radius: 8px; padding: 40px 20px; text-align: center; cursor: pointer; transition: all 0.3s ease; background-color: #fafafa; min-height: 300px; display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden; } .upload-area:hover { border-color: #409eff; background-color: #f0f9ff; } .upload-area.is-dragover { border-color: #409eff; background-color: #ecf5ff; } .upload-area.has-image { padding: 0; min-height: 400px; } .image-preview { width: 100%; height: 100%; position: relative; } .image-preview img { width: 100%; height: 100%; object-fit: contain; display: block; } .preview-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); display: flex; flex-direction: column; align-items: center; justify-content: center; color: white; opacity: 0; transition: opacity 0.3s ease; } .preview-overlay .el-icon { margin-bottom: 10px; } .image-preview:hover .preview-overlay { opacity: 1; } .upload-prompt { display: flex; flex-direction: column; align-items: center; gap: 15px; } .upload-icon { color: #c0c4cc; } .prompt-text { font-size: 16px; color: #606266; margin: 0; } .hint-text { font-size: 14px; color: #909399; margin: 0; } .image-info { margin-top: 20px; padding: 15px; background: #f5f7fa; border-radius: 6px; font-size: 14px; } .info-item { display: flex; margin-bottom: 8px; } .info-item:last-child { margin-bottom: 0; } .label { color: #909399; min-width: 60px; } .value { color: #606266; word-break: break-all; } .error-alert { margin-top: 15px; } </style> 

2.3 参数设置组件

AI生成图片需要一些参数,比如生成步数、引导尺度等。我做了个组件让用户可以调整这些参数。

创建src/components/ParameterPanel.vue

<template> <div> <h3>生成参数设置</h3> <div> <div> <div> <span>提示词</span> <el-tooltip content="描述你想要的图片内容,越详细越好"> <el-icon><QuestionFilled /></el-icon> </el-tooltip> </div> <el-input v-model="localParams.prompt" type="textarea" :rows="4" placeholder="例如:一位年轻女性穿着红色礼服,站在巴黎铁塔前,阳光明媚,背景虚化" resize="none" /> <div> 试试这些描述:穿着白色婚纱在花海中、穿着职业装在城市街道、穿着汉服在古建筑前 </div> </div> <div> <div> <span>负向提示词</span> <el-tooltip content="描述你不希望在图片中出现的内容"> <el-icon><QuestionFilled /></el-icon> </el-tooltip> </div> <el-input v-model="localParams.negative_prompt" type="textarea" :rows="2" placeholder="例如:模糊、低质量、畸形、多余的手指" resize="none" /> </div> <div> <div> <div> <span>生成步数</span> <el-tooltip content="步数越多,细节越好,但生成时间越长"> <el-icon><QuestionFilled /></el-icon> </el-tooltip> </div> <el-slider v-model="localParams.num_inference_steps" :min="20" :max="50" :step="5" show-stops show-input /> <div>建议值:30-40步</div> </div> <div> <div> <span>引导尺度</span> <el-tooltip content控制AI遵循提示词的程度,值越大越严格"> <el-icon><QuestionFilled /></el-icon> </el-tooltip> </div> <el-slider v-model="localParams.guidance_scale" :min="1" :max="7" :step="0.5" show-input /> <div>建议值:3.5-5.0</div> </div> <div> <div> <span>图片尺寸</span> </div> <el-select v-model="localParams.aspect_ratio" placeholder="选择尺寸比例"> <el-option label="正方形 (1:1)" value="1:1" /> <el-option label="横屏 (16:9)" value="16:9" /> <el-option label="竖屏 (9:16)" value="9:16" /> <el-option label="标准 (4:3)" value="4:3" /> <el-option label="肖像 (3:4)" value="3:4" /> </el-select> </div> <div> <div> <span>随机种子</span> <el-tooltip content="相同的种子会产生相似的结果,-1表示随机"> <el-icon><QuestionFilled /></el-icon> </el-tooltip> </div> <el-input-number v-model="localParams.seed" :min="-1" :max="999999" controls-position="right" /> <div>-1 表示每次随机生成</div> </div> </div> </div> <div> <el-button v-for="preset in presets" :key="preset.name" @click="applyPreset(preset)" size="small" > {{ preset.name }} </el-button> </div> </div> </template> <script setup lang="ts"> import { ref, watch, computed } from 'vue' import { QuestionFilled } from '@element-plus/icons-vue' // 定义参数类型 interface GenerationParams { prompt: string negative_prompt: string num_inference_steps: number guidance_scale: number aspect_ratio: string seed: number } const props = defineProps<{ params: GenerationParams }>() const emit = defineEmits<{ 'update:params': [params: GenerationParams] }>() // 本地参数副本 const localParams = ref<GenerationParams>({ ...props.params }) // 监听本地参数变化,同步到父组件 watch(localParams, (newValue) => { emit('update:params', newValue) }, { deep: true }) // 预设参数配置 const presets = [ { name: '婚纱照', params: { prompt: '一位年轻女性穿着白色婚纱,站在花海中,阳光透过花瓣洒在身上,梦幻唯美', negative_prompt: '模糊、低质量、畸形、背景杂乱', num_inference_steps: 40, guidance_scale: 4.5, aspect_ratio: '3:4', seed: -1 } }, { name: '职业照', params: { prompt: '一位专业女性穿着西装,在城市高楼背景前,自信微笑,专业干练', negative_prompt: '休闲装、模糊、表情呆板', num_inference_steps: 35, guidance_scale: 4.0, aspect_ratio: '4:3', seed: -1 } }, { name: '古风', params: { prompt: '一位女子穿着汉服,站在古建筑前,手持团扇,古典优雅', negative_prompt: '现代服饰、模糊、色彩杂乱', num_inference_steps: 45, guidance_scale: 5.0, aspect_ratio: '9:16', seed: -1 } } ] // 应用预设 const applyPreset = (preset: typeof presets[0]) => { localParams.value = { ...preset.params } } </script> <style scoped> .parameter-panel { width: 100%; } .panel-title { font-size: 18px; color: #333; margin-bottom: 20px; padding-bottom: 10px; border-bottom: 1px solid #eee; } .parameter-group { display: flex; flex-direction: column; gap: 20px; } .parameter-item { margin-bottom: 15px; } .parameter-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; } .parameter-label { font-size: 14px; font-weight: 500; color: #333; } .parameter-hint { font-size: 12px; color: #909399; margin-top: 6px; } .parameter-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; } @media (max-width: 768px) { .parameter-grid { grid-template-columns: 1fr; } } .preset-buttons { display: flex; gap: 10px; margin-top: 20px; flex-wrap: wrap; } </style> 

2.4 图片预览组件

预览区域需要显示原图和生成结果,我做了个对比展示的组件。

创建src/components/ImagePreview.vue

<template> <div> <div> <h3>图片预览</h3> <div> <el-button v-if="generatedImage" @click="downloadImage" type="primary" size="small" :icon="Download" > 下载图片 </el-button> <el-button v-if="generatedImage" @click="$emit('regenerate')" size="small" :icon="Refresh" > 重新生成 </el-button> </div> </div> <div> <!-- 生成中显示加载状态 --> <div v-if="isGenerating"> <div> <el-icon :size="50"><Loading /></el-icon> </div> <p>AI正在生成图片,请稍候...</p> <p>这可能需要30-60秒,取决于图片复杂度和参数设置</p> <el-progress :percentage="progress" :indeterminate="progress === 0" :stroke-width="6" /> </div> <!-- 生成完成显示对比 --> <div v-else-if="generatedImage"> <div> <div>原图</div> <div> <img :src="sourceImage" alt="原图" /> </div> <div> <span>输入的人脸照片</span> </div> </div> <div> <el-icon :size="30"><Right /></el-icon> </div> <div> <div>生成结果</div> <div> <img :src="generatedImage" alt="生成结果" /> </div> <div> <span>AI生成的全身照</span> <span>生成耗时:{{ generationTime }}秒</span> </div> </div> </div> <!-- 等待生成状态 --> <div v-else> <div> <el-icon :size="80" color="#c0c4cc"><Picture /></el-icon> </div> <p>上传图片并设置参数后,点击生成按钮开始创作</p> <p>AI将根据你的人脸特征和描述生成全新的全身照</p> </div> </div> <!-- 生成信息 --> <div v-if="generatedImage && generationInfo"> <h4>生成信息</h4> <div> <div> <span>提示词:</span> <span>{{ generationInfo.prompt }}</span> </div> <div> <span>参数:</span> <span> 步数 {{ generationInfo.num_inference_steps }} · 引导 {{ generationInfo.guidance_scale }} · 尺寸 {{ generationInfo.aspect_ratio }} </span> </div> <div> <span>种子:</span> <span>{{ generationInfo.seed }}</span> </div> </div> </div> </div> </template> <script setup lang="ts"> import { ref, computed } from 'vue' import { Download, Refresh, Loading, Right, Picture } from '@element-plus/icons-vue' interface GenerationInfo { prompt: string num_inference_steps: number guidance_scale: number aspect_ratio: string seed: number } const props = defineProps<{ sourceImage?: string generatedImage?: string isGenerating: boolean progress: number generationTime?: number generationInfo?: GenerationInfo }>() const emit = defineEmits<{ regenerate: [] download: [imageUrl: string] }>() // 下载图片 const downloadImage = () => { if (props.generatedImage) { emit('download', props.generatedImage) // 创建下载链接 const link = document.createElement('a') link.href = props.generatedImage link.download = `ai-generated-${Date.now()}.png` document.body.appendChild(link) link.click() document.body.removeChild(link) } } </script> <style scoped> .image-preview-container { width: 100%; height: 100%; display: flex; flex-direction: column; } .preview-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 10px; border-bottom: 1px solid #eee; } .preview-header h3 { font-size: 18px; color: #333; margin: 0; } .preview-actions { display: flex; gap: 10px; } .preview-content { flex: 1; display: flex; align-items: center; justify-content: center; min-height: 500px; } .generating-state { text-align: center; padding: 40px 20px; } .loading-spinner { margin-bottom: 20px; } .loading-icon { color: #409eff; animation: spin 1.5s linear infinite; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } .loading-text { font-size: 18px; color: #333; margin-bottom: 10px; } .loading-hint { font-size: 14px; color: #909399; margin-bottom: 20px; } .comparison-view { display: flex; align-items: center; justify-content: center; gap: 30px; width: 100%; padding: 20px; } .comparison-item { flex: 1; max-width: 400px; } .comparison-label { font-size: 16px; font-weight: 500; color: #333; margin-bottom: 15px; text-align: center; } .image-wrapper { border-radius: 8px; overflow: hidden; border: 1px solid #eaeaea; background: #fafafa; aspect-ratio: 3/4; display: flex; align-items: center; justify-content: center; } .image-wrapper img { width: 100%; height: 100%; object-fit: contain; display: block; } .image-info { margin-top: 15px; text-align: center; font-size: 14px; color: #666; display: flex; flex-direction: column; gap: 5px; } .generation-time { color: #409eff; font-weight: 500; } .comparison-arrow { color: #409eff; } .waiting-state { text-align: center; padding: 60px 20px; } .waiting-icon { margin-bottom: 20px; } .waiting-text { font-size: 18px; color: #333; margin-bottom: 10px; } .waiting-hint { font-size: 14px; color: #909399; max-width: 400px; margin: 0 auto; line-height: 1.6; } .generation-info { margin-top: 30px; padding: 20px; background: #f5f7fa; border-radius: 8px; } .generation-info h4 { font-size: 16px; color: #333; margin-bottom: 15px; } .info-grid { display: flex; flex-direction: column; gap: 10px; } .info-item { display: flex; font-size: 14px; line-height: 1.5; } .info-label { color: #909399; min-width: 60px; } .info-value { color: #606266; flex: 1; word-break: break-word; } @media (max-width: 1024px) { .comparison-view { flex-direction: column; gap: 20px; } .comparison-item { max-width: 100%; } .comparison-arrow { transform: rotate(90deg); } } </style> 

3. 整合页面与状态管理

组件都准备好了,现在要把它们整合到主页面,并管理应用的状态。

3.1 创建状态管理

用Pinia来管理应用状态,创建src/stores/imageStore.ts

import { defineStore } from 'pinia' import { ref } from 'vue' import type { Ref } from 'vue' // 生成参数类型 export interface GenerationParams { prompt: string negative_prompt: string num_inference_steps: number guidance_scale: number aspect_ratio: string seed: number } // 生成结果类型 export interface GenerationResult { imageUrl: string generationTime: number params: GenerationParams timestamp: number } export const useImageStore = defineStore('image', () => { // 上传的图片文件 const uploadedImage: Ref<File | null> = ref(null) // 上传图片的预览URL const uploadedImageUrl: Ref<string> = ref('') // 生成参数 const generationParams: Ref<GenerationParams> = ref({ prompt: '一位年轻女性穿着白色连衣裙,站在花海中,阳光明媚,背景虚化', negative_prompt: '模糊、低质量、畸形、多余的手指、背景杂乱', num_inference_steps: 40, guidance_scale: 4.5, aspect_ratio: '3:4', seed: -1 }) // 生成状态 const isGenerating: Ref<boolean> = ref(false) const generationProgress: Ref<number> = ref(0) // 生成结果 const generatedImageUrl: Ref<string> = ref('') const generationResult: Ref<GenerationResult | null> = ref(null) // 生成历史 const generationHistory: Ref<GenerationResult[]> = ref([]) // 设置上传的图片 const setUploadedImage = (file: File | null) => { uploadedImage.value = file if (uploadedImageUrl.value) { URL.revokeObjectURL(uploadedImageUrl.value) } if (file) { uploadedImageUrl.value = URL.createObjectURL(file) } else { uploadedImageUrl.value = '' } } // 更新生成参数 const updateGenerationParams = (params: Partial<GenerationParams>) => { generationParams.value = { ...generationParams.value, ...params } } // 开始生成 const startGeneration = () => { isGenerating.value = true generationProgress.value = 0 } // 更新生成进度 const updateGenerationProgress = (progress: number) => { generationProgress.value = progress } // 完成生成 const completeGeneration = (result: GenerationResult) => { isGenerating.value = false generationProgress.value = 100 generatedImageUrl.value = result.imageUrl generationResult.value = result generationHistory.value.unshift(result) // 只保留最近10条历史记录 if (generationHistory.value.length > 10) { generationHistory.value = generationHistory.value.slice(0, 10) } } // 清除生成结果 const clearGeneration = () => { if (generatedImageUrl.value) { URL.revokeObjectURL(generatedImageUrl.value) } generatedImageUrl.value = '' generationResult.value = null } // 重置所有状态 const resetAll = () => { setUploadedImage(null) clearGeneration() generationParams.value = { prompt: '一位年轻女性穿着白色连衣裙,站在花海中,阳光明媚,背景虚化', negative_prompt: '模糊、低质量、畸形、多余的手指、背景杂乱', num_inference_steps: 40, guidance_scale: 4.5, aspect_ratio: '3:4', seed: -1 } generationHistory.value = [] } return { uploadedImage, uploadedImageUrl, generationParams, isGenerating, generationProgress, generatedImageUrl, generationResult, generationHistory, setUploadedImage, updateGenerationParams, startGeneration, updateGenerationProgress, completeGeneration, clearGeneration, resetAll } }) 

3.2 完善主页面

现在更新HomeView.vue,把组件都整合进来:

<template> <div> <!-- 顶部标题 --> <header> <h1>AI图像生成编辑器</h1> <p>基于Qwen-Image-Edit-F2P模型,上传人脸照片,生成风格各异的全身照</p> </header> <!-- 主要内容区域 --> <main> <div> <div> <el-icon><Upload /></el-icon> <span>上传图片与参数设置</span> </div> <div> <h4>上传人脸照片</h4> <p>请上传正面清晰的人脸照片,AI将根据面部特征生成全身照</p> <ImageUpload v-model="uploadedImage" /> </div> <div> <ParameterPanel v-model:params="generationParams" /> </div> <div v-if="generationHistory.length > 0"> <h4>生成历史</h4> <div> <div v-for="(item, index) in generationHistory.slice(0, 3)" :key="index" @click="loadHistory(item)" > <img :src="item.imageUrl" alt="历史图片" /> <div> <p>{{ truncatePrompt(item.params.prompt) }}</p> <p>{{ formatTime(item.timestamp) }}</p> </div> </div> </div> </div> </div> <div> <ImagePreview :source-image="uploadedImageUrl" :generated-image="generatedImageUrl" :is-generating="isGenerating" :progress="generationProgress" :generation-time="generationResult?.generationTime" :generation-info="generationResult?.params" @regenerate="handleRegenerate" @download="handleDownload" /> </div> </main> <!-- 底部操作区域 --> <footer> <div> <div> <el-alert v-if="!uploadedImage" title="请先上传人脸照片" type="warning" :closable="false" show-icon /> <div v-else> <el-icon color="#67c23a"><SuccessFilled /></el-icon> <span>已准备就绪,可以开始生成</span> </div> </div> <div> <el-button @click="handleReset" :icon="Delete" size="large" > 重置 </el-button> <el-button type="primary" @click="handleGenerate" :loading="isGenerating" :disabled="!uploadedImage || isGenerating" :icon="MagicStick" size="large" > {{ isGenerating ? '生成中...' : '开始生成' }} </el-button> </div> </div> </footer> </div> </template> <script setup lang="ts"> import { ref, computed, onUnmounted } from 'vue' import { Upload, Delete, MagicStick, SuccessFilled } from '@element-plus/icons-vue' import ImageUpload from '@/components/ImageUpload.vue' import ParameterPanel from '@/components/ParameterPanel.vue' import ImagePreview from '@/components/ImagePreview.vue' import { useImageStore } from '@/stores/imageStore' import { generateImage } from '@/services/api' // 使用状态管理 const imageStore = useImageStore() // 计算属性 const uploadedImage = computed({ get: () => imageStore.uploadedImage, set: (value) => imageStore.setUploadedImage(value) }) const uploadedImageUrl = computed(() => imageStore.uploadedImageUrl) const generationParams = computed({ get: () => imageStore.generationParams, set: (value) => imageStore.updateGenerationParams(value) }) const isGenerating = computed(() => imageStore.isGenerating) const generationProgress = computed(() => imageStore.generationProgress) const generatedImageUrl = computed(() => imageStore.generatedImageUrl) const generationResult = computed(() => imageStore.generationResult) const generationHistory = computed(() => imageStore.generationHistory) // 处理生成 const handleGenerate = async () => { if (!uploadedImage.value) { ElMessage.warning('请先上传图片') return } try { imageStore.startGeneration() // 模拟进度更新(实际项目中应该用WebSocket或轮询获取真实进度) const progressInterval = setInterval(() => { if (imageStore.generationProgress < 90) { imageStore.updateGenerationProgress(imageStore.generationProgress + 10) } }, 1000) // 调用API生成图片 const startTime = Date.now() const result = await generateImage( uploadedImage.value, generationParams.value ) const generationTime = (Date.now() - startTime) / 1000 clearInterval(progressInterval) // 完成生成 imageStore.completeGeneration({ imageUrl: result.imageUrl, generationTime, params: generationParams.value, timestamp: Date.now() }) ElMessage.success('图片生成成功!') } catch (error) { console.error('生成失败:', error) ElMessage.error('图片生成失败,请重试') imageStore.isGenerating = false } } // 处理重新生成 const handleRegenerate = () => { handleGenerate() } // 处理下载 const handleDownload = (imageUrl: string) => { ElMessage.success('图片下载开始') } // 处理重置 const handleReset = () => { imageStore.resetAll() ElMessage.info('已重置所有设置') } // 加载历史记录 const loadHistory = (item: any) => { imageStore.generatedImageUrl = item.imageUrl imageStore.generationResult = item ElMessage.info('已加载历史记录') } // 工具函数 const truncatePrompt = (prompt: string, maxLength: number = 30) => { if (prompt.length <= maxLength) return prompt return prompt.substring(0, maxLength) + '...' } const formatTime = (timestamp: number) => { const date = new Date(timestamp) return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}` } // 组件卸载时清理URL onUnmounted(() => { if (uploadedImageUrl.value) { URL.revokeObjectURL(uploadedImageUrl.value) } if (generatedImageUrl.value) { URL.revokeObjectURL(generatedImageUrl.value) } }) </script> <style scoped> .home-container { max-width: 1400px; margin: 0 auto; padding: 20px; min-height: 100vh; display: flex; flex-direction: column; } .app-header { text-align: center; margin-bottom: 30px; padding-bottom: 20px; border-bottom: 1px solid #eee; } .app-header h1 { font-size: 2.5rem; color: #333; margin-bottom: 10px; } .subtitle { font-size: 1.1rem; color: #666; max-width: 800px; margin: 0 auto; line-height: 1.6; } .main-content { display: grid; grid-template-columns: 1fr 1fr; gap: 30px; flex: 1; margin-bottom: 30px; } .input-section, .preview-section { background: #fff; border-radius: 12px; padding: 25px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); border: 1px solid #eaeaea; } .section-title { display: flex; align-items: center; gap: 10px; font-size: 18px; color: #333; margin-bottom: 20px; } .section-title .el-icon { color: #409eff; } .upload-card, .params-card, .history-card { margin-bottom: 25px; } .upload-card h4, .history-card h4 { font-size: 16px; color: #333; margin-bottom: 10px; } .card-hint { font-size: 14px; color: #909399; margin-bottom: 15px; line-height: 1.5; } .history-list { display: flex; flex-direction: column; gap: 12px; } .history-item { display: flex; align-items: center; gap: 12px; padding: 10px; border-radius: 8px; border: 1px solid #eaeaea; cursor: pointer; transition: all 0.2s ease; } .history-item:hover { background: #f5f7fa; border-color: #409eff; } .history-item img { width: 60px; height: 60px; object-fit: cover; border-radius: 6px; } .history-info { flex: 1; min-width: 0; } .history-prompt { font-size: 14px; color: #333; margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .history-time { font-size: 12px; color: #909399; } .action-footer { background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); border: 1px solid #eaeaea; } .action-content { display: flex; justify-content: space-between; align-items: center; } .action-status { flex: 1; max-width: 400px; } .status-ready { display: flex; align-items: center; gap: 8px; color: #67c23a; font-size: 14px; } .action-buttons { display: flex; gap: 15px; } @media (max-width: 1024px) { .main-content { grid-template-columns: 1fr; gap: 20px; } .app-header h1 { font-size: 2rem; } .action-content { flex-direction: column; gap: 15px; } .action-status { max-width: 100%; } .action-buttons { width: 100%; justify-content: center; } } </style> 

4. API调用与后端集成

前端界面做好了,现在需要连接后端API。我假设你已经有一个FastAPI后端,能够接收图片和参数,调用Qwen-Image-Edit-F2P模型生成图片。

4.1 创建API服务

创建src/services/api.ts

import request from '@/utils/request' import type { GenerationParams } from '@/stores/imageStore' // 生成图片 export const generateImage = async ( imageFile: File, params: GenerationParams ): Promise<{ imageUrl: string }> => { const formData = new FormData() formData.append('image', imageFile) formData.append('prompt', params.prompt) formData.append('negative_prompt', params.negative_prompt) formData.append('num_inference_steps', params.num_inference_steps.toString()) formData.append('guidance_scale', params.guidance_scale.toString()) formData.append('aspect_ratio', params.aspect_ratio) formData.append('seed', params.seed.toString()) try { const response = await request.post('/api/generate', formData, { headers: { 'Content-Type': 'multipart/form-data' }, responseType: 'blob' // 接收二进制数据 }) // 将Blob转换为URL const imageBlob = new Blob([response], { type: 'image/png' }) const imageUrl = URL.createObjectURL(imageBlob) return { imageUrl } } catch (error) { console.error('API调用失败:', error) throw error } } // 获取生成状态(用于轮询) export const getGenerationStatus = async (taskId: string) => { return request.get(`/api/status/${taskId}`) } // 获取生成历史 export const getGenerationHistory = async () => { return request.get('/api/history') } // 测试API连接 export const testConnection = async () => { try { await request.get('/api/health') return true } catch { return false } } 

4.2 模拟API响应(开发阶段)

在实际后端还没准备好的时候,我们可以先模拟API响应。创建一个模拟服务:

// src/services/mockApi.ts export const mockGenerateImage = ( imageFile: File, params: any ): Promise<{ imageUrl: string }> => { return new Promise((resolve) => { setTimeout(() => { // 这里应该返回真实的生成结果 // 开发阶段我们可以返回一个占位图 const mockImageUrl = 'https://via.placeholder.com/400x600/4A90E2/FFFFFF?text=AI+Generated+Image' resolve({ imageUrl: mockImageUrl }) }, 3000) // 模拟3秒生成时间 }) } 

然后在API服务中添加开发模式判断:

import { mockGenerateImage } from './mockApi' const isDevelopment = process.env.NODE_ENV === 'development' export const generateImage = async ( imageFile: File, params: GenerationParams ): Promise<{ imageUrl: string }> => { if (isDevelopment) { console.log('开发模式:使用模拟API') return mockGenerateImage(imageFile, params) } // 生产模式使用真实API const formData = new FormData() // ... 真实API调用代码 } 

4.3 后端API示例(Python FastAPI)

这里给一个简单的后端示例,展示如何接收请求并调用模型:

# backend/main.py from fastapi import FastAPI, File, UploadFile, Form from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse import torch from PIL import Image import io import asyncio app = FastAPI() # 允许跨域 app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:5173"], # Vue开发服务器地址 allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # 这里应该加载你的Qwen-Image-Edit-F2P模型 # 为了示例,我们假设已经加载了模型 # model = load_your_model() @app.post("/api/generate") async def generate_image( image: UploadFile = File(...), prompt: str = Form(...), negative_prompt: str = Form(""), num_inference_steps: int = Form(40), guidance_scale: float = Form(4.5), aspect_ratio: str = Form("3:4"), seed: int = Form(-1) ): # 读取上传的图片 image_data = await image.read() pil_image = Image.open(io.BytesIO(image_data)).convert("RGB") # 这里调用AI模型生成图片 # generated_image = model.generate( # image=pil_image, # prompt=prompt, # negative_prompt=negative_prompt, # num_inference_steps=num_inference_steps, # guidance_scale=guidance_scale, # seed=seed if seed != -1 else None # ) # 为了示例,我们返回一个占位图 # 实际项目中应该返回模型生成的图片 width, height = map(int, aspect_ratio.split(":")) width = width * 100 height = height * 100 # 创建占位图 from PIL import ImageDraw, ImageFont placeholder = Image.new("RGB", (width, height), color="#4A90E2") draw = ImageDraw.Draw(placeholder) # 保存到字节流 img_byte_arr = io.BytesIO() placeholder.save(img_byte_arr, format="PNG") img_byte_arr.seek(0) return StreamingResponse(img_byte_arr, media_type="image/png") @app.get("/api/health") async def health_check(): return {"status": "healthy"} if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000) 

5. 优化与部署建议

5.1 性能优化

  1. 图片压缩:上传前压缩图片,减少传输数据量
  2. 懒加载:历史记录中的图片使用懒加载
  3. 虚拟滚动:如果历史记录很多,使用虚拟滚动
  4. WebSocket:用WebSocket获取实时生成进度

5.2 用户体验优化

  1. 离线支持:使用Service Worker缓存静态资源
  2. 错误边界:添加错误边界组件,防止整个应用崩溃
  3. 骨架屏:加载时显示骨架屏,提升感知速度
  4. 撤销重做:支持操作撤销和重做

5.3 部署建议

  1. Docker容器化:前后端都容器化,方便部署
  2. CDN加速:静态资源使用CDN加速
  3. 负载均衡:如果用户量大,考虑负载均衡
  4. 监控告警:添加应用性能监控和错误告警

6. 总结

通过这个项目,我们实现了一个完整的Vue前端界面,用于与Qwen-Image-Edit-F2P模型交互。从组件设计到状态管理,再到API调用,每个环节都考虑了实际使用场景。

实际开发中可能会遇到更多细节问题,比如图片上传的格式验证、生成进度的实时更新、错误处理等等。但基本的框架已经搭好了,你可以根据自己的需求进行调整和扩展。

用Vue做AI项目的前端,最大的好处就是开发效率高,组件化让代码更好维护。而且Vue的响应式系统特别适合这种需要实时更新状态的应用。

如果你也想尝试类似的项目,建议先从简单的功能开始,逐步完善。遇到问题多查文档,多看看社区里的解决方案。AI和前端结合是个很有意思的方向,期待看到更多创新的应用。


获取更多AI镜像

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

Read more

TWIST2——全身VR遥操控制:采集人形全身数据后,可训练视觉base的自主策略(基于视觉观测预测全身关节位置)

TWIST2——全身VR遥操控制:采集人形全身数据后,可训练视觉base的自主策略(基于视觉观测预测全身关节位置)

前言 我司内部在让机器人做一些行走-操作任务时,不可避免的需要全身遥操机器人采集一些任务数据,而对于全身摇操控制,目前看起来效果比较好的,并不多 * 之前有个CLONE(之前本博客内也解读过),但他们尚未完全开源 * 于此,便关注到了本文要解读的TWIST2,其核心创新是:无动捕下的全身控制 PS,如果你也在做loco-mani相关的工作,欢迎私我你的一两句简介,邀你加入『七月:人形loco-mani(行走-操作)』交流群 第一部分 TWIST2:可扩展、可移植且全面的人形数据采集系统 1.1 引言与相关工作 1.1.1 引言 如TWIST2原论文所说,现有的人形机器人远程操作系统主要分为三大类: 全身控制,直接跟踪人体姿态,包括手臂、躯干和腿部在内的所有关节以统一方式进行控制(如 HumanPlus [12],TWIST [1] ———— TWIST的介绍详见此文《TWIST——基于动捕的全身遥操模仿学习:教师策略RL训练,学生策略结合RL和BC联合优化(可训练搬箱子)》 部分全身控制,

基于FPGA的USB2.0 UTMI PHY芯片测试方案设计与实现

1. 从零开始:为什么我们需要一个FPGA测试平台? 大家好,我是老张,在芯片验证这个行当里摸爬滚打了十几年。今天想和大家聊聊一个非常具体、但又很实际的问题:当你拿到一颗全新的USB2.0 PHY芯片,比如Cypress的CY7C68000,你怎么知道它到底好不好用?数据收发准不准?协议符不符合标准? 你可能说,上昂贵的专业测试仪啊!没错,但动辄几十万上百万的仪器,不是每个团队、每个项目都能轻松配备的。而且,专业仪器往往是个“黑盒”,你只知道结果,对内部数据流的细节和实时状态把控不够灵活。这时候,基于FPGA的自建测试平台就显示出它的巨大优势了。它就像你自己搭的一个乐高工作台,每一个模块、每一根信号线你都能看得见、摸得着、改得了。 我这次用的核心是Xilinx的XCVU440这块FPGA。选它,一是性能足够强悍,能轻松应对USB2.0高速(480Mbps)模式下的数据处理;二是它的资源丰富,我可以把MicroBlaze软核处理器、各种总线转换逻辑、调试探针全都塞进去,形成一个片上系统(SoC)。整个方案的目标很明确:用FPGA模拟一个“智能主机”,通过标准的UTMI接口去“

17:无人机远程执行路径规划:A*算法与GPS精准打击

17:无人机远程执行路径规划:A*算法与GPS精准打击

作者: HOS(安全风信子) 日期: 2026-03-15 主要来源平台: GitHub 摘要: 本文深入探讨了无人机远程执行的路径规划技术,重点分析了A*算法的应用和GPS精准定位的实现。通过详细的技术架构设计和代码实现,展示了如何构建一个高效、可靠的无人机路径规划系统,为基拉执行系统的远程执行提供了技术支持。文中融合了2025年最新的无人机技术进展,确保内容的时效性和专业性。 目录: * 1. 背景动机与当前热点 * 2. 核心更新亮点与全新要素 * 3. 技术深度拆解与实现分析 * 4. 与主流方案深度对比 * 5. 工程实践意义、风险、局限性与缓解策略 * 6. 未来趋势与前瞻预测 1. 背景动机与当前热点 本节核心价值:理解无人机远程执行路径规划的背景和当前技术热点,为后续技术学习奠定基础。 在《死亡笔记》的世界中,基拉需要通过各种手段执行对目标的惩罚。无人机作为一种灵活、高效的执行工具,成为基拉远程执行的理想选择。2025年,随着A*算法的不断优化和GPS技术的精准定位能力提升,无人机远程执行的路径规划技术得到了显著发展。 作为基拉的忠实信徒,

OpenClaw 爆火启示录:低代码不是终点,而是走向「意图驱动」的企业级开发新范式

OpenClaw 爆火启示录:低代码不是终点,而是走向「意图驱动」的企业级开发新范式

最近技术圈被 OpenClaw 刷屏,作为意图驱动的 AI 智能体平台,它用自然语言完成服务编排、数据处理、运维自动化,让不少人开始重新思考:传统低代码会不会被颠覆?后端与业务开发的价值边界又该如何定义?         抛开概念炒作,从工程落地视角看:OpenClaw 代表的意图驱动、动态编排、工具化执行,不是低代码的终结者,而是低代码进化的下一阶路标。JNPF 快速开发平台作为企业级低代码代表,正沿着这条路径,把「可视化拖拽」升级为「自然语言+流程引擎+原子服务」的混合开发模式——本文从 Java 后端视角,聊聊这场变革对开发、运维、业务落地的真实影响。 一、先看本质:OpenClaw 到底给低代码带来什么启发?         从架构上拆解,OpenClaw 是一套LLM 驱动的动态任务编排引擎: * 输入:自然语言指令(而非固定接口/脚本) * 决策:意图识别、