Web开发全栈实践:构建一个完整的Qwen-Image-Edit-F2P在线生成网站
Web开发全栈实践:构建一个完整的Qwen-Image-Edit-F2P在线生成网站
最近有不少朋友问我,学了前端Vue.js和后端Node.js,怎么把它们串起来做个能实际跑起来的项目?光看教程总觉得差点意思,最好能有个从零到一、功能完整的案例。
正好,图像编辑AI现在挺火的,如果能自己搭个网站,让用户上传图片、在线编辑、还能分享作品,这不就是个绝佳的全栈练手项目吗?今天我就带你走一遍这个流程,我们用Vue 3做前端界面,Node.js + Express搭后台服务,MySQL存数据,最后接上星图GPU平台上的Qwen-Image-Edit-F2P模型API,做一个功能齐全的在线AI图片编辑网站。
整个项目会涵盖用户系统、图片上传与生成、作品管理、社区分享这些核心模块。你不仅能巩固全栈技能,还能亲手打造一个能实际提供服务的应用。代码我都会给出来,你可以跟着一步步实现,也可以根据自己的想法调整。
1. 项目整体设计与技术栈选型
在动手写代码之前,我们先花几分钟把整个项目要做什么、用什么技术来做,心里有个谱。这样后面开发起来才不会东一榔头西一棒子。
1.1 我们要做一个什么样的网站?
想象一下,用户来到这个网站,可以完成以下几件事:
- 注册登录:有个自己的账号,能看到自己的创作历史。
- 上传图片:把本地照片传上来,作为AI编辑的原材料。
- 描述编辑:用文字告诉AI我想怎么改这张图,比如“把背景换成海滩日落”、“给人物穿上西装”。
- 生成新图:网站调用后端的AI模型API,根据我的描述生成编辑后的新图片。
- 管理作品:我生成的所有图片都能在个人中心看到,可以下载或者删除。
- 分享交流:我可以把满意的作品发布到社区广场,大家都能看到、点赞。
这就是我们网站的核心功能闭环。从技术上看,它需要前端界面、后端逻辑、数据库存储和外部AI服务四部分紧密配合。
1.2 技术栈怎么选?
基于上面这些需求,我选择了下面这套技术组合,原因很简单:流行、成熟、资料多,适合全栈学习。
-
前端:Vue 3 + Vite + Element Plus
- Vue 3:现在主流的前端框架,组合式API写起来很灵活。
- Vite:构建工具,启动和热更新速度飞快,开发体验好。
- Element Plus:基于Vue 3的UI组件库,按钮、表单、弹窗这些基础组件直接用,能省下大量写样式的时间。
-
后端:Node.js + Express
- Node.js:用JavaScript写后端,前后端语言统一,学习成本低。
- Express:最流行的Node.js Web框架,轻量且灵活,用来搭建API接口非常顺手。
-
数据库:MySQL
- 关系型数据库,结构清晰,适合存储用户、图片记录这种关联性强的数据。用起来也不复杂。
-
AI能力:Qwen-Image-Edit-F2P API
- 这是我们项目的“智能引擎”。它部署在星图GPU平台上,提供了强大的图像理解和编辑能力。我们后端的工作之一,就是安全、高效地调用这个API。
-
其他工具
- Axios:前后端都用它来发HTTP请求。
- Multer:Node.js中间件,专门处理前端上传的文件。
- jsonwebtoken (JWT):用来做用户登录状态的认证,比Session更适合前后端分离的项目。
- Dotenv:管理环境变量,比如数据库密码、API密钥这些敏感信息,不会硬编码在代码里。
技术栈确定后,我们心里就有了一张清晰的“施工图”。接下来,就从搭建开发环境开始。
2. 一步步搭建开发环境与项目骨架
开发环境是咱们的“工作台”,得先准备好。这里我假设你已经安装了Node.js(建议16.x或以上版本)和一个趁手的代码编辑器(比如VS Code)。
2.1 创建前后端项目
我们采用前后端分离的模式,所以需要创建两个独立的项目文件夹。
前端项目创建: 打开终端,执行以下命令。我们用Vite官方模板来快速初始化一个Vue 3项目。
# 创建一个名为 `ai-image-editor-frontend` 的前端项目
npm create vue@latest ai-image-editor-frontend
# 创建过程中,Vite会询问你配置,除了TypeScript和JSX按需选择外,
# 建议勾选上 Router(路由) 和 Pinia(状态管理),这对我们项目有用。
# 创建完成后,进入目录并安装依赖
cd ai-image-editor-frontend
npm install
# 额外安装我们需要的UI库和请求库
npm install element-plus axios
npm install --save-dev @element-plus/icons-vue
后端项目创建: 在另一个终端窗口或目录下,创建后端项目。
# 创建一个名为 `ai-image-editor-backend` 的文件夹并初始化
mkdir ai-image-editor-backend
cd ai-image-editor-backend
npm init -y
# 安装后端需要的核心依赖
npm install express mysql2 dotenv jsonwebtoken bcryptjs multer axios cors
# 安装开发依赖,用于代码热更新
npm install --save-dev nodemon
安装完成后,记得修改后端 package.json 中的 scripts 部分,方便我们启动服务:
{
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
}
}
2.2 设计数据库表结构
在写代码之前,先设计好数据库表。我们在MySQL里创建一个名为 ai_image_editor 的数据库,然后设计三张核心表:
- users (用户表):存用户基本信息。
- images (图片记录表):存每一次图片生成的任务记录。
- community_posts (社区帖子表):存用户分享到社区的作品。
你可以用下面的SQL语句在MySQL客户端(如MySQL Workbench或命令行)中执行:
CREATE DATABASE IF NOT EXISTS ai_image_editor;
USE ai_image_editor;
-- 用户表
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL, -- 存储加密后的密码
avatar_url VARCHAR(500), -- 头像链接
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 图片生成记录表
CREATE TABLE images (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
original_image_url VARCHAR(500) NOT NULL, -- 原图存储路径或URL
edited_image_url VARCHAR(500), -- 编辑后图片的路径或URL
prompt TEXT NOT NULL, -- 用户输入的编辑描述
status ENUM('pending', 'processing', 'completed', 'failed') DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- 社区分享帖子表
CREATE TABLE community_posts (
id INT PRIMARY KEY AUTO_INCREMENT,
image_id INT NOT NULL,
user_id INT NOT NULL,
title VARCHAR(200),
description TEXT,
likes INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (image_id) REFERENCES images(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
表结构设计好,数据的“家”就有了。接下来,我们开始编写后端的逻辑,让这些表能活起来。
3. 后端核心:构建Node.js API服务
后端是整个应用的大脑,负责处理业务逻辑、操作数据库、调用AI API。我们在 ai-image-editor-backend 目录下创建一个 server.js 作为入口文件。
3.1 基础服务器与数据库连接
首先,搭建一个最简单的Express服务器,并连接上MySQL数据库。
// server.js
const express = require('express');
const mysql = require('mysql2/promise'); // 使用promise版本的mysql2
const cors = require('cors');
require('dotenv').config(); // 加载环境变量
const app = express();
const PORT = process.env.PORT || 3000;
// 中间件
app.use(cors()); // 允许前端跨域请求
app.use(express.json()); // 解析JSON格式的请求体
app.use('/uploads', express.static('uploads')); // 设置静态文件目录,用于访问上传的图片
// 数据库连接池(使用连接池提升性能)
const pool = mysql.createPool({
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME || 'ai_image_editor',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
// 一个简单的测试接口
app.get('/api/health', (req, res) => {
res.json({ status: 'OK', message: '后端服务运行正常' });
});
// 启动服务器
app.listen(PORT, () => {
console.log(`后端服务器运行在 http://localhost:${PORT}`);
});
在同一目录下创建一个 .env 文件,存放你的数据库密码等敏感信息(记得把这个文件加入 .gitignore,不要提交到代码仓库)。
# .env
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=your_mysql_password_here
DB_NAME=ai_image_editor
JWT_SECRET=your_super_secret_jwt_key_here
3.2 实现用户认证模块
用户系统是基础。我们需要注册和登录接口,登录成功后颁发JWT令牌。
// 在server.js中继续添加
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const JWT_SECRET = process.env.JWT_SECRET;
// 用户注册接口
app.post('/api/register', async (req, res) => {
const { username, email, password } = req.body;
try {
// 1. 检查用户是否已存在
const [users] = await pool.execute('SELECT id FROM users WHERE email = ? OR username = ?', [email, username]);
if (users.length > 0) {
return res.status(400).json({ error: '用户名或邮箱已被注册' });
}
// 2. 对密码进行哈希加密
const passwordHash = await bcrypt.hash(password, 10);
// 3. 插入新用户
const [result] = await pool.execute(
'INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?)',
[username, email, passwordHash]
);
res.status(201).json({ message: '注册成功', userId: result.insertId });
} catch (error) {
console.error('注册错误:', error);
res.status(500).json({ error: '服务器内部错误' });
}
});
// 用户登录接口
app.post('/api/login', async (req, res) => {
const { email, password } = req.body;
try {
// 1. 根据邮箱查找用户
const [users] = await pool.execute('SELECT * FROM users WHERE email = ?', [email]);
if (users.length === 0) {
return res.status(401).json({ error: '邮箱或密码错误' });
}
const user = users[0];
// 2. 验证密码
const isPasswordValid = await bcrypt.compare(password, user.password_hash);
if (!isPasswordValid) {
return res.status(401).json({ error: '邮箱或密码错误' });
}
// 3. 生成JWT令牌(有效期为7天)
const token = jwt.sign(
{ userId: user.id, username: user.username },
JWT_SECRET,
{ expiresIn: '7d' }
);
// 4. 返回用户信息(不包含密码)和token
const { password_hash, ...userInfo } = user;
res.json({ message: '登录成功', token, user: userInfo });
} catch (error) {
console.error('登录错误:', error);
res.status(500).json({ error: '服务器内部错误' });
}
});
// 一个需要认证的示例接口(获取当前用户信息)
app.get('/api/profile', authenticateToken, async (req, res) => {
// authenticateToken 是一个我们接下来要写的中间件,用于验证JWT
res.json({ user: req.user });
});
// JWT认证中间件
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // 格式:Bearer <token>
if (!token) {
return res.status(401).json({ error: '未提供访问令牌' });
}
jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ error: '令牌无效或已过期' });
}
req.user = user; // 将解码后的用户信息挂载到request对象上
next();
});
}
3.3 图片上传与AI编辑任务处理
这是项目的核心功能。用户上传图片,我们后端接收文件,调用AI API,并管理任务状态。
首先,安装并配置 multer 来处理文件上传。
// 在server.js中继续添加
const multer = require('multer');
const path = require('path');
const fs = require('fs');
// 确保上传目录存在
const uploadDir = 'uploads/originals';
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
// 配置multer存储引擎
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, uploadDir);
},
filename: function (req, file, cb) {
// 生成唯一文件名:时间戳+随机数+原扩展名
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname));
}
});
const upload = multer({ storage: storage });
// 图片上传与创建编辑任务接口
app.post('/api/images/generate', authenticateToken, upload.single('image'), async (req, res) => {
const { prompt } = req.body;
const userId = req.user.userId;
const originalImagePath = req.file.path; // multer保存的文件路径
if (!prompt) {
return res.status(400).json({ error: '请输入编辑描述' });
}
try {
// 1. 在数据库中创建一条待处理的任务记录
const [result] = await pool.execute(
'INSERT INTO images (user_id, original_image_url, prompt, status) VALUES (?, ?, ?, ?)',
[userId, `/uploads/originals/${path.basename(originalImagePath)}`, prompt, 'pending']
);
const taskId = result.insertId;
// 2. 立即返回任务ID,让前端可以轮询状态
res.status(202).json({
message: '任务已提交,正在处理中',
taskId: taskId
});
// 3. (关键)异步调用AI处理函数,不阻塞请求响应
processImageEditTask(taskId, originalImagePath, prompt).catch(console.error);
} catch (error) {
console.error('创建任务失败:', error);
res.status(500).json({ error: '创建编辑任务失败' });
}
});
// 异步处理图片编辑任务的函数
async function processImageEditTask(taskId, imagePath, prompt) {
const axios = require('axios');
try {
// 1. 更新任务状态为“处理中”
await pool.execute('UPDATE images SET status = ? WHERE id = ?', ['processing', taskId]);
// 2. 准备调用Qwen-Image-Edit-F2P API
// 注意:这里需要替换成你在星图平台获取的真实API端点、密钥和请求格式
const AI_API_URL = 'https://your-gpu-platform-endpoint.com/v1/images/edits'; // 示例URL
const AI_API_KEY = process.env.AI_API_KEY;
// 3. 构建API请求
// 通常需要将图片以multipart/form-data或base64形式发送
const formData = new FormData();
// 这里需要根据具体API要求调整,例如读取图片文件并附加
const imageBuffer = fs.readFileSync(imagePath);
// 假设API接受base64图片和文本prompt
const requestBody = {
image: imageBuffer.toString('base64'),
prompt: prompt,
// 可能还有其他参数,如size, n等
};
const response = await axios.post(AI_API_URL, requestBody, {
headers: {
'Authorization': `Bearer ${AI_API_KEY}`,
'Content-Type': 'application/json',
},
timeout: 120000 // 设置长超时,因为AI生成可能较慢
});
// 4. 假设API返回编辑后图片的URL或base64数据
const editedImageData = response.data.data[0].url; // 根据实际API响应结构调整
const editedImageFilename = `edited-${taskId}.png`;
const editedImagePath = path.join('uploads/edited', editedImageFilename);
// 5. 将AI返回的图片保存到本地(如果是URL则需要下载)
// 这里简化处理,假设返回的是可访问的URL,我们直接存URL到数据库
// 实际项目中可能需要下载图片文件到自己的服务器或对象存储
// 6. 更新数据库,标记任务完成,并保存结果URL
await pool.execute(
'UPDATE images SET edited_image_url = ?, status = ? WHERE id = ?',
[editedImageData, 'completed', taskId]
);
console.log(`任务 ${taskId} 处理完成`);
} catch (error) {
console.error(`处理任务 ${taskId} 失败:`, error);
// 更新任务状态为失败
await pool.execute('UPDATE images SET status = ? WHERE id = ?', ['failed', taskId]);
}
}
// 查询任务状态和结果的接口
app.get('/api/images/task/:taskId', authenticateToken, async (req, res) => {
const { taskId } = req.params;
const userId = req.user.userId;
try {
const [tasks] = await pool.execute(
'SELECT id, original_image_url, edited_image_url, prompt, status, created_at FROM images WHERE id = ? AND user_id = ?',
[taskId, userId]
);
if (tasks.length === 0) {
return res.status(404).json({ error: '任务不存在或无权访问' });
}
res.json({ task: tasks[0] });
} catch (error) {
console.error('查询任务失败:', error);
res.status(500).json({ error: '查询失败' });
}
});
// 获取用户历史作品列表
app.get('/api/images/my', authenticateToken, async (req, res) => {
const userId = req.user.userId;
const { page = 1, limit = 10 } = req.query;
const offset = (page - 1) * limit;
try {
const [images] = await pool.execute(
'SELECT id, original_image_url, edited_image_url, prompt, status, created_at FROM images WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?',
[userId, parseInt(limit), offset]
);
const [countResult] = await pool.execute('SELECT COUNT(*) as total FROM images WHERE user_id = ?', [userId]);
res.json({
images,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total: countResult[0].total
}
});
} catch (error) {
console.error('获取作品列表失败:', error);
res.status(500).json({ error: '获取失败' });
}
});
后端的主要逻辑就搭建好了。它提供了用户认证、图片上传、异步AI任务处理和数据查询等一系列API。现在,我们需要一个好看又好用的前端界面来调用这些接口。
4. 前端实现:用Vue 3构建用户界面
前端项目我们之前已经用Vite创建好了。现在我们来编写几个核心页面组件。
4.1 配置路由与状态管理
首先,设置一下前端路由和全局状态。修改 src/router/index.js 和创建 src/stores/auth.js。
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView
},
{
path: '/login',
name: 'login',
component: () => import('../views/LoginView.vue')
},
{
path: '/register',
name: 'register',
component: () => import('../views/RegisterView.vue')
},
{
path: '/generate',
name: 'generate',
component: () => import('../views/GenerateView.vue'),
meta: { requiresAuth: true } // 需要登录才能访问
},
{
path: '/my-works',
name: 'my-works',
component: () => import('../views/MyWorksView.vue'),
meta: { requiresAuth: true }
},
{
path: '/community',
name: 'community',
component: () => import('../views/CommunityView.vue')
}
]
})
// 路由守卫:检查是否需要登录
router.beforeEach((to, from, next) => {
const isAuthenticated = !!localStorage.getItem('auth_token'); // 简单检查token是否存在
if (to.meta.requiresAuth && !isAuthenticated) {
next({ name: 'login' });
} else {
next();
}
})
export default router
// src/stores/auth.js
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import axios from 'axios'
// 创建axios实例,统一设置baseURL和请求头
const api = axios.create({
baseURL: 'http://localhost:3000/api', // 指向你的后端地址
})
// 请求拦截器:自动添加JWT Token
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('auth_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器:统一处理错误,如Token过期
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response && error.response.status === 401) {
// Token无效或过期,清空本地存储,跳转到登录页
localStorage.removeItem('auth_token')
localStorage.removeItem('user_info')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('auth_token') || '')
const user = ref(JSON.parse(localStorage.getItem('user_info') || 'null'))
const isAuthenticated = computed(() => !!token.value)
function setAuth(userData, authToken) {
token.value = authToken
user.value = userData
localStorage.setItem('auth_token', authToken)
localStorage.setItem('user_info', JSON.stringify(userData))
// 将token设置到axios默认头
api.defaults.headers.common['Authorization'] = `Bearer ${authToken}`
}
function clearAuth() {
token.value = ''
user.value = null
localStorage.removeItem('auth_token')
localStorage.removeItem('user_info')
delete api.defaults.headers.common['Authorization']
}
// 登录动作
async function login(credentials) {
try {
const response = await api.post('/login', credentials)
setAuth(response.data.user, response.data.token)
return { success: true }
} catch (error) {
return { success: false, message: error.response?.data?.error || '登录失败' }
}
}
// 注册动作
async function register(userInfo) {
try {
const response = await api.post('/register', userInfo)
return { success: true, data: response.data }
} catch (error) {
return { success: false, message: error.response?.data?.error || '注册失败' }
}
}
return { token, user, isAuthenticated, setAuth, clearAuth, login, register, api }
})
4.2 核心页面:图片生成器
这是最重要的功能页面。用户在这里上传图片、输入描述、提交任务并查看结果。
<!-- src/views/GenerateView.vue -->
<template>
<div class="generate-container">
<el-page-header @back="goBack" title="返回">
<template #content>
<span class="text-large font-600 mr-3"> AI图片编辑生成器 </span>
</template>
</el-page-header>
<el-row :gutter="20" class="mt-6">
<!-- 左侧:上传和输入区 -->
<el-col :span="12">
<el-card class="input-card">
<template #header>
<div class="card-header">
<span>第一步:上传图片</span>
</div>
</template>
<el-upload
class="upload-demo"
drag
action="#" <!-- 我们自定义上传逻辑,所以action设为# -->
:auto-upload="false"
:on-change="handleImageChange"
:show-file-list="false"
accept="image/*"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">
拖拽图片到此处,或 <em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
支持JPG、PNG格式,文件大小不超过5MB
</div>
</template>
</el-upload>
<div v-if="previewImage" class="preview-area mt-4">
<h4>原图预览:</h4>
<el-image :src="previewImage" fit="contain" style="max-height: 200px;" />
</div>
</el-card>
<el-card class="input-card mt-4">
<template #header>
<div class="card-header">
<span>第二步:描述你想怎么编辑</span>
</div>
</template>
<el-input
v-model="prompt"
type="textarea"
:rows="4"
placeholder="用文字详细描述你希望如何编辑这张图片。例如:'把背景换成雪山湖泊'、'给这只猫戴上墨镜和帽子'、'将照片风格转换为水彩画'..."
maxlength="500"
show-word-limit
/>
<div class="mt-4">
<el-button type="primary" :loading="isSubmitting" @click="submitTask" :disabled="!canSubmit">
{{ isSubmitting ? '生成中...' : '开始AI编辑' }}
</el-button>
<el-button @click="resetForm">清空重来</el-button>
</div>
</el-card>
</el-col>
<!-- 右侧:任务状态和结果展示区 -->
<el-col :span="12">
<el-card class="result-card">
<template #header>
<div class="card-header">
<span>生成状态与结果</span>
</div>
</template>
<div v-if="currentTask">
<el-steps :active="stepActive" align-center class="mb-6">
<el-step title="任务提交" :description="formatTime(currentTask.created_at)" />
<el-step title="AI处理中" description="模型正在理解并编辑图片" />
<el-step title="生成完成" :description="currentTask.status === 'completed' ? '快来查看效果吧!' : ''" />
</el-steps>
<div v-if="currentTask.status === 'processing'" class="processing-hint">
<el-icon class="is-loading"><loading /></el-icon>
<span>AI正在努力创作中,这可能需要几十秒到一分钟,请耐心等待...</span>
</div>
<div v-if="currentTask.status === 'completed' && currentTask.edited_image_url" class="result-area">
<h4>编辑结果:</h4>
<el-image :src="currentTask.edited_image_url" fit="contain" style="max-height: 300px;" class="result-image" />
<div class="action-buttons mt-4">
<el-button type="success" @click="downloadImage(currentTask.edited_image_url)">
<el-icon><download /></el-icon>下载图片
</el-button>
<el-button type="info" @click="shareToCommunity(currentTask.id)">分享到社区</el-button>
</div>
</div>
<div v-if="currentTask.status === 'failed'" class="error-area">
<el-alert title="生成失败" type="error" :closable="false">
<p>很抱歉,AI处理过程中出现了问题。可能是描述过于复杂或服务暂时不稳定。</p>
<p>建议:尝试简化你的描述,或者稍后再试。</p>
</el-alert>
</div>
</div>
<div v-else class="empty-state">
<el-empty description="提交任务后,这里会显示生成状态和最终结果" />
</div>
</el-card>
<!-- 历史任务列表(折叠面板) -->
<el-card class="mt-4">
<template #header>
<div class="card-header">
<span>最近的任务</span>
<el-button text @click="loadMyWorks">刷新</el-button>
</div>
</template>
<el-collapse v-if="recentTasks.length > 0">
<el-collapse-item v-for="task in recentTasks" :key="task.id" :title="`任务#${task.id}: ${task.prompt.substring(0, 30)}...`">
<div class="task-item">
<p><strong>状态:</strong><el-tag :type="getStatusTagType(task.status)">{{ task.status }}</el-tag></p>
<p><strong>描述:</strong>{{ task.prompt }}</p>
<p><strong>时间:</strong>{{ formatTime(task.created_at) }}</p>
<el-button v-if="task.edited_image_url" size="small" @click="previewResult(task)">查看结果</el-button>
</div>
</el-collapse-item>
</el-collapse>
<el-empty v-else description="暂无历史任务" />
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { UploadFilled, Loading, Download } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const previewImage = ref('')
const prompt = ref('')
const selectedFile = ref(null)
const isSubmitting = ref(false)
const currentTask = ref(null)
const recentTasks = ref([])
const pollInterval = ref(null)
const canSubmit = computed(() => {
return selectedFile.value && prompt.value.trim().length > 5 && !isSubmitting.value
})
const stepActive = computed(() => {
if (!currentTask.value) return 0
switch (currentTask.value.status) {
case 'pending': return 1
case 'processing': return 2
case 'completed': return 3
case 'failed': return 3 // 失败也算走到最后一步
default: return 0
}
})
function handleImageChange(file) {
const isImage = file.raw.type.startsWith('image/')
const isLt5M = file.raw.size / 1024 / 1024 < 5
if (!isImage) {
ElMessage.error('只能上传图片文件!')
return
}
if (!isLt5M) {
ElMessage.error('图片大小不能超过5MB!')
return
}
selectedFile.value = file.raw
previewImage.value = URL.createObjectURL(file.raw)
}
async function submitTask() {
if (!canSubmit.value) return
isSubmitting.value = true
const formData = new FormData()
formData.append('image', selectedFile.value)
formData.append('prompt', prompt.value)
try {
const response = await authStore.api.post('/images/generate', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
ElMessage.success('任务已提交,开始处理!')
currentTask.value = { id: response.data.taskId, status: 'pending', created_at: new Date().toISOString() }
// 开始轮询任务状态
startPollingTask(response.data.taskId)
// 刷新历史列表
loadMyWorks()
} catch (error) {
ElMessage.error(error.response?.data?.error || '提交任务失败')
} finally {
isSubmitting.value = false
}
}
function startPollingTask(taskId) {
// 先清除旧的轮询
if (pollInterval.value) clearInterval(pollInterval.value)
pollInterval.value = setInterval(async () => {
try {
const response = await authStore.api.get(`/images/task/${taskId}`)
currentTask.value = response.data.task
if (response.data.task.status === 'completed' || response.data.task.status === 'failed') {
clearInterval(pollInterval.value)
pollInterval.value = null
if (response.data.task.status === 'completed') {
ElMessage.success('图片编辑完成!')
}
}
} catch (error) {
console.error('轮询任务状态失败:', error)
}
}, 3000) // 每3秒查询一次
}
async function loadMyWorks() {
try {
const response = await authStore.api.get('/images/my?limit=5')
recentTasks.value = response.data.images
} catch (error) {
console.error('加载历史任务失败:', error)
}
}
function getStatusTagType(status) {
const map = { pending: 'info', processing: 'warning', completed: 'success', failed: 'danger' }
return map[status] || 'info'
}
function formatTime(timeStr) {
return new Date(timeStr).toLocaleString()
}
function resetForm() {
selectedFile.value = null
previewImage.value = ''
prompt.value = ''
if (pollInterval.value) {
clearInterval(pollInterval.value)
pollInterval.value = null
}
currentTask.value = null
}
function goBack() {
router.push('/')
}
function downloadImage(url) {
const link = document.createElement('a')
link.href = url
link.download = `ai-edited-${Date.now()}.png`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
function shareToCommunity(imageId) {
ElMessageBox.prompt('为你的作品起个标题吧', '分享到社区', {
confirmButtonText: '分享',
cancelButtonText: '取消',
inputPlaceholder: '例如:我的AI奇幻大作'
}).then(async ({ value }) => {
try {
await authStore.api.post('/community/posts', { image_id: imageId, title: value })
ElMessage.success('分享成功!')
router.push('/community')
} catch (error) {
ElMessage.error('分享失败')
}
})
}
function previewResult(task) {
currentTask.value = task
}
onMounted(() => {
loadMyWorks()
})
// 组件卸载前清除轮询
import { onUnmounted } from 'vue'
onUnmounted(() => {
if (pollInterval.value) clearInterval(pollInterval.value)
})
</script>
<style scoped>
.generate-container {
padding: 20px;
}
.input-card, .result-card {
height: 100%;
}
.preview-area, .result-area {
text-align: center;
}
.processing-hint {
text-align: center;
padding: 20px;
color: #e6a23c;
}
.task-item {
font-size: 0.9em;
}
.task-item p {
margin: 5px 0;
}
</style>
由于篇幅限制,注册、登录、个人作品和社区页面的代码就不在这里全部展开了。它们的结构相对标准:表单提交调用对应的Store中的方法,列表页从API获取数据并渲染。你可以参考上面的模式,利用Element Plus的组件(如 el-form, el-table, el-card)来快速构建。
5. 项目部署与上线思考
代码都写好了,在本地跑起来没问题。但怎么让其他人也能访问你的网站呢?这就是部署要做的事。
5.1 前端部署
前端项目最终是一堆静态文件(HTML, CSS, JS)。我们需要把它们构建出来,放到任何能托管静态文件的服务器上。
# 在前端项目根目录下
npm run build
这个命令会在 dist 文件夹里生成优化后的静态文件。你可以:
- 上传到 GitHub Pages、Vercel 或 Netlify 这类免费的静态网站托管平台。
- 放到你自己的 Nginx 或 Apache 服务器目录下。
5.2 后端部署
后端是Node.js服务,需要在一个能长期运行的服务器环境中启动。
- 准备生产环境:确保你的服务器上安装了Node.js和MySQL。
- 上传代码:将后端项目文件上传到服务器。
- 安装依赖:在服务器上运行
npm install --production(只安装生产依赖)。 - 配置环境变量:在服务器上创建
.env文件,填入生产环境的数据库信息和AI API密钥。 - 使用进程管理工具:为了让服务在后台稳定运行,即使崩溃也能重启,建议使用 PM2。
npm install -g pm2 pm2 start server.js --name ai-image-editor-api pm2 save pm2 startup # 设置开机自启 - 配置反向代理(可选但推荐):通常我们不会让用户直接访问
3000端口。可以用Nginx作为反向代理,将域名(如api.yoursite.com)的请求转发到本地的Node.js服务,还能处理HTTPS、负载均衡等。
5.3 数据库与文件存储
- 数据库:本地MySQL可以,但对于正式项目,考虑使用云数据库服务(如阿里云RDS、腾讯云CDB),它们提供更高的可用性、自动备份和更便捷的管理。
- 文件存储:我们目前把上传的图片存在服务器本地
uploads文件夹。这在小规模时可行,但存在扩展性和可靠性问题。更好的做法是使用对象存储服务,如阿里云OSS、腾讯云COS或AWS S3。它们专为存储大量文件设计,提供高可用、高扩展性和低成本。你需要修改后端代码,将文件上传到对象存储,并在数据库中保存文件在对象存储中的URL。
5.4 安全与性能考量
- HTTPS:务必为你的域名配置SSL证书,启用HTTPS。现在几乎所有云平台都提供免费的证书申请(如Let's Encrypt)。
- API密钥保护:后端调用AI API的密钥必须通过环境变量管理,绝对不要写在代码里或提交到代码仓库。
- 用户上传验证:除了前端验证,后端也必须对上传文件的类型、大小进行严格检查,防止恶意文件上传。
- 限流与防刷:对于AI生成这类消耗计算资源的接口,可以考虑添加限流(rate limiting),防止恶意用户刷接口导致资源耗尽。
- 异步队列:如果用户量变大,同步处理AI任务会阻塞请求。可以考虑引入消息队列(如RabbitMQ、Redis),将生成任务放入队列,由独立的Worker进程消费,实现解耦和削峰填谷。
6. 回顾与展望
走完这一趟,我们从零搭建了一个具备完整前后端功能的AI图片编辑网站。你不仅实践了Vue 3、Node.js、MySQL这些具体技术,更重要的是体验了一个全栈项目的完整生命周期:需求分析、技术选型、数据库设计、前后端开发、功能联调。
这个项目就像一个“样板间”,你可以基于它做很多扩展:
- 功能上:增加图片编辑的“强度”滑块、多种艺术风格滤镜选择、批量处理功能。
- 社区上:增加评论、收藏、关注用户、热门排行榜。
- 技术上:引入WebSocket实现任务进度的实时推送,而不是前端轮询;用Docker容器化部署,让环境配置更简单;尝试用Nuxt.js或Next.js做服务端渲染(SSR)提升首屏体验。
全栈开发最有魅力的地方,就在于你能独立掌控从界面到数据、从逻辑到部署的每一个环节,把一个想法完整地实现出来。这个项目里的很多模式——用户认证、文件上传、异步任务、RESTful API设计——在其他Web应用里也是相通的。
希望这个实践能帮你把学到的知识点串联起来,下次当你再有一个新点子时,能更有信心地动手去实现它。开发过程中遇到问题很正常,多查文档、多调试、多思考,每一个解决的问题都会成为你的经验。
获取更多AI镜像
想探索更多AI镜像和应用场景?访问 CSDN星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。
更多推荐




所有评论(0)