我用 AI 从零生成一个完整项目,只改了 20% 代码
之前写过 AI Agent 辅助编程的文章,这次我想试个更极端的场景:从零开始让 AI 生成一个完整的、能上线的项目,看看人工到底需要介入多少。
项目是一个待办事项管理的 Web 应用,包含用户注册登录、创建和编辑待办事项、标记完成。前后端分离,前端用 React,后端用 Node.js。
整个过程我只做「提需求」和「审核」,代码能交给 AI 就交给它。
第一步:后端 API 搭建
给 AI 的需求描述:
用 Node.js + Express 搭建一个后端服务,要求:
- 用户注册登录,密码加密存储
- JWT 认证
- 待办事项的增删改查
- 每个用户的待办事项互相隔离
- SQLite 数据库
- 接口需要基本的输入验证
AI 生成了以下文件结构:
1 2 3 4 5 6 7 8 9 backend/
├── package.json
├── .env
├── src/
│ ├── index.js # 入口文件
│ ├── db.js # 数据库初始化
│ ├── auth.js # 认证路由
│ ├── todos.js # 待办事项路由
│ └── middleware.js # JWT 中间件
生成的核心代码:
src/db.js(数据库初始化):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const dbPath = path.join(__dirname, '..', 'data', 'app.db');
const db = new sqlite3.Database(dbPath, (err) => {
if (err) {
console.error('Database connection error:', err.message);
}
});
// 初始化表结构
db.serialize(() => {
db.run(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
db.run(`CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
completed BOOLEAN DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
)`);
});
module.exports = db;
src/auth.js(认证路由):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const db = require('./db');
const router = express.Router();
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
// 注册
router.post('/register', async (req, res) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email and password are required' });
}
if (password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
try {
const hashedPassword = await bcrypt.hash(password, 10);
db.run(
'INSERT INTO users (email, password) VALUES (?, ?)',
[email, hashedPassword],
function (err) {
if (err) {
if (err.message.includes('UNIQUE constraint')) {
return res.status(409).json({ error: 'Email already exists' });
}
return res.status(500).json({ error: 'Server error' });
}
res.status(201).json({ id: this.lastID, email });
}
);
} catch (e) {
res.status(500).json({ error: 'Server error' });
}
});
// 登录
router.post('/login', (req, res) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email and password are required' });
}
db.get('SELECT * FROM users WHERE email = ?', [email], async (err, user) => {
if (err) return res.status(500).json({ error: 'Server error' });
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
const valid = await bcrypt.compare(password, user.password);
if (!valid) return res.status(401).json({ error: 'Invalid credentials' });
const token = jwt.sign({ id: user.id, email: user.email }, JWT_SECRET, {
expiresIn: '7d',
});
res.json({ token, user: { id: user.id, email: user.email } });
});
});
module.exports = router;
后端部分 AI 生成了大约 200 行代码。我的人工介入主要在三处:
- .env 文件需要手动创建并填写 JWT_SECRET
- 数据库路径改为相对路径,原始版本写的是绝对路径
- 添加了
.gitignore排除node_modules和data/目录
第二步:前端搭建
给 AI 的需求:
用 React + TypeScript + Vite 搭建前端项目,要求:
- 登录和注册页面
- 登录后显示待办事项列表
- 可以创建、编辑、删除待办事项
- 可以标记完成/未完成
- 用 localStorage 存储 token
- 样式简洁,用 CSS Modules
AI 生成了完整的 React 项目。核心组件:
src/api.ts(API 调用封装):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 const API_BASE = 'http://localhost:3001/api';
function getHeaders() {
const token = localStorage.getItem('token');
return {
'Content-Type': 'application/json',
Authorization: token ? `Bearer ${token}` : '',
};
}
export async function login(email: string, password: string) {
const res = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error('Login failed');
return res.json();
}
export async function register(email: string, password: string) {
const res = await fetch(`${API_BASE}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error('Registration failed');
return res.json();
}
export async function getTodos() {
const res = await fetch(`${API_BASE}/todos`, { headers: getHeaders() });
if (!res.ok) throw new Error('Failed to fetch todos');
return res.json();
}
export async function createTodo(title: string) {
const res = await fetch(`${API_BASE}/todos`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({ title }),
});
if (!res.ok) throw new Error('Failed to create todo');
return res.json();
}
export async function toggleTodo(id: number, completed: boolean) {
const res = await fetch(`{id}`, {
method: 'PATCH',
headers: getHeaders(),
body: JSON.stringify({ completed }),
});
if (!res.ok) throw new Error('Failed to update todo');
return res.json();
}
export async function deleteTodo(id: number) {
const res = await fetch(`{id}`, {
method: 'DELETE',
headers: getHeaders(),
});
if (!res.ok) throw new Error('Failed to delete todo');
return res.json();
}
src/components/TodoList.tsx(待办列表组件):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 import { useState, useEffect } from 'react';
import { getTodos, createTodo, toggleTodo, deleteTodo } from '../api';
import styles from './TodoList.module.css';
interface Todo {
id: number;
title: string;
completed: boolean;
}
export default function TodoList() {
const [todos, setTodos] = useState<Todo[]>([]);
const [newTitle, setNewTitle] = useState('');
const [loading, setLoading] = useState(true);
useEffect(() => {
loadTodos();
}, []);
async function loadTodos() {
try {
const data = await getTodos();
setTodos(data);
} catch (e) {
console.error('Failed to load todos:', e);
} finally {
setLoading(false);
}
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!newTitle.trim()) return;
try {
await createTodo(newTitle.trim());
setNewTitle('');
loadTodos();
} catch (e) {
console.error('Failed to create todo:', e);
}
}
async function handleToggle(todo: Todo) {
try {
await toggleTodo(todo.id, !todo.completed);
loadTodos();
} catch (e) {
console.error('Failed to toggle todo:', e);
}
}
async function handleDelete(id: number) {
try {
await deleteTodo(id);
loadTodos();
} catch (e) {
console.error('Failed to delete todo:', e);
}
}
if (loading) return <div>Loading...</div>;
return (
<div className={styles.container}>
<h1>Todo List</h1>
<form onSubmit={handleCreate} className={styles.form}>
<input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="New todo..."
/>
<button type="submit">Add</button>
</form>
<ul className={styles.list}>
{todos.map((todo) => (
<li key={todo.id} className={styles.item}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => handleToggle(todo)}
/>
<span className={todo.completed ? styles.completed : ''}>
{todo.title}
</span>
<button onClick={() => handleDelete(todo.id)}>Delete</button>
</li>
))}
</ul>
</div>
);
}
前端部分 AI 生成代码约 300 行。我的人工介入:
- 登录页面和注册页面合并到了一个
Auth.tsx组件,AI 原始版本分成了两个文件,有些重复代码 - 样式文件需要手动调整,AI 生成的 CSS 比较基础
- 添加了路由保护——未登录用户访问待办页面时跳转到登录页
第三步:联调和问题修复
前后端代码分别生成后,需要联调。这一步 AI 帮了一些忙,但也暴露了几个问题。
问题 1:跨域
前端跑在 localhost:5173(Vite 默认),后端在 localhost:3001,浏览器报跨域错误。
AI 修复方案是在后端添加 cors 中间件:
1 2 const cors = require('cors');
app.use(cors());
问题 2:数据库文件目录不存在
第一次启动后端时报错,因为 data/ 目录还没有创建。
AI 修复方案(在启动时确保目录存在):
1 2 3 4 5 6 7 const fs = require('fs');
const path = require('path');
const dataDir = path.join(__dirname, '..', 'data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
问题 3:前端缺少加载和错误状态
AI 生成的前端只有 loading 状态,没有 error 状态。我手动添加了错误提示:
1 2 3 4 const [error, setError] = useState('');
// 在 catch 中
setError('加载失败,请重试');
最终结果
项目跑通了,可以正常运行。代码量统计:
| 部分 | AI 生成行数 | 人工修改行数 | 人工新增行数 |
|---|---|---|---|
| 后端 | ~200 行 | ~20 行 | ~30 行 |
| 前端 | ~300 行 | ~40 行 | ~50 行 |
| 配置 | ~20 行 | ~10 行 | ~5 行 |
| 合计 | ~520 行 | ~70 行 | ~85 行 |
人工介入的代码占总代码量约 20%。主要是:
- 配置调整(环境变量、目录创建)
- 样式优化
- 路由保护
- 错误处理补充
- 组件合并重构
实际感受
这次实验下来,几个感受比较直接:
AI 生成的代码质量比我预期的好。核心的业务逻辑、数据库操作、API 调用,大部分能直接用。不是那种玩具代码,是有一定工程质量的。
人工的工作集中在边界情况和工程细节上。AI 能写出核心逻辑,但目录不存在怎么办、跨域怎么处理、未登录怎么跳转——这些边界情况得人工补。
提示词写清楚比事后修改效率高得多。一开始需求描述太模糊,生成的代码缺了很多东西。后来改了一次详细的提示词,生成质量明显提升。
AI 不是完全不需要你懂技术。生成的代码出了问题,你得能看懂报错、能判断是 AI 的问题还是环境的问题。不懂代码的人想靠 AI 编程,目前不太现实。
总结
从零用 AI 生成一个完整的前后端项目是可行的。大部分代码(约 80%)AI 能完成,剩下 20% 主要是配置、样式、边界处理这些「脏活」。
也在尝试用 AI 做完整项目的话,我的建议是:把项目拆成小块,一块一块让 AI 生成,比一句话让它生成整个项目效果好。
来源参考:
- AI 全栈开发破局路线
- 从零上线全栈 Web 应用
- AI 生成网站从 0 到上线
夜雨聆风
