1. 项目概述与核心需求拆解
1.1 工地建材仓管到底管什么:从一句话需求到功能清单
做工地建材仓库管理系统,最怕上来就写代码。甲方嘴上说“做个入库出库就行”,实际到工地转一圈就会发现,钢筋、水泥、砂石、防水卷材这些材料,每一类都有不同的计量单位、验收标准和存放要求。钢材按吨算,管件按根算,袋装水泥按吨也行按袋也行,砂石料则要区分粗细骨料。如果系统里没有规格型号和单位换算逻辑,月底对账的时候仓库管理员能把台账翻烂。
这个项目最开始的需求就是我上面说的“能记个账”,但实际拆解下来,至少包含四层:第一层是基础档案,包括建材品类、供应商、仓库位、领用班组;第二层是核心业务,包括采购入库、领料出库、退库、盘点调拨;第三层是库存监控,要能实时看每个仓库、每种材料的余量,低于安全库存自动提醒;第四层是报表统计,方便项目部和公司总部按周、按月核对材料消耗和成本。
所以这一版系统我最终落地了七个功能模块:供应商管理、建材档案、仓库管理、入库管理、出库管理、库存查询与预警、以及基于角色的用户权限管理。听起来很多,但真正动手后你会发现,前面几个档案模块只是增删改查,最难的是后面两个:库存怎么扣减才不出错,以及报表怎么算才能对得上现场周报。
1.2 为什么这套系统选了 Node.js + Vue
现在做管理系统,技术选型往往不是技术驱动的,而是由“谁来维护、谁来部署、现场环境什么样”决定的。工地项目部的电脑普遍不算新,装不了太重的桌面客户端,浏览器访问是最省事的。前后端分离架构里,后端用 Node.js + Express,前端用 Vue 3 + Element Plus,整套东西在普通办公电脑上从零开始跑起来,十分钟以内能完成环境搭建,明显比 Spring Boot + 前端那套省内存、省配置。
我选择 Node.js 还有一个实际原因:这套系统后期要给现场技术员做二次定制,比如加一个地磅接口的过磅数据自动录入,或者对接智慧工地平台。Node.js 生态里处理 JSON 和 HTTP 请求太顺手了,现场改需求时表达成本极低。Vue 这边看中的是组件化开发和响应式更新,表格、弹窗、表单校验这类后台管理场景,Element Plus 开箱即用,省掉大量手写 UI 的时间。
2. 技术选型、数据库设计与工程初始化
2.1 后端框架、ORM、数据库怎么搭最省心
后端主干我用 Express 4.x,它足够稳定,中间件生态丰富,资料也多。数据库选了 MySQL 8.0,因为建材仓库业务里入库单、出库单、库存流水之间是强事务关系,对数据一致性的要求远比字段灵活性高,这一点上关系型数据库比 MongoDB 这类文档数据库更合适,工地对账审计永远只看最终结果。
操作数据库我用的是 Sequelize ORM。有人觉得 ORM 性能差,但在这个项目里,建表、迁移、关联查询的便捷性更重要。我在模型里把 Stock 和 StockLog 的关联关系定义清楚后,后面做事务操作、预加载关联数据都非常规整,代码也容易维护。如果你更习惯写原生 SQL,那用 mysql2 连接池也没问题,只是模型多了以后,原生 SQL 的拼接会变得很痛苦。
ORM 之外,我还加了三样基础设施:JWT 做登录鉴权、winston 做日志记录、joi 做请求参数校验。登录接口返回的 token 用来保护所有业务接口,前端路由守卫根据 token 判断是否跳转登录页;日志模块记录每个用户的操作行为,包括谁在什么时候创建了入库单、修改了库存;参数校验是为了防止前端传负数、传空值把库存搞坏。
2.2 数据库表结构设计:五张核心表的字段规划
这部分直接决定后面代码好不好写。我设计的核心表一共五张,先说基础档案类:supplier存供应商名称、联系人、电话;material存建材名称、规格型号、计量单位、分类;warehouse存仓库名称、位置、负责人。库存相关的表有两张,设计时要注意细节:
库存表stock的字段是id、material_id、warehouse_id、quantity。同一材料在不同仓库会有多条记录,所以要在material_id和warehouse_id上建联合唯一索引,避免同一条库存记录被重复插入。
库存流水表stock_log的核心字段是change_type(取值:inbound/outbound/check_adjust)、change_quantity(正数表示增加,负数表示减少)、before_quantity、after_quantity、biz_no(关联入库单号或出库单号)。有了before和after两个快照字段,后面做审计和对账时可以直接查出任何时间点的库存变化轨迹,这也是现场出了纠纷能自证清白的底气。
入库单和出库单我拆成了“主表+明细表”的结构。主表inbound_order存的是单号、供应商ID、入库仓库、入库日期、经手人、审核状态;明细表inbound_order_item存的是每一条材料的 material_id、数量、单价、金额。为什么要拆?因为一张入库单可能包含十几种建材,主表和明细表分别维护不同粒度的信息,报表统计按单号汇总金额时只需要查主表,按材料分析消耗时只需要查明细表,效率上明显更好。
具体建表时,金额字段我统一用DECIMAL(10,2),数量字段DECIMAL(12,3),这是从用友那类成熟软件借鉴来的。建材里钢管是按“根”入库但按“米”消耗的场景不少,保留三位小数可以应付米和吨之间的换算余量,避免四舍五入造成累计误差。
2.3 npm 国内镜像源与依赖安装:这一步别踩坑
新项目初始化阶段最影响心情的就是 npm 下载速度。安装 Express、Sequelize 的时候,如果默认走官方源,等半天还可能超时失败。我一般在项目刚开始就切到国内镜像源,命令是:
npm config set registry https://registry.npmmirror.com设置完可以执行npm config get registry确认。切完镜像源后,安装依赖的速度完全是两个体验。这里提醒一句,镜像源只影响包下载,不影响包本身的内容,所以可以放心用。
Node.js 版本方面,如果你的机器已经装过旧版 Node,我建议直接安装 LTS 版本,目前建议使用 18.x 或 20.x。为什么不用最新的奇数版本?因为很多原生模块还没有完成适配,开发中报错你都不一定查得到原因。装完之后在终端执行node -v和npm -v,能看到版本号说明基础环境已经通了。很多新人卡在“node 不是内部或外部命令”上,十有八九是安装时没勾选自动加入 PATH,这个后面第三节详细说。
3. 从零到一:Node.js 环境与 Vue 工程搭建
3.1 Node.js 安装与环境变量配置
Windows 上安装 Node.js 通常有两种方式:官网下载安装包,或者通过 nvm-windows 管理多版本。如果你只做这一个项目,直接下载安装包最省事。下载时选择.msi格式,安装过程中注意两点:第一,安装路径不要带空格和中文,建议直接装到D:\nodejs这类纯英文目录;第二,安装向导里会有一个 “Add to PATH” 的勾选项,务必确认它是勾选状态。
如果你拿到一台已经装过 Node 的电脑,node -v正常但npm -v报错,多半是 npm 的快捷方式损坏,或者安装包不完整,最简单的方案是卸载后重装,不要试图手动修复。macOS 用户建议用brew install node,装完自动配好 PATH,不用手动折腾。
Linux 服务器部署时,我习惯用 NodeSource 仓库安装,一条命令就能装到指定的 LTS 版本:
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - sudo apt-get install -y nodejs安装完成后,建议顺手把 npm 的 global 包路径也确认一下。Windows 下执行npm config get prefix,默认会指向 Node 安装目录,如果不对,后面全局安装的工具容易找不到。
3.2 Vue 3 + Vite 工程脚手架快速初始化
前端这一侧我直接用 Vite 创建 Vue 3 工程。相比 Vue CLI,Vite 启动速度快得多,配置也更简洁,创建命令如下:
npm create vite@latest material-web -- --template vue cd material-web npm install工程创建后,需要按功能装依赖。UI 组件库选 Element Plus,路由用 Vue Router 4,状态管理我没上 Pinia,因为这个项目的数据流转比较简单,组件之间通过 props 和事件通信就够用了,额外引入状态管理反而增加心智负担。依赖安装命令:
npm install element-plus vue-router@4 axios安装 Element Plus 的时候,最好全局引入还是按需引入?我建议直接全局引入。仓库管理后台的页面数量不算多,按需引入虽然能减小打包体积,但配置起来要额外处理插件,对维护者不友好。全局引入的缺点是打包后 JS 文件会大一些,大概有 800KB 左右,对于后台管理系统来说完全可以接受。
工程跑起来后,我先做了一件事:封装 axios 实例。统一设置baseURL指向后端地址,在请求拦截器里带上Authorization头,在响应拦截器里统一处理 401 跳转登录页。这一步不做好,后面每个请求都要手动写 token 逻辑,很容易漏。
3.3 前后端联调时跨域问题的两种解决方案
开发环境下,前端跑在localhost:5173,后端跑在localhost:3000,直接请求必然遇到跨域问题。我的处理方案是后端启用cors中间件,一条命令搞定:
const cors = require('cors'); app.use(cors());开发阶段这种处理最省事,把跨域校验完全放开。但生产部署时不能这么弄,生产环境我用的是 Nginx 反向代理,把前端静态文件和/api接口代理到同一个域名下,从根上消除跨域,同时也把后端的真实端口隐藏起来。这个部署细节,我在本文第 5.4 节里会完整展开。
另外一个实践心得:在 vite.config.js 里配置 proxy 也能解决开发环境的跨域,效果等价。我是两种都配了,原因是为了让团队里不熟悉前端的同事也能顺利启动,不需要知道跨域原理就能跑起来。
4. 核心功能模块的代码实现拆解
4.1 入库单流程:从创建单据到库存累加
入库单是这个系统最典型的业务场景。前端页面上,用户选择供应商、选择入库仓库,再逐行添加材料明细,每条明细填材料、数量、单价、金额,最后提交。后端拿到入库单数据后,不是一个简单的 insert,而是一个包含多步写操作的事务。
我用 Sequelize 事务来完成整个流程,核心代码如下:
const transaction = await sequelize.transaction(); try { const order = await InboundOrder.create(payload.orderInfo, { transaction }); for (const item of payload.items) { await InboundOrderItem.create({ ...item, orderId: order.id }, { transaction }); // 更新库存 const [stock] = await Stock.findOrCreate({ where: { materialId: item.materialId, warehouseId: payload.orderInfo.warehouseId }, defaults: { quantity: 0 }, transaction }); const oldQty = stock.quantity; await stock.update({ quantity: sequelize.literal(`quantity + ${item.quantity}`) }, { transaction }); await StockLog.create({ materialId: item.materialId, warehouseId: payload.orderInfo.warehouseId, changeType: 'inbound', changeQuantity: item.quantity, beforeQuantity: oldQty, afterQuantity: oldQty + item.quantity, bizNo: order.orderNo }, { transaction }); } await transaction.commit(); } catch (error) { await transaction.rollback(); // 返回错误信息 }这里有几个容易忽略的细节。第一,订单号不要用自增 ID,我用的是INB20250312-0001这种格式,由“前缀+日期+当日序号”生成,现场的人一看就知道是当天的第几张入库单,比一串数字好用得多。第二,库存更新用sequelize.literal做原子自增,不要在代码里读出旧值再写回,并发情况下那种写法会造成库存丢失。第三,流水表要记录变更前的库存数值和变更后的库存数值,这一步不是为了写代码方便,是为了将来你被问“这个数字怎么来的”时有据可查。
4.2 出库单流程:库存扣减事务与防超领
出库单的逻辑是入库的逆向操作,但多了一个重要约束:扣减库存时不能扣成负数。工地上领料是有额度的,超领需要走审批,哪怕系统做得简单,这个规则也必须有。
后端处理出库时,我在事务里做了两重校验。第一重,遍历出库明细时检查库存是否足够,不够直接抛异常,整个事务回滚,一笔出库单提交失败不会影响任何数据。第二重,考虑现场可能有两个仓管员同时在操作出库,在 SQL 层面用条件更新来兜底:
const [affected] = await Stock.update({ quantity: sequelize.literal(`quantity - ${item.quantity}`) }, { where: { materialId: item.materialId, warehouseId: payload.warehouseId, quantity: { [Op.gte]: item.quantity } }, transaction }); if (affected === 0) { throw new Error(`材料${item.materialId}库存不足`); }affected === 0表示条件不满足,即库存不够,直接回滚。这种写法的好处是,即使两个请求同时扣同一批货,数据库的行锁也会保证只有一个请求成功,另一个会等锁然后条件不满足而失败,资金和实物都不会出现负数库存。
出库单创建成功后,前端列表页要实时刷新库存。我提供一个库存查询接口,前端在提交出库单后的回调里调用一次。这里可以加一个小优化:库存查询接口只返回库存低于安全库存的材料列表,前端拿到这个列表弹出警示框,提示哪些材料需要补货,现场效果非常直观。
4.3 库存预警与图表报表:SQL 聚合与前端可视化
仓库管理的核心价值在于让管理者一眼看出问题。库存预警功能我实现得很简单但有效:在 material 表加一个safety_stock字段,每次查询库存列表时,后端用一条 SQL 把当前库存和安全库存做对比:
const lowStockList = await Stock.findAll({ include: [{ model: Material, attributes: ['name', 'spec', 'unit', 'safetyStock'] }], where: { quantity: { [Op.lte]: sequelize.col('material.safety_stock') } } });quantity <= safety_stock的字段联查,直接把低于安全库存的材料捞出来。前端页面上,我把这些材料标记成醒目的警告样式,一眼扫过去就知道哪些要赶紧下单补货。
报表这块,最基础也最被高频使用的是月度出入库汇总。SQL 只需要按月份分组聚合即可:
SELECT DATE_FORMAT(created_at, '%Y-%m') AS month, SUM(CASE WHEN change_type = 'inbound' THEN change_quantity ELSE 0 END) AS inbound_total, SUM(CASE WHEN change_type = 'outbound' THEN change_quantity ELSE 0 END) AS outbound_total FROM stock_log WHERE material_id = ? GROUP BY DATE_FORMAT(created_at, '%Y-%m') ORDER BY month DESC;前端图表我用 ECharts,柱状图显示入库量、出库量,折线图显示库存变化趋势。这种组合图表对项目经理和材料员来说都很直观,周会上投屏讲数据时比口头汇报有说服力得多。
4.4 用户权限与系统管理模块设计
工地项目上的人员角色非常清楚:材料员负责入库和出库操作,仓管员负责审核盘点,项目副经理看着所有数据,公司总部的人只对报表感兴趣。所以权限管理不需要做成复杂的 RBAC 三表模型,我简化为角色字段 + 路由守卫 + 接口中间件三层。
数据库里用户表加一个role字段,取值:admin、manager、operator。前端路由表里给不同角色配置了不同的meta.roles,路由守卫里判断当前用户的角色能否访问该路由,不能则跳转 403 页。后端每个业务接口都加一个authMiddleware,读 JWT 里的角色信息,校验权限。
接口权限中间件的核心代码:
const requireRole = (roles) => { return (req, res, next) => { const userRole = req.user.role; if (!roles.includes(userRole)) { return res.status(403).json({ message: '没有权限执行该操作' }); } next(); }; }; app.post('/api/inbound', requireRole(['admin', 'manager']), inboundController.create);这个设计够用、不臃肿。真正重要的是操作日志。我在系统里记录了每一次库存变动,包括操作人、操作时间、变更前后数量。有了这个日志,一旦工地上有人反馈“库存不对”,可以直接定位到具体某一天的某笔操作,快速复盘。这一步看着不起眼,但在实际项目验收的时候是加分项,也是保护系统开发者自己的关键设计。
5. 高频坑位盘点:来自真实项目的排查实录
5.1 npm.ps1 无法加载文件:PowerShell 执行策略与 npx 方案
这个问题在 Windows 上出现频率极高,网上搜索量常年居高不下。你在终端执行 npm 相关命令时,报错提示 “npm.ps1 无法加载文件,因为在此系统上禁止运行脚本”,原因是 PowerShell 默认的执行策略是Restricted,禁止运行任何脚本文件,而 npm 本身是一个.ps1脚本。
解决办法有两种。第一种是修改 PowerShell 执行策略,用管理员身份打开终端,执行:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser执行策略有Restricted、RemoteSigned、Unrestricted等几个级别,RemoteSigned的含义是“本地创建的脚本可以运行,从网上下载的脚本必须有数字签名”,日常开发完全够用。改完后执行npm -v验证即可。
第二种办法是绕过这个问题,使用 npm 命令的替代方式。把npm run dev改成npx vite这类命令,npx 会直接去node_modules/.bin目录执行可执行文件,不走.ps1脚本,也就不会被执行策略拦截。团队里如果每个人电脑策略都不一样,我建议把命令统一写成 npx 形式,少一事。
5.2 Vue 样式冲突:scoped、模块化与全局样式的边界
Vue 单文件组件默认全局样式,不加限制的话,不同的组件类名一旦重复,样式就会互相覆盖,尤其是 Element Plus 组件的覆盖样式,写起来很容易互相打架。我第一次做项目时,表格页的按钮样式被另一个页面莫名影响,排查了很久才发现是全局样式污染。
现在我的经验是:组件内的样式一律加scoped,代码这样写:
<style scoped> .inbound-table .el-button { margin-right: 8px; } </style>scoped的原理是给组件 DOM 加一个>:root { --el-color-primary: #1e6fff; }
这样全局统一改主题色就不再需要翻找每个组件文件了。
5.3 Vue 路由与插槽:仓库管理后台中真正好用的姿势
Vue Router 在这个后台项目里的价值被很多入门教程低估了。仓库管理后台通常是一个“左侧菜单 + 右侧内容区”的布局结构,路由配置里用嵌套路由来组织页面层级,非常清晰:
{ path: '/warehouse', component: Layout, children: [ { path: 'inbound', component: InboundList, meta: { title: '入库管理', roles: ['admin', 'manager'] } }, { path: 'outbound', component: OutboundList, meta: { title: '出库管理', roles: ['admin', 'manager'] } }, { path: 'stock', component: StockList, meta: { title: '库存查询', roles: ['admin', 'manager', 'operator'] } } ] }meta字段里同时挂标题和角色权限,路由守卫读取这些信息后决定菜单显示和访问控制。动态路由这块,我目前没有做前端动态生成菜单,因为项目角色固定、页面不多,写死在路由表里反而更直观。如果以后要做多项目级别的内容隔离,再上动态路由不迟。
Vue 插槽在表格操作列里特别有用。Element Plus 的el-table-column提供了自定义插槽,我在这里面放操作按钮(编辑、删除、审核),不用改表格结构,数据渲染和操作按钮分离:
<el-table-column label="操作" width="160"> <template #default="scope"> <el-button size="small" @click="editRow(scope.row)">编辑</el-button> <el-button type="danger" size="small" @click="removeRow(scope.row)">删除</el-button> </template> </el-table-column>插槽还有一种典型用法是“状态标签翻译”。数据库里存的是0、1、2这样的数字状态,界面上要展示成“待审核 / 已入库 / 已驳回”,写个函数根据状态转文字和颜色,模板里用插槽替换默认单元格,展示效果明显。
5.4 端口隐藏与接口安全:从开发到部署的 Nginx 转发
很多人问 Node.js 服务怎么把端口和接口地址藏起来,避免被别人直接扫到。开发时localhost:3000无所谓,但部署到服务器后,直接把 Node 端口暴露公网是安全隐患,对方可以绕过前端直接调你的后端接口。
我的做法是在服务器上用 Nginx 做反向代理。Node 服务监听内网3000端口,Nginx 监听80/443端口,前端请求统一走/api,Nginx 把/api开头的请求转发给本机的3000端口。核心配置:
server { listen 80; server_name your-domain.com; root /opt/material-web/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:3000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location / { try_files $uri $uri/ /index.html; } }关键点是try_files。Vue Router 如果是 history 模式,刷新某个子路由时 Nginx 直接返回 404,必须靠try_files把请求回退到index.html,由前端路由接管。这一行配置不加,部署十次有八次挂在“页面刷新就 404”上。
配合 HTTPS 证书后,端口完全被封在 Nginx 层内部,外部只能看到 443 端口。接口地址也不会在浏览器里直接暴露,JavaScript 里的请求地址是相对路径/api/xxx,用户看到的一直是你配置的域名,Node 的真实端口只有服务器上的人知道。安全性提升是立竿见影的。
5.5 若忽略 build 与部署细节,代码写得再好也白搭:一行命令背后的项目生命周期
很多人在本地开发环境中调试一切正常,但到了部署阶段就发现前端资源加载不出来、接口 404、或者后端进程莫名其妙地挂掉。这里有几个容易被忽略的环节值得专门提醒。
前端构建这一步不能等到部署时才做。在本地执行npm run build生成dist目录后,要检查里面index.html引用的 JS/CSS 路径是不是绝对路径。如果你把前端部署在域名根目录,那么默认配置没问题;一旦你想部署在子目录(比如https://example.com/material-web/),就必须在vite.config.js里设置base: '/material-web/':
export default defineConfig({ base: '/material-web/', // 其他配置 });忘了这行配置,构建后的资源路径会变成/assets/xxx.js,在子目录下全部 404。
后端进程守护方面,用 Node 直接node app.js启动的方式,一旦终端关闭或进程崩溃,服务就没了。推荐用 PM2 做进程管理,一条命令启动,自动检测崩溃后重启,还能看日志:
pm2 start app.js --name material-api pm2 save如果服务器上装了宝塔面板,也可以直接用它内置的 Node 项目管理器,功能类似,对不熟悉命令行的同事更友好。
5.6 延伸场景:把仓库系统接入视频监控与多媒体点播
项目验收阶段,有人提了一句“能不能在系统里看料场的监控画面”,这其实是一个很常见的附加需求。仓库管理系统里嵌入视频流有两种常见方案:第一种是海康/大华的 RTSP 流直接转 HLS,再用前端播放器拉流;第二种是接入已经做了媒体处理的平台,直接拿流地址。
这里就牵涉到 Vue 播放 m3u8 格式流媒体的场景。HLS 协议的视频流地址后缀通常为.m3u8,只要后端能把直播流转成 HLS 切片,前端用hls.js就能播放:
npm install hls.js播放组件的核心逻辑其实不复杂:实例化Hls,把m3u8地址传给loadSource,然后attachMedia把视频元素挂载上去。真正麻烦的是流地址的鉴权,很多视频平台要求带时效性 token,所以我在工程里把流地址统一经过后端接口返回,不在前端写死,这既保证安全又方便替换平台。这个模块我目前是作为独立组件挂在仓库详情页里的,不影响主业务流程,属于锦上添花的功能。
6. 写在最后:个人实操心得与后续扩展方向
这套系统从业务梳理到部署上线,前后大概用了三周时间,其中一半时间花在需求确认和报表格式沟通上,真正写代码的时间并不多。我最大的体会是,工地项目最缺的不是花哨功能,而是可靠的数据录入逻辑和让人敢拍脑袋信任的报表。在库存扣减上坚持事务和原子操作、在表单里加参数校验、设计好日志流水,这些看不到的细节才是系统稳定运行的关键。
另外分享一个实际经验:开工前一定先把材料分类和单位规范跟仓管员沟通清楚。我在项目中途遇到过砂石料的单位一会儿按“吨”一会儿按“方”的情况,导致库存对不上。后来在材料档案里加了“默认单位”和“换算系数”两个字段,所有报表都基于默认单位输出,问题才彻底解决。这类业务规则层面的坑,比任何技术坑都要费时间。
如果后续还要扩展,我建议优先做两个方向:一是对接地磅或 PDA 扫码枪,让钢筋、水泥入库时自动读取数量,减少人工录入误差;二是加一个材料成本分析模块,把每月的材料消耗金额和工程产值挂钩,辅助项目部做成本控制。这套 Node.js + Vue 的骨架足够支撑这些扩展,核心业务不用重构,你完全可以在现有基础上继续往上搭。