跳到主要内容
基于 Vue 的 Qwen-Image-Edit-F2P 前端交互界面实现 | 极客日志
TypeScript AI 大前端
基于 Vue 的 Qwen-Image-Edit-F2P 前端交互界面实现 本项目展示了如何利用 Vue 3 和 TypeScript 为 Qwen-Image-Edit-F2P 模型构建 Web 交互界面。通过 Element Plus 快速搭建布局,结合 Pinia 管理图片上传与生成状态,实现了人脸照片上传、提示词配置及结果预览的全流程功能。文章涵盖项目初始化、组件封装、Axios 请求处理及前后端联调细节,并提供性能优化建议,帮助开发者高效集成 AI 能力到前端应用中。
禅心 发布于 2026/4/5 更新于 2026/4/25 1 浏览使用 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'
timeout
300000
interceptors
request
use
(config ) =>
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 服务 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 响应。创建一个模拟服务:
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 )
})
}
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)
}
const formData = new FormData ()
}
4.3 后端 API 示例(Python FastAPI) 这里给一个简单的后端示例,展示如何接收请求并调用模型:
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" )
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 性能优化
图片压缩 :上传前压缩图片,减少传输数据量
懒加载 :历史记录中的图片使用懒加载
虚拟滚动 :如果历史记录很多,使用虚拟滚动
WebSocket :用 WebSocket 获取实时生成进度
5.2 用户体验优化
离线支持 :使用 Service Worker 缓存静态资源
错误边界 :添加错误边界组件,防止整个应用崩溃
骨架屏 :加载时显示骨架屏,提升感知速度
撤销重做 :支持操作撤销和重做
5.3 部署建议
Docker 容器化 :前后端都容器化,方便部署
CDN 加速 :静态资源使用 CDN 加速
负载均衡 :如果用户量大,考虑负载均衡
监控告警 :添加应用性能监控和错误告警
6. 总结 通过这个项目,我们实现了一个完整的 Vue 前端界面,用于与 Qwen-Image-Edit-F2P 模型交互。从组件设计到状态管理,再到 API 调用,每个环节都考虑了实际使用场景。
实际开发中可能会遇到更多细节问题,比如图片上传的格式验证、生成进度的实时更新、错误处理等等。但基本的框架已经搭好了,你可以根据自己的需求进行调整和扩展。
用 Vue 做 AI 项目的前端,最大的好处就是开发效率高,组件化让代码更好维护。而且 Vue 的响应式系统特别适合这种需要实时更新状态的应用。
如果你也想尝试类似的项目,建议先从简单的功能开始,逐步完善。遇到问题多查文档,多看看社区里的解决方案。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