今年又陆续接到好几个“网上销售网站”方向的咨询,题目几乎都是同一个模板:基于Nodejs+Vue+ElementUI的网上产品销售网站的设计与实现。
说实话,这类选题是典型的全栈入门题里最有代表性的一个。技术栈生态完整、前后端分离思路清晰、功能边界明确,做出来既能讲清楚 Vue 组件通信,也能说清 Node.js 接口设计,放到毕业设计或者简历项目里都非常合适。但我也看了不少同学做这个题目时卡住的地方,基本不是业务逻辑太难,而是栽在环境配置、组件使用细节、前后端联调这三类问题上。
这篇文章我会按自己做过类似项目的顺序来写:先说动手前必须定下来的架构和数据库表设计,再讲后端 Node.js 接口的分层实现,然后说 Vue + ElementUI 前端工程怎么搭、页面怎么做,最后盘一盘最容易让新手崩溃的配置和联调问题。你按这个顺序一步步做,基本不会走偏。
1. 整体架构与数据库设计:动手前先吃掉这三块
1.1 技术选型为什么这样搭配,Vue 2 还是 Vue 3 要分清
这个题目的核心能力其实就是“买卖双方通过网站完成商品浏览、下单、订单查看”,属于典型的中小型管理系统加电商逻辑。后端用 Node.js,核心价值有两点:
- 开发语言统一,前端同学不用额外学 Java 那套工程结构;
- Express 中间件生态成熟,搭接口、配跨域、做鉴权都非常快。
如果你会 Spring Boot,也可以做这个题目,但既然题面写的是 Nodejs,那就老老实实把 Express 和 MySQL 打通。
有一个关键点必须先提醒:ElementUI 这个组件库只对应 Vue 2,Vue 3 对应的组件库叫 Element Plus。很多新手在这个地方反复踩坑,下载 ElementUI 之后启动报错,或者页面直接空白。所以如果你的题目里明确写了“ElementUI”,建议直接用 Vue 2.6 + Element UI 2.15.x 这套组合,这也是目前各种管理系统毕设里最稳的组合。
Node.js 版本建议装 16 到 18 之间的 LTS 版本。版本太高的话,有些旧脚手架依赖会有兼容警告,太低的话新版本 npm 语法又不支持。我实际测试下来,Node 16.20 配合 Vue CLI 5 和 Express 4,基本不会出什么幺蛾子。
当然,如果你对 Vue 3 更熟,也可以把题目里的 ElementUI 理解成 Element Plus 来做。组件名称大部分一样,少量 API 有差异,文章后面我会标注这些差异点。
1.2 前端、后端、数据库三块的目录与信息流设计
先不要急着写代码,我把整个系统的信息流画一遍:用户打开浏览器访问前端页面,前端通过 axios 请求 Node.js 后端接口,后端操作 MySQL 数据库,把数据返回给前端,前端渲染到 ElementUI 组件里。
所以项目从物理上拆成两块:
code复制online-shop/
├── client # Vue 前端工程
│ ├── public
│ └── src
│ ├── api
│ ├── assets
│ ├── components
│ ├── router
│ ├── store
│ ├── views
│ ├── App.vue
│ └── main.js
└── server # Node.js 后端工程
├── routes
├── controllers
├── models
├── middleware
├── sql
├── app.js
├── package.json
└── .env
client 放前端,server 放后端,两个目录各自有 package.json。开发时开着两个终端,一个跑 npm run serve,一个跑 node app.js。
在动手之前把目录拆好,最大的好处是后面答辩或者写报告时,你可以很清楚地讲出“前端负责展示和交互,后端负责业务逻辑和数据持久化”。这本身就是前后端分离架构的核心得分点。
数据库设计建议单独建一个 online_shop 库,字符集选 utf8mb4。这个字符集必须用 utf8mb4,不是 utf8,否则用户填了个生僻字或者表情符号,插入数据库就直接报错,很多新手会在订单备注功能上遇到这个诡异的乱码问题。
1.3 核心数据库表到底建哪几张
按电商系统通常的最小闭环来设计,一般需要六张核心表。我以实际可用的字段清单为例,你直接抄也行,适当扩展也可以:
- user 用户表:id、username、password、nickname、avatar、phone、create_time。注意 password 不要存明文,存 bcrypt 加密后的字符串。
- category 商品分类表:id、name、sort。商品按分类展示是后台管理的常见要求。
- product 商品表:id、category_id、name、description、price、stock、image、sales、status。price 字段我用 decimal(10,2),不用 float,避免浮点数算钱出问题。
- cart 购物车表:id、user_id、product_id、quantity、selected。selected 用来标记勾选状态,方便后面的结算逻辑。
- orders 订单表:id、order_no、user_id、total_amount、status、address、create_time。status 可以设计成多个订单状态。
- order_item 订单明细表:id、order_id、product_id、product_name、product_image、price、quantity。
这几张表之间的关联关系也简单清晰:用户下单时把购物车里选中的商品捞出来,计算总价生成 orders,再把每个商品拆成 order_item 明细。这样订单表不冗余,明细表又能记录下单那一刻的商品快照。
订单状态这里我习惯用一个整数表示:0 待付款、1 待发货、2 待收货、3 已完成、4 已取消。后端接口里统一处理这个状态枚举,前端再映射成 ElementUI 的 tag 标签颜色。实际做毕设时,“支付”通常用模拟状态代替,不需要真的接第三方支付接口。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 后端 Node.js 接口怎么分层:从用户鉴权到商品模块
2.1 初始化 Express 项目并处理跨域
先建一个 server 目录,在里面执行 npm init -y,然后安装基础依赖:
bash复制npm install express mysql2 cors body-parser jsonwebtoken bcryptjs
Express 4 目前还是最主流的选择。安装的时候注意一定要把 mysql2、cors、jsonwebtoken、bcryptjs 列出来,这几个分别负责数据库连接、跨域处理、登录令牌、密码加密。
后端入口文件我习惯叫 app.js,内容大致如下:
javascript复制const express = require('express');
const cors = require('cors');
const bodyParser = require('body-parser');
const app = express();
app.use(cors());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
// 静态资源,后续存储图片用
app.use('/uploads', express.static(__dirname + '/uploads'));
// 路由注册
app.use('/api/user', require('./routes/user'));
app.use('/api/product', require('./routes/product'));
app.use('/api/cart', require('./routes/cart'));
app.use('/api/order', require('./routes/order'));
app.listen(3000, () => {
console.log('server running at http://localhost:3000');
});
cors() 这个中间件不要省略,它就是用来解决“前端 http://localhost:8080 访问后端 http://localhost:3000 被浏览器拦截”问题的。很多人第 1 次请求就报跨域,根本原因其实就是没加这个中间件。
在正常项目中我还会把数据库连接单独抽一个 db.js:
javascript复制const mysql = require('mysql2');
const pool = mysql.createPool({
host: 'localhost',
user: 'root',
password: '你的数据库密码',
database: 'online_shop',
waitForConnections: true,
connectionLimit: 10,
});
module.exports = pool.promise();
这里用 .promise() 是为了支持 async/await 写法,避免回调地狱。如果你之前看的老教程是用 callback 写 SQL,我建议这次直接用 async/await,代码会清爽很多,讲解起来也更容易。
2.2 用户注册登录与 JWT 鉴权逻辑
用户模块是第一个要写的接口,因为后面的购物车、订单都要依赖当前登录用户。注册接口的流程是这样:
- 检查用户名是否已存在;
- 用 bcryptjs 对密码加密;
- 插入用户表。
javascript复制const bcrypt = require('bcryptjs');
// 注册
router.post('/register', async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.json({ code: 1, msg: '用户名和密码不能为空' });
}
const [rows] = await db.query('SELECT id FROM user WHERE username = ?', [username]);
if (rows.length > 0) {
return res.json({ code: 1, msg: '用户名已被注册' });
}
const hashPassword = bcrypt.hashSync(password, 10);
await db.query('INSERT INTO user (username, password) VALUES (?, ?)', [username, hashPassword]);
res.json({ code: 0, msg: '注册成功' });
});
登录接口和注册的区别在于:登录成功后要签发一个 JWT 令牌返回给前端,前端每次请求要登录的接口时,在请求头里带上这个令牌。JWT 你可以理解成一张“临时通行证”,服务端验签通过就放行,验签失败就返回 401,让前端跳回登录页。签发代码:
javascript复制const jwt = require('jsonwebtoken');
router.post('/login', async (req, res) => {
const { username, password } = req.body;
const [rows] = await db.query('SELECT * FROM user WHERE username = ?', [username]);
if (rows.length === 0) {
return res.json({ code: 1, msg: '用户不存在' });
}
const user = rows[0];
const isMatch = bcrypt.compareSync(password, user.password);
if (!isMatch) {
return res.json({ code: 1, msg: '密码错误' });
}
const token = jwt.sign({ id: user.id, username: user.username }, 'your-secret-key', {
expiresIn: '24h',
});
res.json({ code: 0, data: { token, userInfo: { id: user.id, username: user.username } } });
});
JWT 生成的密钥在实际项目里要放到环境变量里,不要在代码里写死。不过毕设场景下,只要不是直接提交到公网仓库,问题也不是特别大。
需要登录的接口,我建议统一抽一个 auth 中间件:
javascript复制const jwt = require('jsonwebtoken');
module.exports = function (req, res, next) {
const token = req.headers['authorization']?.split(' ')[1];
if (!token) {
return res.status(401).json({ code: 1, msg: '未登录' });
}
try {
const decoded = jwt.verify(token, 'your-secret-key');
req.userId = decoded.id;
next();
} catch (err) {
return res.status(401).json({ code: 1, msg: '登录已过期' });
}
};
在需要登录的接口路由里加上 router.get('/list', auth, handler) 就行。购物车、订单、个人中心的接口都要这么保护。
2.3 商品列表与商品详情的接口怎么写
商品接口不需要登录,属于公开接口。列表接口要支持分类筛选、关键字搜索和分页,分页是最容易出问题的点,因为前端 ElementUI 分页组件通常会给两个参数:
- pageNum:当前页
- pageSize:每页条数
对应的后端 SQL 就必须同时查出列表数据和总数。我习惯返回一个统一结构:
javascript复制router.get('/list', async (req, res) => {
let { pageNum = 1, pageSize = 10, categoryId = '', keyword = '' } = req.query;
pageNum = Number(pageNum);
pageSize = Number(pageSize);
let whereSql = ' WHERE 1=1';
let params = [];
if (categoryId) {
whereSql += ' AND category_id = ?';
params.push(categoryId);
}
if (keyword) {
whereSql += ' AND name LIKE ?';
params.push(`%${keyword}%`);
}
const [rows] = await db.query(
`SELECT * FROM product ${whereSql} LIMIT ? OFFSET ?`,
[...params, pageSize, (pageNum - 1) * pageSize]
);
const [[{ total }]] = await db.query(
`SELECT COUNT(*) AS total FROM product ${whereSql}`,
params
);
res.json({
code: 0,
data: {
list: rows,
total,
},
});
});
你注意看这里 WHERE 1=1 这个写法,很多教材觉得它不优雅,但实际项目里拼接筛选条件真的很方便,后面加条件不用再判断是否第一次拼 where。这种写法在真实后端代码里很常见。
LIMIT ? OFFSET ? 的占位符看起来是两个问号,实际上 WHERE 后面的占位符要先补齐,这个顺序不能乱。我早期就吃过亏,params 里的顺序没对应好,结果接口返回的数据永远是前几页。
商品详情接口就简单了,直接根据 id 查询单条商品返回。加上点击量自增这种小功能也可以,但核心还是把数据查出来。
3. 订单流程里的购物车、库存与事务细节
3.1 购物车为什么要独立一张表,接口怎么设计
购物车表的核心作用是保存“用户还没下单的商品集合”。有些新手会想着把购物车数据直接存到 localStorage,这样确实省事,但换个浏览器数据就丢了,而且后端看不到用户的购物车状态,管理端就无法统计加购情况。题目既然叫网上产品销售网站,用户登录后购物车必须跟账号绑定,所以要独立建表。
购物车接口至少需要五个:
- 添加购物车
- 修改购物车商品数量
- 勾选/取消勾选商品
- 删除购物车商品
- 获取购物车列表
添加购物车有一个细节需要特别处理:同一用户同一商品重复添加到购物车时,不应该新增记录,而应该在原有记录上累加数量。SQL 可以这么写:
javascript复制router.post('/add', auth, async (req, res) => {
const { productId, quantity = 1 } = req.body;
const [rows] = await db.query(
'SELECT id, quantity FROM cart WHERE user_id = ? AND product_id = ?',
[req.userId, productId]
);
if (rows.length > 0) {
await db.query(
'UPDATE cart SET quantity = quantity + ? WHERE id = ?',
[quantity, rows[0].id]
);
} else {
await db.query(
'INSERT INTO cart (user_id, product_id, quantity, selected) VALUES (?, ?, ?, ?)',
[req.userId, productId, quantity, 1]
);
}
res.json({ code: 0, msg: '已加入购物车' });
});
购物车列表需要联表查询出商品价格、图片、名称,我很少直接在购物车表里冗余商品名和价格,而是通过 product_id 关联 product 表。因为如果商品改价了,购物车展示的应该是最新价,而不是加购时的旧价。只有在真正下单生成 order_item 的时候才保存价格快照。
3.2 下单接口的事务控制与库存扣减
下单是整个后端逻辑里最容易出错的地方。核心步骤是:
- 获取当前用户购物车中 selected=1 的记录;
- 逐条联查商品当前库存;
- 如果库存不足,直接返回错误;
- 如果库存充足,创建订单主记录;
- 创建订单明细记录;
- 扣减商品库存;
- 删除对应购物车记录。
这七步里,任意一步失败,前面所有操作都应该回滚,否则会出现“订单创建了但库存没扣”或者“库存扣了但购物车没清空”这种数据不一致。
所以这个接口必须用事务。以 mysql2 promise 连接池为例:
javascript复制const conn = await db.getConnection();
try {
await conn.beginTransaction();
// 1. 查购物车选中记录
// 2. 查库存并计算总价
// 3. 插入订单
// 4. 插入订单明细
// 5. 扣库存 UPDATE product SET stock = stock - ? WHERE id = ? AND stock >= ?
// 6. 删除购物车记录
await conn.commit();
res.json({ code: 0, msg: '下单成功', data: { orderNo } });
} catch (error) {
await conn.rollback();
res.json({ code: 1, msg: '下单失败' });
} finally {
conn.release();
}
扣减库存的 UPDATE 语句是我重点想强调的地方,加一个 AND stock >= ? 条件是为了防止超卖。这里是判断和扣减变成一个原子操作的过程,避免了两个用户同时下单时把库存扣成负数。用 MySQL 事务虽然能回滚,但先查后扣的写法在高并发下仍然有并发窗口,加这个条件是一个更稳的兜底。
订单号生成我一般不用数据库自增 id,而是用时间戳加随机数拼一个字符串。这样呈现给用户的订单号更长更规范,也方便后续按订单号查询。比如 const orderNo = Date.now() + '' + Math.floor(Math.random() * 1000000);
有的同学在这个接口上纠结要不要拆分成多个后端接口让前端依次调用,我的建议是不要。前端最好只调一次下单接口,把所有复杂逻辑都收在后端完成。这样即使后面要做移动端,也能复用同一套接口逻辑。
4. Vue 前端工程化目录与 ElementUI 组件库落地
4.1 初始化 Vue 项目时最容易忽略的版本问题
前端我首推用 Vue CLI 创建工程。命令行执行:
bash复制npm install -g @vue/cli
vue create client
创建的时候会让你选 preset,如果你不是特别清楚每个选项,直接选 Default (Vue 2) 就行。这里提醒一下,不要用 Vite 去创建 Vue 2 项目,Vite 的默认模板更多面向 Vue 3,强行配置 Vue 2 比较折腾。考试和毕设图的是稳,不是图新。
项目创建完后,在 client 目录安装 ElementUI:
bash复制npm install element-ui@2.15.13
npm install axios
npm install vue-router@3
这里 vue-router 必须装 3.x 版本,因为 Vue 2 对应的路由插件是 vue-router 3。如果你手滑装成 vue-router 4,启动后页面直接白屏报错。Vue 2 和 Vue 3 的生态插件版本差异很大,这是新手最容易忽略的问题。
4.2 ElementUI 按需引入还是全量引入
ElementUI 引入方式有两种:全量引入和按需引入。全量引入的代码非常简单:
javascript复制import Vue from 'vue';
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
Vue.use(ElementUI);
这个方式非常适合当前场景,因为网上销售网站用到的组件比较多:Button、Table、Form、Dialog、Pagination、Select 都会用到。全量引入的好处是不用担心漏组件,代价是打包体积大一点,但对毕设项目来说完全不是问题。
按需引入需要安装 babel-plugin-component,还要在 .babelrc 或 babel.config.js 里写配置,配置错了就报错。我实际见过有同学为了省点体积在按需引入上折腾了两天,最后项目还是跑不起来。所以我的建议很直接:安装时用全量引入,写报告时再写一句“为了开发效率,本项目采用 ElementUI 全局注册方式”。
同样,axios 的封装也应该在开发前做好,不然后续每个页面都要重复写 axios.get 的地址前缀和 token 处理逻辑,非常乱。
我习惯在 src/api 目录下建一个 request.js:
javascript复制import axios from 'axios';
import { Message } from 'element-ui';
import router from '@/router';
const request = axios.create({
baseURL: '/api',
timeout: 10000,
});
request.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = 'Bearer ' + token;
}
return config;
});
request.interceptors.response.use(
response => {
const res = response.data;
if (res.code === 1) {
Message.error(res.msg || '请求失败');
return Promise.reject(new Error(res.msg));
}
return res;
},
error => {
if (error.response && error.response.status === 401) {
localStorage.removeItem('token');
router.push('/login');
}
Message.error(error.message || '网络异常');
return Promise.reject(error);
}
);
export default request;
这里把接口前缀统一写成 /api,然后在 vue.config.js 里通过代理转发到后端 3000 端口,这样开发环境就不会跨域。vue.config.js 内容:
javascript复制module.exports = {
devServer: {
port: 8080,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
};
4.3 路由表设计与登录守卫
路由文件我按页面模块来组织,常用的页面包含首页、商品详情、购物车、订单列表、后台管理、登录注册。用一个例子展示:
javascript复制import Vue from 'vue';
import VueRouter from 'vue-router';
Vue.use(VueRouter);
const routes = [
{ path: '/', redirect: '/home' },
{ path: '/home', component: () => import('@/views/Home.vue') },
{ path: '/product/:id', component: () => import('@/views/ProductDetail.vue') },
{ path: '/cart', component: () => import('@/views/Cart.vue'), meta: { requiresAuth: true } },
{ path: '/orders', component: () => import('@/views/Orders.vue'), meta: { requiresAuth: true } },
{ path: '/login', component: () => import('@/views/Login.vue') },
{ path: '/register', component: () => import('@/views/Register.vue') },
{ path: '/admin', component: () => import('@/views/Admin.vue'), meta: { requiresAuth: true } },
];
const router = new VueRouter({
mode: 'history',
routes,
});
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token');
if (to.meta.requiresAuth && !token) {
next('/login');
} else {
next();
}
});
export default router;
路由传参是一个高频考点,也是很多人面试会问的地方。当使用 /product/:id 这种路径参数时,在页面里要用 this.$route.params.id 接收;当使用 this.$router.push({ path: '/search', query: { keyword } }) 这种查询参数时,要用 this.$route.query.keyword 接收。这两者的区别在于:路径参数是地址栏里可见的 URL 片段,更利于 SEO;query 参数更灵活,适合筛选条件。
写在 meta 里的 requiresAuth 就是做路由守卫用的。拦截逻辑放在前端,能保证“未登录用户不能访问购物车和后台”,但真正严格的控制仍然要在后端接口中间件里做,双保险。
5. 商品展示、购物车页面的组件级实现
5.1 商品列表页与 ElementUI 分页的坑
商品列表我用一个 el-row 嵌套 el-col 的卡片布局,展示商品图、名称、价格和“加入购物车”按钮。页面底部放 el-pagination 分页组件:
html复制<el-pagination
background
layout="prev, pager, next, sizes, total"
:total="total"
:page-sizes="[8, 12, 16]"
:page-size="queryParams.pageSize"
:current-page="queryParams.pageNum"
@current-change="handlePageChange"
@size-change="handleSizeChange"
>
</el-pagination>
这个组件的核心坑在于::total 是后端返回的总条数,:current-page 是当前页码,:page-size 是每页条数。组件内部维护了当前页码,但它的数据源必须是 data 里定义的那些响应式字段,不能写死成数字。
handlePageChange 里要做的事情就是更新 pageNum,然后重新调用商品列表接口:
javascript复制handlePageChange(page) {
this.queryParams.pageNum = page;
this.getList();
},
handleSizeChange(size) {
this.queryParams.pageSize = size;
this.queryParams.pageNum = 1;
this.getList();
}
很多同学在这块写了半天,发现点下一页数据不变,大概率是两种原因:一是 current-page 绑定的值没有同步更新,组件点击后又被数据覆盖回 1;二是后端接口返回的 total 不对,比如 MySQL 查询用了两条 SQL 但参数串了。
我在 2.3 节里就把后端返回的 total 设计好了,前端只需要 this.total = res.data.total; this.list = res.data.list;,数据流非常清晰。
另外 ElementUI 的 el-pagination 有一个总页数属性叫 page-count,它可以替代 total。如果传入 total,组件会自动算总页数;如果只传 page-count,组件就不会显示实际条数。一般情况下建议传 total,不传 page-count。
5.2 购物车表格的勾选、全选与联动计算
购物车页面我直接用 el-table,然后给一个列加上 type="selection" 实现多选。这是 ElementUI 表格多选最常见也最标准的写法:
html复制<el-table :data="cartList" ref="cartTable" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55"></el-table-column>
<el-table-column prop="productName" label="商品"></el-table-column>
<el-table-column prop="price" label="单价"></el-table-column>
<el-table-column label="数量">
<template slot-scope="scope">
<el-input-number :value="scope.row.quantity" @change="updateQuantity(scope.row, $event)"></el-input-number>
</template>
</el-table-column>
<el-table-column label="操作">
<template slot-scope="scope">
<el-button type="danger" size="mini" @click="deleteCartItem(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
type="selection" 这一列会自动带表头全选和行复选框,不用额外写全选的逻辑。但要拿到所有选中的记录,需要在 @selection-change 事件里保存一份数组。用户点击结算时,不要重新去查数据,直接用当前数组筛选出选中项传给后端即可。
这里有一个容易被忽略的问题:el-table 的 selection 列只有当表格数据有唯一行 key 时组件才能正确维护选中状态。你需要给 el-table 加上 row-key="id",否则在某些刷新场景下,选中状态可能错乱。
热搜词里经常出现“elementui下拉多选全选”,如果你在后台管理页面做商品分类的多选筛选,用的是 el-select 的 multiple 属性:
html复制<el-select v-model="selectedCategories" multiple placeholder="请选择分类">
<el-option
v-for="item in categoryList"
:key="item.id"
:label="item.name"
:value="item.id"
></el-option>
</el-select>
el-select 的 multiple 和表格的全选不一样:它没有自带“全选”按钮,需要你在下拉面板里加一个“全选/反选”的 option,比如在下拉面板上方放一个 el-checkbox,点击后把 categoryList 的所有 id 都赋给 selectedCategories。这里返回给接口的是一个数组,后端接收时直接拿 req.body.categoryIds 即可,不需要自己循环拼 SQL。
5.3 下单结果与订单状态展示
购物车里用户点击“去结算”,前端先把选中的 cartId 数组传过来。我用 this.$router.push({ path: '/orders' }) 跳转到订单列表,或者弹一个确认下单的 Dialog。
结算时我会把总金额展示成一个固定位置,用 ElementUI 的 el-badge 和 el-tag 做视觉强调。用户确认后调用后端 /api/order/create,后端返回订单号,前端再跳转到订单列表。订单列表用 el-table 展示,状态列用 tag 渲染不同颜色:
html复制<el-table-column label="状态">
<template slot-scope="scope">
<el-tag :type="orderStatusType(scope.row.status)">
{{ orderStatusText(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
这种状态映射函数在成熟项目里会抽到 utils 文件里,前端和后端保持一致。下次想改文案,只改一个地方就够了。
6. 联调阶段躲不开的四类环境配置坑
6.1 “npm.ps1 无法加载”的完整排查链路
这是这个题目下被搜索最多的问题之一,因为它几乎拦住了所有在 Windows PowerShell 上第一次安装依赖的同学。报错内容关键词是:
code复制npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本
原因不是 Node.js 没装好,而是 Windows 默认的 PowerShell 执行策略限制了 npm.ps1 脚本运行。Node.js 环境本身没有任何问题,你切换到 cmd 窗口去执行 npm -v 都能正常输出版本号,只是 PowerShell 出于安全策略不让你跑这个脚本。
解决办法有两种。
第一种最简单:直接用 cmd 或 Git Bash 代替 PowerShell。命令行窗口按 Win+R 输入 cmd 回车,在 cmd 里执行 npm 命令就不会有这个问题。
第二种是修改 PowerShell 执行策略。以管理员身份打开 PowerShell,执行:
powershell复制Set-ExecutionPolicy -ExecutionPolicy RemoteSigned
输入 Y 确认后再执行 npm -v 就能正常工作了。
这个问题的本质是执行策略限制,RemoteSigned 表示本地脚本可以运行,远程下载的脚本必须有签名。如果只是本地开发,RemoteSigned 已经够用也相对安全,不需要设置成 Unrestricted。
如果你用的是 nvm 或者自定义了 Node.js 安装目录,报错里的路径可能变成 D:\Program Files (x86)\nodejs\npm.ps1,但处理方式完全一样。
6.2 Node.js 安装与 npm 换源细节
Node.js 安装本身一般不会太困难,重点是你装完之后要验证环境变量。打开终端执行:
bash复制node -v
npm -v
两个都能输出版本号,说明安装成功。如果 npm -v 在 PowerShell 报错就是上面说的执行策略问题;如果提示“不是内部或外部命令”,说明安装时没有勾选自动添加 PATH,需要手动把 Node.js 安装目录加到系统环境变量。
依赖下载慢是国内很多新手项目卡住的另一个点。你要是发现 npm install 卡几分钟没动静,多半是默认源慢。换源可以用两种方式:
bash复制# 临时使用国内镜像源
npm install --registry=https://registry.npmmirror.com
# 永久设置
npm config set registry https://registry.npmmirror.com
设置了全局镜像源之后,npm config get registry 可以确认结果。配置好之后安装 Vue、ElementUI 这些依赖就会从国内镜像下载,速度快很多。这里强调一下,换源只是下载依赖的渠道变了,不影响项目运行结果。
6.3 跨域、代理和接口 404 的区分
前端用 axios 请求 /api/product/list 时,实际过程是:前端把请求发给自己的开发服务器(8080),vue.config.js 里的代理把这个请求转发给后端服务器(3000),后端把数据返回给前端开发服务器,再返回给浏览器。
如果代理没配,浏览器会报跨域错误,错误信息里能看到两个不同的端口号。如果代理配了但路径不对,比如后端路由注册的是 /api/product,但你请求的是 /product,那就报 404,Network 面板里显示的地址是后端地址,说明代理生效但路由匹配失败。
排查这类问题,我建议先打开浏览器开发者工具,切到 Network 面板,看请求的实际 URL 和响应状态:
- 状态码 404:查路由路径、方法名是否对得上;
- 状态码 500:把后端控制台报错信息贴出来看;
- 状态码 401:看登录令牌有没有带上,前端 axios 拦截器是否生效;
- CORS error:检查后端 cors 中间件或代理配置。
这个排查顺序能解决百分之九十九的前后端联调问题。很多同学一报错就只看前端 console,没有完整看 Network 的请求详情和后端终端日志,定位方向就偏了。
6.4 vue-router history 模式刷新 404 与 vue devtools 调试
如果前端路由用了 mode: 'history',开发环境下频繁刷新某个子路由页面可能正常,但部署到服务端后,直接刷新 /cart 页面会出现 404。原因在于:history 模式的 URL 没有 # 号,浏览器刷新时真的向服务器请求了 /cart 这个地址,但服务器并没有这个物理文件。
解决办法是在部署的静态服务器上做“所有请求都回退到 index.html”。如果你把前端 build 后的 dist 目录交给 Express 托管,要在后端加上类似这样的兜底处理:
javascript复制const path = require('path');
app.use(express.static(path.join(__dirname, 'dist')));
app.get(/^(?!\/api).*/, (req, res) => {
res.sendFile(path.join(__dirname, 'dist/index.html'));
});
注意这个通配路由一定要放在所有 /api 接口之后,不然会把接口请求也接到 index.html 上。
调试 Vue 项目建议装上 vue devtools 浏览器插件。它能直接看到组件树、Vuex 状态、props 传递。比如购物车页面点击加号数量没变,你可以先看组件 data 里的 cartList 有没有更新,再看后端数据库有没有更新,就可以快速定位是前端状态问题还是接口返回问题。
7. 打包部署与演示数据准备的心得
7.1 前端 build 与后端静态托管
开发完全结束后,执行:
bash复制npm run build
会在 client 目录生成 dist 文件夹,里面是编译压缩后的静态文件。把这个 dist 文件夹放到 server 目录下,然后在 app.js 中加两行代码托管静态资源,再用 node app.js 启动后端服务,浏览器访问 http://localhost:3000 就能看到完整项目。
如果项目要部署到云服务器,建议把 SQL 文件导入线上数据库,然后上传 server 目录和 dist,用 pm2 守护 Node.js 进程:
bash复制npm install -g pm2
pm2 start app.js --name online-shop
线上部署时不要在代码里写 localhost 作数据库地址,要改成云数据库连接地址。数据库密码也不要写明文到代码里,可以用环境变量或者 .env 文件管理。
7.2 预置数据和演示顺序
在交给别人演示或答辩之前,数据库里一定要预置足够的数据。我一般每个分类准备五到十条商品,图片用本地 uploads 目录下的静态图片,而不是外链图床。外链图片加载慢或者图床失效,现场演示就会很难看。
图片处理有两种常见方式:
- 后端做一个上传接口,后台管理页面可以手动上传商品图;
- 直接在产品表中用
/uploads/xxx.jpg这样的相对路径,图片文件手动放到后端 uploads 目录。
第二种方式更省事,适合验收和答辩准备阶段。
演示时的操作顺序建议是:注册一个新账号 -> 登录 -> 浏览首页 -> 查看商品详情 -> 加入购物车 -> 修改数量并勾选 -> 模拟提交订单 -> 到订单列表查看订单状态变化 -> 用后台账号进入管理页面新增一个商品图片并下架一个商品。这个顺序覆盖了项目设计里的所有核心功能点,讲解时间也能控制住。
如果想让项目有点加分项,可以在订单模块上做“取消订单”“确认收货”两个状态流转。这几个功能实现不难,但能明显体现出你对整个业务状态的理解,比简单增删改查更有价值。
我个人实际做下来,最花时间的反而不是业务代码,而是各种环境衔接问题。Node.js 版本和 npm 源搞定、ElementUI 版本匹配 Vue、路由传参和代理配好、分页和表格勾选的数据流理清,这个项目基本上就稳了。剩下的事情就是不断点页面走流程,每发现一个报错就按 Network 面板加后端日志的顺序排查,做完会发现自己对前后端数据协作的理解又上了一个台阶。
