跳到主要内容
极客日志极客日志面向AI+效率的开发者社区
首页博客GitHub 精选镜像AI 生图工具UI配色美学隐私政策关于联系
搜索内容 / 工具 / 仓库 / 镜像...⌘K搜索
注册
博客列表
TypeScriptAI大前端

基于 Vue 的 Qwen-Image-Edit-F2P 前端交互界面实现

本项目展示了如何利用 Vue 3 和 TypeScript 为 Qwen-Image-Edit-F2P 模型构建 Web 交互界面。通过 Element Plus 快速搭建布局,结合 Pinia 管理图片上传与生成状态,实现了人脸照片上传、提示词配置及结果预览的全流程功能。文章涵盖项目初始化、组件封装、Axios 请求处理及前后端联调细节,并提供性能优化建议,帮助开发者高效集成 AI 能力到前端应用中。

禅心发布于 2026/4/5更新于 2026/7/2432 浏览

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

Qwen-Image-Edit-F2P 模型可以根据一张人脸照片生成风格各异的全身照,效果不错。不过,直接在 ComfyUI 里拖拽节点、调整参数对普通用户来说门槛有点高。我们能不能做个简单点的网页界面,让用户上传照片、输入描述,点个按钮就能看到效果?正好 Vue 用起来顺手,今天就来分享一下怎么用 Vue 给这个 AI 模型搭建一个友好的 Web 交互界面。

1. 项目准备与环境搭建

我们的目标是做一个网页,用户可以在上面上传人脸照片,输入文字描述(比如'穿着红色礼服站在巴黎铁塔前'),然后点击生成,就能看到 AI 根据照片和描述生成的全身照。整个项目会分成前端和后端两部分。前端用 Vue 3,后端用 Python 的 FastAPI 来调用模型。我们先从前端开始。

1.1 创建 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。再创建一个 src/utils/request.ts 文件来配置 Axios:

import axios  

 request = axios.({
  : , 
  : , 
})


request...(
   {
    
     config
  },
   {
     .(error)
  }
)


request...(
   {
     response.
  },
   {
    .(, error)
     .(error)
  }
)

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

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

2. 页面布局与组件设计

我想把界面做得简洁明了,主要功能都放在一个页面上。大概分成这几个区域:顶部标题、左侧输入区域(上传图片、输入描述)、右侧预览区域(显示原图和生成结果)、底部操作区域。

2.1 创建主页面组件

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

<template>
  <div class="home-container">
    <!-- 顶部标题 -->
    <header class="app-header">
      <h1>AI 图像生成编辑器</h1>
      <p class="subtitle">基于 Qwen-Image-Edit-F2P 模型,上传人脸照片,生成风格各异的全身照</p>
    </header>

    <!-- 主要内容区域 -->
    <main class="main-content">
      <div class="input-section">
        <!-- 这里放输入相关的组件 -->
      </div>
      <div class="preview-section">
        <!-- 这里放预览相关的组件 -->
      </div>
    </main>

    <!-- 底部操作区域 -->
    <footer class="action-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 class="image-upload">
    <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 class="preview-overlay">
          <el-icon :size="30"><Upload /></el-icon>
          <p>点击或拖拽更换图片</p>
        </div>
      </div>
      <!-- 没有图片时显示上传提示 -->
      <div v-else>
        <el-icon :size="60" class="upload-icon"><Upload /></el-icon>
        <p class="prompt-text">点击或拖拽上传人脸照片</p>
        <p class="hint-text">建议使用正面清晰的人脸照片,背景简单为佳</p>
      </div>
      <!-- 隐藏的文件输入 -->
      <input ref="fileInput" type="file" accept="image/*" @change="handleFileChange" />
    </div>

    <!-- 图片信息显示 -->
    <div v-if="imageFile" class="image-info">
      <div class="info-item">
        <span class="label">文件名:</span>
        <span class="value">{{ imageFile.name }}</span>
      </div>
      <div class="info-item">
        <span class="label">大小:</span>
        <span class="value">{{ formatFileSize(imageFile.size) }}</span>
      </div>
      <div class="info-item">
        <span class="label">类型:</span>
        <span class="value">{{ 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: #666;
  word-break: break-all;
}
.error-alert {
  margin-top: 15px;
}
</style>
2.3 参数设置组件

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

创建 src/components/ParameterPanel.vue:

<template>
  <div class="parameter-panel">
    <h3 class="panel-title">生成参数设置</h3>
    <div class="parameter-group">
      <div class="parameter-item">
        <div class="parameter-header">
          <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 class="parameter-hint">试试这些描述:穿着白色婚纱在花海中、穿着职业装在城市街道、穿着汉服在古建筑前</div>
      </div>

      <div class="parameter-item">
        <div class="parameter-header">
          <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 class="parameter-grid">
        <div class="parameter-item">
          <div class="parameter-header">
            <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 class="parameter-hint">建议值:30-40 步</div>
        </div>

        <div class="parameter-item">
          <div class="parameter-header">
            <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 class="parameter-hint">建议值:3.5-5.0</div>
        </div>

        <div class="parameter-item">
          <div class="parameter-header">
            <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 class="parameter-item">
          <div class="parameter-header">
            <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 class="parameter-hint">-1 表示每次随机生成</div>
        </div>
      </div>
    </div>

    <div class="preset-buttons">
      <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 class="image-preview-container">
    <div class="preview-header">
      <h3>图片预览</h3>
      <div class="preview-actions">
        <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 class="preview-content">
      <!-- 生成中显示加载状态 -->
      <div v-if="isGenerating" class="generating-state">
        <div class="loading-spinner">
          <el-icon :size="50" class="loading-icon"><Loading /></el-icon>
        </div>
        <p class="loading-text">AI 正在生成图片,请稍候...</p>
        <p class="loading-hint">这可能需要 30-60 秒,取决于图片复杂度和参数设置</p>
        <el-progress
          :percentage="progress"
          :indeterminate="progress === 0"
          :stroke-width="6"
        />
      </div>

      <!-- 生成完成显示对比 -->
      <div v-else-if="generatedImage" class="comparison-view">
        <div class="comparison-item">
          <div class="comparison-label">原图</div>
          <div class="image-wrapper">
            <img :src="sourceImage" alt="原图" />
          </div>
          <div class="image-info">
            <span>输入的人脸照片</span>
          </div>
        </div>
        <div class="comparison-arrow">
          <el-icon :size="30"><Right /></el-icon>
        </div>
        <div class="comparison-item">
          <div class="comparison-label">生成结果</div>
          <div class="image-wrapper">
            <img :src="generatedImage" alt="生成结果" />
          </div>
          <div class="image-info">
            <span>AI 生成的全身照</span>
            <span class="generation-time">生成耗时:{{ generationTime }}秒</span>
          </div>
        </div>
      </div>

      <!-- 等待生成状态 -->
      <div v-else class="waiting-state">
        <div class="waiting-icon">
          <el-icon :size="80" color="#c0c4cc"><Picture /></el-icon>
        </div>
        <p class="waiting-text">上传图片并设置参数后,点击生成按钮开始创作</p>
        <p class="waiting-hint">AI 将根据你的人脸特征和描述生成全新的全身照</p>
      </div>
    </div>

    <!-- 生成信息 -->
    <div v-if="generatedImage && generationInfo" class="generation-info">
      <h4>生成信息</h4>
      <div class="info-grid">
        <div class="info-item">
          <span class="info-label">提示词:</span>
          <span class="info-value">{{ generationInfo.prompt }}</span>
        </div>
        <div class="info-item">
          <span class="info-label">参数:</span>
          <span class="info-value">
            步数 {{ generationInfo.num_inference_steps }} · 引导 {{ generationInfo.guidance_scale }} · 尺寸 {{ generationInfo.aspect_ratio }}
          </span>
        </div>
        <div class="info-item">
          <span class="info-label">种子:</span>
          <span class="info-value">{{ 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: #666;
  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)
  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)
    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 class="home-container">
    <header class="app-header">
      <h1>AI 图像生成编辑器</h1>
      <p class="subtitle">基于 Qwen-Image-Edit-F2P 模型,上传人脸照片,生成风格各异的全身照</p>
    </header>

    <main class="main-content">
      <div class="input-section">
        <div class="section-title">
          <el-icon><Upload /></el-icon>
          <span>上传图片与参数设置</span>
        </div>
        <div class="upload-card">
          <h4>上传人脸照片</h4>
          <p class="card-hint">请上传正面清晰的人脸照片,AI 将根据面部特征生成全身照</p>
          <ImageUpload v-model="uploadedImage" />
        </div>
        <div class="params-card">
          <ParameterPanel v-model:params="generationParams" />
        </div>
        <div v-if="generationHistory.length > 0" class="history-card">
          <h4>生成历史</h4>
          <div class="history-list">
            <div
              v-for="(item, index) in generationHistory.slice(0, 3)"
              :key="index"
              @click="loadHistory(item)"
              class="history-item"
            >
              <img :src="item.imageUrl" alt="历史图片" />
              <div class="history-info">
                <p class="history-prompt">{{ truncatePrompt(item.params.prompt) }}</p>
                <p class="history-time">{{ formatTime(item.timestamp) }}</p>
              </div>
            </div>
          </div>
        </div>
      </div>

      <div class="preview-section">
        <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 class="action-footer">
      <div class="action-content">
        <div class="action-status">
          <el-alert
            v-if="!uploadedImage"
            title="请先上传人脸照片"
            type="warning"
            :closable="false"
            show-icon
          />
          <div v-else class="status-ready">
            <el-icon color="#67c23a"><SuccessFilled /></el-icon>
            <span>已准备就绪,可以开始生成</span>
          </div>
        </div>
        <div class="action-buttons">
          <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()
    const progressInterval = setInterval(() => {
      if (imageStore.generationProgress < 90) {
        imageStore.updateGenerationProgress(imageStore.generationProgress + 10)
      }
    }, 1000)

    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')}`
}

onUnmounted(() => {
  if (uploadedImageUrl.value) {
    URL.revokeObjectURL(uploadedImageUrl.value)
  }
  if (generatedImageUrl.value) {
    URL.revokeObjectURL(generatedImageUrl.value)
  }
})
</script>

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',
    })

    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')
}

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)
  })
}

然后在 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"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

@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

    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 和前端结合是个很有意思的方向,期待看到更多创新的应用。

目录

  1. 使用 Vue 构建 Qwen-Image-Edit-F2P 前端交互界面
  2. 1. 项目准备与环境搭建
  3. 1.1 创建 Vue 项目
  4. 1.2 安装必要的 UI 库
  5. 1.3 安装 HTTP 请求库
  6. 2. 页面布局与组件设计
  7. 2.1 创建主页面组件
  8. 2.2 图片上传组件
  9. 2.3 参数设置组件
  10. 2.4 图片预览组件
  11. 3. 整合页面与状态管理
  12. 3.1 创建状态管理
  13. 3.2 完善主页面
  14. 4. API 调用与后端集成
  15. 4.1 创建 API 服务
  16. 4.2 模拟 API 响应(开发阶段)
  17. 4.3 后端 API 示例(Python FastAPI)
  18. backend/main.py
  19. 5. 优化与部署建议
  20. 5.1 性能优化
  21. 5.2 用户体验优化
  22. 5.3 部署建议
  23. 6. 总结
  • 免费图片AI生成工具免费生成了解详情
  • Magick API 一键接入全球大模型注册送1000万token查看
  • 免费图片视频在线生成30秒,将你的创意变成现实开始设计
  • X/Twitter免费视频下载器免登陆无限额度免费视频解析下载了解详情
  • 100+免费在线小游戏爽一把
极客日志微信公众号二维码

微信扫一扫,关注极客日志

微信公众号「极客日志V2」,在微信中扫描左侧二维码关注。展示文案:极客日志V2 zeeklog

更多推荐文章

查看全部
  • Go 语言实现复杂密码生成器算法(附源码)
  • 2026 年 3 月 18 日人工智能领域要闻汇总
  • 从「填涂颜色」看漫水填充(Flood Fill)算法与逆向思维
  • Python 基于关键词爬取小红书笔记数据脚本
  • 基于 Leaflet Trackplayer 的高速公路轨迹 WebGIS 可视化实践
  • 国家数据局官宣:AI 核心术语 Token 中文译名为「词元」
  • 联想一体机安装 Linux 后报错 Error 1962 解决方案
  • SBUS 协议详解:从原理到嵌入式实战
  • Dockerfile 核心指令详解与实战指南
  • 基于 STM32 的智能家居环境监测系统设计
  • 链表经典 OJ 题目解析与 C 语言实现
  • 二叉树算法实战:美国血统重建与深度宽度计算
  • 无线蜂窝网络:原理、架构与代际演进
  • Java 解析磁力链 (Magnet URI) 完整指南
  • Notepad++ 直连 SAP SE80 提升 BSP/WebClient UI 视图维护效率
  • C++ 数组模拟单双向链表实现与优化
  • AI 大模型发展三波浪潮:探讨中国如何在人工智能新浪潮中突破与领先
  • SpringBoot+Vue 基于 Web 的咖啡点单系统设计与实现
  • Ubuntu 本地化部署 DeepSeek R1 模型:Ollama+Docker
  • 取消 ChatGPT 运动蔓延、军方合作引争议:AI 伦理与具身智能产业化

相关免费在线工具

  • RSA密钥对生成器

    生成新的随机RSA私钥和公钥pem证书。 在线工具,RSA密钥对生成器在线工具,online

  • Mermaid 预览与可视化编辑

    基于 Mermaid.js 实时预览流程图、时序图等图表,支持源码编辑与即时渲染。 在线工具,Mermaid 预览与可视化编辑在线工具,online

  • 随机西班牙地址生成器

    随机生成西班牙地址(支持马德里、加泰罗尼亚、安达卢西亚、瓦伦西亚筛选),支持数量快捷选择、显示全部与下载。 在线工具,随机西班牙地址生成器在线工具,online

  • Base64 字符串编码/解码

    将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online

  • Base64 文件转换器

    将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online

  • Markdown转HTML

    将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online