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 我们要做一个什么样的网站?

想象一下,用户来到这个网站,可以完成以下几件事:

  1. 注册登录:有个自己的账号,能看到自己的创作历史。
  2. 上传图片:把本地照片传上来,作为AI编辑的原材料。
  3. 描述编辑:用文字告诉AI我想怎么改这张图,比如“把背景换成海滩日落”、“给人物穿上西装”。
  4. 生成新图:网站调用后端的AI模型API,根据我的描述生成编辑后的新图片。
  5. 管理作品:我生成的所有图片都能在个人中心看到,可以下载或者删除。
  6. 分享交流:我可以把满意的作品发布到社区广场,大家都能看到、点赞。

这就是我们网站的核心功能闭环。从技术上看,它需要前端界面后端逻辑数据库存储外部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 的数据库,然后设计三张核心表:

  1. users (用户表):存用户基本信息。
  2. images (图片记录表):存每一次图片生成的任务记录。
  3. 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 PagesVercelNetlify 这类免费的静态网站托管平台。
  • 放到你自己的 NginxApache 服务器目录下。

5.2 后端部署

后端是Node.js服务,需要在一个能长期运行的服务器环境中启动。

  1. 准备生产环境:确保你的服务器上安装了Node.js和MySQL。
  2. 上传代码:将后端项目文件上传到服务器。
  3. 安装依赖:在服务器上运行 npm install --production(只安装生产依赖)。
  4. 配置环境变量:在服务器上创建 .env 文件,填入生产环境的数据库信息和AI API密钥。
  5. 使用进程管理工具:为了让服务在后台稳定运行,即使崩溃也能重启,建议使用 PM2
    npm install -g pm2
    pm2 start server.js --name ai-image-editor-api
    pm2 save
    pm2 startup # 设置开机自启
    
  6. 配置反向代理(可选但推荐):通常我们不会让用户直接访问 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星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐