1. 需求拆解:在线食品安全信息平台到底要做什么
1.1 先别被“在线平台”四个字唬住
如果你搜过 nodejs+vue+express 这类关键词,大概率会见着一批“某某信息平台”“某某管理系统”的毕业设计。这个在线食品安全信息平台就是很典型的代表:名字里把技术栈写得很清楚,Vue 管页面,Express 出接口,Node.js 当运行环境,主题落在食品安全上。至于标题末尾跟着的 _4140eq54,那只是项目生成工具留下的随机编号,和业务没有任何关系,别被它带偏。
这类系统剥开外壳以后,本质是一组食品安全相关数据在“公众”和“平台维护人员”之间流动。公众需要看到企业食品安全状态、公告信息和相关抽查结果;维护人员要能录入这些信息、更新内容、管理后台账号。放在一个真实的院校或行业实训场景里,这套系统的上线价值是明确的:让原本散落在 Excel 和纸质记录里的食品安全信息,变成一个能搜索、能分类、能及时更新的在线入口。
很多人拿到题目后第一反应是“食品安全信息是不是要接很多外部数据源”。如果做成国家级平台,确实要接监控和检验系统;但作为单体全栈项目,你只需要管好一个边界:平台自己维护的静态业务数据。企业资料、抽查记录、新闻公告都是管理员手动或批量维护的,公众端做展示和关键词查询。搞清楚这个边界,后续数据表、路由、页面菜单都会清晰很多。
1.2 用户角色决定了页面怎么拆
我把这类平台的用户习惯拆成三类:匿名访客、后台操作员、系统管理员。绝大多数课程设计和毕业设计里,不会单独做一个企业自主注册的完整流程,更多是平台方帮企业建档。所以前台不需要开放注册入口,只需要让访客浏览;后台才需要账号体系。
三类角色的权限路径大致如下:
| 角色 | 访问范围 | 典型操作 |
|---|---|---|
| 匿名访客 | 平台首页、企业档案浏览、资讯阅读、抽查结果查询 | 搜索、查看详情 |
| 后台操作员 | 登录后的数据维护界面 | 企业新增与编辑、抽查记录填报、公告发布 |
| 系统管理员 | 全部后台,外加账号配置 | 操作员账号创建、密码重置、关键数据删除 |
这个划分直接对应到 Vue 路由上:公开页面走一套 Layout,后台页面走另一套 Layout,并且在路由守卫里检查登录状态。我见过不少项目因为一开始没把角色理清,后面把“编辑企业”的按钮直接放到了公众首页,被迫在组件里写一堆 v-if,维护起来非常痛苦。
1.3 模块收敛到三张业务表就能跑起来
理想状态下,一个直接可运行版本建议只做三个业务主模块。
第一是食品企业档案模块。企业名称、统一社会信用代码、法人、注册地址、经营状态、资质证书图片,这些字段是企业维度的基础数据,也是整个平台最重要的一张表。
第二是抽查记录模块。抽查结果要和企业档案关联,能按企业查看多批次记录,也能按“合格/不合格”过滤。设计时有同学会把抽查结果当企业的一个字段,这会导致同一家企业两次抽查只能留一条,明显不对。正确做法是独立一张 inspection_record 表,企业是一方,抽查记录是多方的“一对多”关系。只要这个外键关系摆对,页面展示、统计接口都会好写很多。
第三是公告资讯模块。平台不能只有数据列表,还需要有人维护“平台最近动态”“食品安全提示”这类内容。用一张文章表存标题、摘要、正文、封面、发布时间就够。
如果还要加后台管理员账号表,那就是三张业务表外加一张用户表,四个表便能撑起整个平台。建议所有想在完整项目里练手的人先守住这个规模,把登录鉴权、文件上传、搜索分页这些通用能力做成可复用的,比盲目堆砌模块要提升得快。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型背后的道理:为什么是 nodejs+vue+express
2.1 Express 不算新,但它的生态足够稳
很多人在方案答辩时会被问“为什么用 Express,不用 Egg.js、NestJS?”我的回答通常很简单:这个项目内聚程度并不高,不需要框架层替我做太多约束,Express 的中间件模型让一切请求处理都透明可控。
Express 对新手直观的地方在于,一个路由就是一个函数。app.get('/api/enterprise/list', handler),请求过来,函数执行,返回 JSON。没有依赖注入,也没有全局拦截器的魔法。一旦想排查问题,从 app.js 入口往下看路由注册,整个过程一目了然。这种透明度在课程设计和中期答辩里非常占便宜,因为评审老师随时可能问“你的登录校验是怎么加上的?”你可以顺着中间件注册顺序讲得很清楚。
用 Node.js 而不是 Java/Python 跑后端,还有一个现实因素:前后端都是 JavaScript,传参、序列化、时间格式这些心智负担会低很多。前端拿到后端返回的对象,不需要再写一堆 DTO 转换,开发效率显著提升。
2.2 Vue 在表单和列表场景下很顺手
前端选 Vue 的原因也直接。食品安全信息平台大部分页面无非是企业列表、企业表单、抽查记录表格、公告编辑,这类需求恰恰是 Vue 的舒适区。
Vue 的响应式机制让“页面必须跟着数据变”变得自然。输入框绑定 v-model,表格循环 v-for,提交时 axios.post,发布后调用列表接口刷新。与传统的 jQuery 操作 DOM 相比,代码量至少少三分之一。Vue Router 和 Vuex/Pinia 又有成熟配套,后台管理里常见的侧边栏、顶部栏、路由鉴权都有现成模式可以抄。Vue 3 组合式 API 流行以后,把某个列表页的查询条件、分页参数、数据请求函数收敛在一个 setup 里,维护起来非常舒服。
至于 Vue 2 还是 Vue 3,只要是从零开始,直接选 Vue 3。许多新型组件库已经默认不再兼容 Vue 2,选择新版本能避免后续升级成本。
2.3 和前几年流行的其他组合比,差异在哪
选型时我也看过 Spring Boot+Vue、ThinkPHP+Vue 的方案。三者都能实现需求,但学习曲线和工程体积差异很大。
| 技术组合 | 优势 | 主要成本 |
|---|---|---|
| Node.js + Express + Vue | 全栈 JS 方言统一,依赖安装快 | 新手可能忽略异步错误处理 |
| Spring Boot + Vue | 工程规范、Java 生态成熟 | 需要 JDK/Maven/容器知识,更重 |
| PHP + Vue | 部署简单、上手快 | 团队协作时接口规范较随意 |
结论很简单:如果诉求是两周内做出一个能演示、能部署、能讲清楚架构的平台,Express 是性价比最高的选择。它没有太多强制规范,但对负责任的全栈开发者来说反而更考验你是否愿意自行约束代码结构。
2.4 数据库仍然选择关系型
数据库我坚持用 MySQL 或 MariaDB。食品安全信息平台的数据高度结构化,企业字段固定,抽查记录格式固定,公告内容即便长一些也仍是字符串。使用关系型数据库意味着可以轻易写 SQL 做多个维度的统计查询,比如“某个月度不合格批次数量”“各企业被抽查次数排行”。如果用 MongoDB 存,这些统计在后续实现时要额外处理,复杂度会提高。工程上,处理结构化数据不折腾,反而是最大的优势。
3. 从环境安装到项目初始化,三个高频坎提前避掉
3.1 Node.js 安装好不代表终端就能识别
无论是 Windows 还是 macOS,最稳妥的方式是到 Node 官网下载 LTS 版本安装包,而不是用命令行工具拉取可能在更新的 non-LTS 版本。Windows 下安装器 .msi 会帮你把 Node 和 npm 写进系统环境变量,安装完成后新开一个终端,执行下面两条命令验证:
bash复制node -v
npm -v
如果出现“node 不是内部或外部命令”,首先想一下是不是终端没有重开会话。环境变量是在安装那一刻写入系统的,已经开着的 PowerShell 或 CMD 不会自动刷新。重新打开终端之后仍然报错,再检查系统环境变量的 Path 里有没有 C:\Program Files\nodejs\ 这个路径。
我见过太多人一上来就重装,其实多数情况只是会话没刷新。先重开终端,省下二十分钟。
3.2 PowerShell 报“禁止运行脚本”的完整处理
这个报错在 Windows 上命中率太高,搜索量常年排在 Node 相关错误榜首。当你执行 npm -v 或者用 npm 安装依赖时,如果终端输出如下这段:
text复制npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本。
原因不在 npm,而在 PowerShell 的执行策略。.ps1 是 PowerShell 脚本,默认执行策略 Restricted 禁止运行任何脚本,npm 的批处理包装脚本自然也被拦下来。
最快的解决方案是以管理员身份打开 PowerShell,执行:
powershell复制Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
执行完输入 Y 确认。为什么推荐 RemoteSigned 而不是 Unrestricted?因为 RemoteSigned 意味着本地脚本可以运行,从互联网下载的脚本必须经过数字签名才能执行,它兼顾了可操作性和安全性。如果当前用户没有管理员权限,也可以靠 -Scope CurrentUser 只改动当前用户配置,不需要动系统级策略。
如果不希望调整 PowerShell 策略,另一个临时方案是改用 CMD 或 Git Bash 执行 npm 命令,也能绕过去。但从长期体验看,把执行策略改掉更省事,因为后面用 Vue CLI、Vite、Nx 这类工具时还会反复触发同样的问题。
3.3 前后端目录分离和镜像源配置
项目初始化阶段,我的习惯是创建两个完全独立的目录,比如 backend 和 frontend,而不是在同一个目录下混合存放 Vue 源码和 Express 源码。分开后,后端可以单独 npm install,前端也可以单独安装依赖,部署时甚至能放到两台服务器,后面上 CI/CD 也更方便。
后端初始化:
bash复制mkdir food-platform
cd food-platform
mkdir backend frontend
cd backend
npm init -y
npm install express mysql2 cors jsonwebtoken multer
前端脚手架用 Vite 初始化:
bash复制cd food-platform/frontend
npm create vite@latest . -- --template vue
npm install
npm install axios vue-router@4 pinia
国内网络环境下,首次安装依赖很容易超时。建议先把 npm 源切到国内镜像:
bash复制npm config set registry https://registry.npmmirror.com
配置完可以执行 npm config get registry 验证是否生效。这一步能明显提升下载速度,尤其是安装 node-sass 一类重依赖的时代虽然过去了,但网络波动依然会浪费不少时间。遇到依赖装到一半卡死,别硬等,Ctrl+C 删除 node_modules 后再重装,往往比反复续传更干净。
4. 后端核心:数据表设计与接口实现的关键细节
4.1 数据库表结构不要设计得过于分散
首次建库,我用四张表就能把平台跑通:管理员表、企业信息表、抽查记录表、文章表。如果硬要扩展到更多维度的内容,可以在文章表上加分类字段,但核心就是这四张。
按我的实际使用场景,表结构简要如下:
sql复制CREATE TABLE admin_user (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(200) NOT NULL,
real_name VARCHAR(50),
create_time DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE food_enterprise (
id INT PRIMARY KEY AUTO_INCREMENT,
enterprise_name VARCHAR(120) NOT NULL,
credit_code VARCHAR(50),
legal_person VARCHAR(50),
address VARCHAR(255),
license_img VARCHAR(255),
business_status TINYINT DEFAULT 1,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE inspection_record (
id INT PRIMARY KEY AUTO_INCREMENT,
enterprise_id INT NOT NULL,
sample_name VARCHAR(100),
inspect_result ENUM('合格','不合格'),
inspect_org VARCHAR(100),
inspect_date DATE,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (enterprise_id) REFERENCES food_enterprise(id)
);
CREATE TABLE article (
id INT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(200) NOT NULL,
summary VARCHAR(500),
content TEXT,
cover_img VARCHAR(255),
create_time DATETIME DEFAULT CURRENT_TIMESTAMP
);
这张表结构里最需要注意的关系是 inspection_record.enterprise_id 外键指向企业表。实际做编辑功能时,后端会把某企业名下的抽查记录带出来,按时间倒序展示。有这条外键,后面统计“每家企业被抽查了多少次”直接 GROUP BY enterprise_id 就能拿到,写起来很省心。
mysql2 连接池是更推荐的用法,避免每次请求都新建连接。一个标准的连接池配置长这样:
javascript复制const mysql = require('mysql2/promise');
const pool = mysql.createPool({
host: '127.0.0.1',
user: 'root',
password: '你的数据库密码',
database: 'food_safety',
waitForConnections: true,
connectionLimit: 10,
charset: 'utf8mb4'
});
注意 charset 务必使用 utf8mb4,否则前端录入的生僻字或者表情符号会在入库时报错或变成问号。
4.2 统一返回格式和登录鉴权要在第一时间搭好
后端最容易后期返工的是返回结构不一致。很早之前我写过一版接口,有的返回 { success: true, data: [] },有的返回 { code: 0, rows: [] },前端 axios 封装被迫写大量分支判断。这次从第一行接口代码开始,我就强制统一格式:
javascript复制function ok(res, data = null, msg = 'success') {
res.json({ code: 0, data, msg });
}
function fail(res, msg = 'error', code = 1) {
res.json({ code, data: null, msg });
}
登录接口和鉴权中间件的实现思路是:用户携带账号密码请求 /api/login,比对数据库中的密码哈希后签发 JWT;后续写操作全部经过 authMiddleware 校验。密码无论如何不要明文存储,用 bcryptjs 做哈希。
javascript复制const jwt = require('jsonwebtoken');
// 登录成功后签发 token
const token = jwt.sign(
{ id: user.id, username: user.username },
process.env.JWT_SECRET || 'food_platform_secret',
{ expiresIn: '12h' }
);
// 需要鉴权的路由使用这个中间件
function auth(req, res, next) {
const token = String(req.headers.authorization || '').replace('Bearer ', '');
if (!token) return res.status(401).json({ code: 401, msg: '请先登录' });
try {
req.user = jwt.verify(token, process.env.JWT_SECRET || 'food_platform_secret');
next();
} catch (e) {
return res.status(401).json({ code: 401, msg: '登录状态已失效' });
}
}
在注册所有业务路由之前,先用 app.use(express.json()) 和 app.use(cors()) 处理 JSON 请求体和跨域。如果不配置 express.json(),你会发现控制台收到的 req.body 一直是 undefined,这是新手经常踩的坑。
4.3 分页搜索接口要理解 LIMIT 和 OFFSET
企业列表接口建议提供三个参数:page、pageSize、keyword。对应的 SQL 写成:
javascript复制app.get('/api/enterprise/list', async (req, res) => {
try {
const page = Number(req.query.page) || 1;
const pageSize = Number(req.query.pageSize) || 10;
const keyword = req.query.keyword || '';
const offset = (page - 1) * pageSize;
const whereSql = keyword ? 'WHERE enterprise_name LIKE ?' : '';
const params = keyword ? [`%${keyword}%`] : [];
const [rows] = await pool.query(
`SELECT * FROM food_enterprise ${whereSql} ORDER BY create_time DESC LIMIT ? OFFSET ?`,
[...params, pageSize, offset]
);
const [[{ total }]] = await pool.query(
`SELECT COUNT(*) AS total FROM food_enterprise ${whereSql}`,
params
);
ok(res, { list: rows, total, page, pageSize });
} catch (e) {
fail(res, e.message);
}
});
为什么 total 要单独用 COUNT 查询数量,而不是直接返回 rows.length?因为分页之后 rows.length 最多只能等于 pageSize,前端需要总条数才能算出总页数并渲染分页器。少这一步,分页组件无法工作,UI 上确实能看到第一页数据,但用户点第二页就会失去分页状态。
MySQL 的 LIMIT ? OFFSET ? 在 mysql2 里可以用占位符传参,注意它们必须作为整数传入,不能直接拼接字符串。这样既安全又能避免一部分 SQL 注入风险。
4.4 文件上传只保存路径,不保存二进制
平台里企业资质证书和公告封面需要上传图片,绝大多数实践项目不推荐往数据库里塞图片二进制,内存和查询性能都不友好。正确做法是图片落到服务器的 uploads 目录,数据库里保存一个可访问的相对路径。
用 multer 处理上传:
javascript复制const multer = require('multer');
const path = require('path');
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, path.join(__dirname, '../uploads')),
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, Date.now() + '-' + Math.round(Math.random() * 1e9) + ext);
}
});
const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 }
});
app.post('/api/upload', auth, upload.single('file'), (req, res) => {
if (!req.file) return fail(res, '上传失败');
ok(res, { url: '/uploads/' + req.file.filename });
});
文件名用时间戳加随机数重新生成,不要留着用户上传时的中文名或特殊字符。真实项目里,同名文件覆盖、路径中带空格导致访问失败,都是很常见的线上问题。把文件改名后,这些坑基本自动消失。
5. Vue 前端落地:从路由配置到管理后台
5.1 路由分成公开和受保护两块
Vue Router 4 里,我通常把页面分成两组:面向公众的平台首页和企业列表页直接放出来;后台管理的首页、企业维护、抽查记录等路由放在一个名为 AdminLayout 的父路由下。
javascript复制const routes = [
{ path: '/', name: 'home', component: () => import('@/views/public/Home.vue') },
{ path: '/enterprise', name: 'enterpriseList', component: () => import('@/views/public/EnterpriseList.vue') },
{
path: '/admin',
component: () => import('@/layout/AdminLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', redirect: '/admin/dashboard' },
{ path: 'dashboard', component: () => import('@/views/admin/Dashboard.vue') },
{ path: 'enterprise', component: () => import('@/views/admin/EnterpriseManage.vue') },
{ path: 'inspection', component: () => import('@/views/admin/InspectionManage.vue') }
]
}
];
路由守卫负责把未登录用户拦在后台之外:
javascript复制router.beforeEach((to) => {
const token = localStorage.getItem('token');
if (to.meta.requiresAuth && !token) {
return { path: '/admin/login' };
}
return true;
});
登录成功后把返回的 token 和用户信息放进 localStorage,退出时再清除。这里有个很小但容易遗漏的点:不要在每次请求时都从 pinia 或全局变量拿 token,页面刷新后全局变量会被清空,必须从持久化存储读取。
5.2 axios 封装是前端联调的第一层地基
所有接口请求统一走 axios 实例,公共代码集中在拦截器里,而不是在页面组件里到处散落。
javascript复制import axios from 'axios';
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 === 401) {
localStorage.removeItem('token');
window.location.href = '/admin/login';
return Promise.reject(new Error('登录已过期'));
}
return res;
},
(error) => {
return Promise.reject(error);
}
);
export default request;
统一封装的好处是登录状态失效时可以在一个地方集中处理跳转,不用在每个页面重复写“如果 code 是 401,跳转登录页”的分支。实际开发中,这条拦截器链会帮你过滤掉无数潜在的前后端交互问题。
5.3 列表页、详情页、编辑表单的三板斧
管理后台最常用的是列表页模板。页面区域大致由搜索表单、操作按钮、主表格、分页器组成。搜索表单中的关键字和企业状态是查询条件,提交时调用列表接口并带上前两个参数。
表格行的“编辑”按钮通常会打开一个抽屉或弹窗,弹窗内是表单组件。表单提交成功后再调用列表刷新。很多人会把弹窗和列表写在一整个大组件里,数据多了以后状态混乱。更合理的做法是抽出一个独立的 EnterpriseForm.vue 子组件,对外暴露一个 submit 方法或事件,父组件只关心保存成功后刷新页面。
详情页和编辑页不要共用同一个组件。详情页应该只负责 GET /api/enterprise/:id 展示,编辑页才需要提交表单;共用组件会因为“能不能改”的逻辑搞得非常别扭。
5.4 管理后台的公共布局
后台 Layout 我建议包含左侧菜单、顶部用户信息和内容区。侧边栏菜单直接配置在路由里,通过 v-for 渲染,不写死 HTML。这样以后增加一个功能页面时,在路由表里加一条记录,左侧菜单会自动长出来。
vue复制<nav class="sidebar">
<router-link v-for="item in menuItems" :key="item.path" :to="item.path">
{{ item.title }}
</router-link>
</nav>
menuItems 从路由中筛选出同一父路由下的子路由,再映射出标题和路径。只要有新页面加入,菜单和路由天然保持一致,不会出现路由能访问但菜单点不到的额外维护成本。
6. 前后端联调:开发时必须处理好的三个体验问题
6.1 开发环境的跨域,用 Vite 代理而不是硬开 CORS
虽然后端代码里已经加了 cors(),本地开发时前端跑 http://localhost:5173,后端跑 http://localhost:3000,直接跨域依然会有预检请求的问题。与其在前端代码里写死“http://localhost:3000”作为 baseURL,不如利用 Vite 的代理把所有以 /api 开头的请求转发到后端。
javascript复制// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:3000',
changeOrigin: true
}
}
}
});
配置完成后,前端所有请求写 /api/login、/api/enterprise/list,浏览器发出的是同源请求,Vite 在开发服务层面把请求转发到 3000 端口,后端收 req.url 时依然是 /api/...。这个方案比把完整 URL 写在前端代码里干净得多,而且部署到正式环境时,前端代码不用改动一个字符,让 Nginx 继续做相同路径的反向代理。
6.2 登录态刷新失效,大概率是 token 的存储读取没做对
这个问题排查起来很神奇:登录后一切正常,一刷新页面就跳回登录页。原因十有八九是路由守卫只读取了内存变量,刷新后变量归零,守卫以为用户没登录。
解决方案很简单:路由守卫优先读取 localStorage。我在前面 axios 封装里已经演示过;如果在大型项目里用 Pinia 管理用户状态,初始化时也要先从 localStorage 恢复,不能默认发空。另一个相关场景是用户在页面停留时间超过 token 有效期,下一次操作接口返回 401,此时前端需要主动跳登录页而不是弹出无意义的报错。在响应拦截器里统一处理 401 状态,是最少成本的方案。
6.3 列表时间少 8 小时或格式不对的问题
MySQL 里 DATETIME 和 Node.js 连接时默认按本地时区读取,一般不会出大问题。真正容易踩的是前端拿到类似 2025-06-01T16:00:00.000Z 的 UTC 格式字符串后直接展示。用户会看到时间显示成了明天凌晨零点之类,体验很糟糕。
推荐的做法是后端查询时用 DATE_FORMAT 或者格式化时显式指定 YYYY-MM-DD HH:mm:ss。如果用的是 mysql2,可以在连接配置里加一条 timezone: '+08:00'。前端再做一层兜底:时间格式化函数要么抽成公共方法,要么用 day.js。
检测顺序是“后端查出的原始时间先打印出来确认,再去复查前端展示逻辑”。不要只在前端换格式化库,否则源头不对,后段怎么处理都可能错。
6.4 图片上传后的回显问题
图片传完以后,数据库里保存的是 /uploads/xxx.jpg。本地开发时,前端走 Vite 代理,默认不会把 /uploads 转发到后端 3000 端口,所以图片预览会 404。你需要在 Vite 代理里额外加一条规则,或者在后端把 uploads 目录做成静态资源托管。
javascript复制app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
生产环境则由 Nginx 用 location /uploads/ 指向磁盘目录。缺少这一步,系统会表现出“上传成功但立刻看不到图”的诡异现象。
7. 部署到服务器:Nginx + PM2 的组合操作
7.1 前端代码打包生成静态文件
Vue 项目开发完成后执行 npm run build,默认会生成一个 dist 目录。这个目录里全是静态文件,理论上放到任意 Web 服务器就能访问。构建产物大部分是不变的 CSS/JS 资源和 index.html,只要你没有修改代码,可以重复部署覆盖。
注意后端接口地址。开发时 axios 使用的代理 /api 在生产环境走不通,需要靠 Nginx 做反向代理让 /api 转发到本地运行的 Express 进程。因此打包前不需要改任何配置,关键是 Nginx 配置必须写对,否则前端资源能加载但接口全部 404。
7.2 Express 进程用 PM2 守护
手动在服务器上执行 node app.js 最明显的问题是:终端一关,进程就没了;进程崩了,也不会自动重启。我推荐用 PM2 作为进程守护工具。
bash复制npm install -g pm2
cd /opt/food-api
pm2 start app.js --name food-api
pm2 save
pm2 startup
pm2 logs food-api
pm2 startup 执行后会输出一条命令,需要复制到终端以 root 权限再执行一次,才能确保服务器重启后 PM2 自动拉起服务。很多人在机房部署时漏掉这一步,导致服务器重启后还要手动登录二次执行,体验大打折扣。pm2 save 则会把当前进程列表持久化,这样 pm2 resurrect 能恢复现场。
7.3 Nginx 反向代理和前端的刷新 404
部署阶段最典型的 Nginx 配置如下,前端 dist 放到 /opt/food-web/dist,后端 Express 跑在 3000 端口,upload 图片放在 /opt/food-api/uploads。
nginx复制server {
listen 80;
server_name _;
root /opt/food-web/dist;
index index.html;
client_max_body_size 10m;
location /api/ {
proxy_pass http://127.0.0.1
