资讯动态

Vue + Node.js 企业合同管理系统实战:从设计到部署

发布时间:2026/9/23 4:09:58 来源:尧图企业网站定制
做企业合同管理系统最常见的问题是合同看不住、查不到、审不清。纸质合同签完往柜子一放想调某个供应商的历史合同得翻半天档案电子合同散落在同事的电脑里版本混乱什么时候到期续签也没人提醒。我自己在给一家中小企业做内部管理系统时选了 vue nodejs 这套前后端分离方案把合同的录入、审批、归档、到期提醒全部串了起来系统内部代号就叫 5c062cu7。这篇文章把整个设计和实现过程完整梳理一遍包括技术选型的思路、数据库怎么设计、前端页面怎么做、后端接口怎么写、部署时踩了哪些坑适合正在做企业级管理系统的朋友参考也适合刚入门 vue 和 nodejs 的开发者拿来当实战项目练手。1. 整体设计为什么是 Vue Node.js 来做合同管理1.1 技术选型背后的几个现实考量先说到底为什么选 Vue而不是 React也不是传统后端渲染的 JSP 或者 Thymeleaf。企业合同管理系统是典型的中后台应用特点是页面多、表单密、状态多、权限细这种场景最需要的是高效的组件化和响应式数据绑定。Vue 在国内团队里的普及率高中文文档完善Element Plus、Ant Design Vue 这套组件库生态成熟一个表格、一个弹窗、一个日期选择器组件库直接拉起来就能用开发效率比手撸 DOM 高一个量级。后端选 Node.js说白了是因为整个团队的技术栈可以统一成 JavaScript。前端写 Vue后端写 Express中间不需要切换语言。企业合同系统的业务复杂度主要体现在流程和状态管理上并不需要特别重的计算能力。Node.js 的事件驱动和非阻塞 I/O处理合同创建、审批、文件上传这类 IO 密集型请求完全够用。我做技术选型时也对比过 Spring Boot虽然它在大型企业里更常见但对于这种内部管理系统用 Spring Boot 的成本在于要多维护一套 Java 构建链尤其当团队里没有专职 Java 后端时一个小问题就能卡很久。选型的核心原则是匹配业务体量和团队能力。项目用 Express 而不是 NestJS是因为业务没有到需要大量依赖注入和装饰器的复杂度Express 的路由、中间件模型足够清晰写起来也够直白。这个决策在这里不是最好的但是最合适的。1.2 系统功能模块与角色权限怎么拆把合同管理拆开核心就四个动作录合同、审合同、存合同、查合同。围绕这四个动作系统的基础功能模块可以拆成下面这些用户与登录模块账号密码登录、Token 鉴权、用户信息维护合同台账模块合同新增、编辑、详情查看、列表查询、多条件筛选审批流模块合同提交审批、业务审批、法务审批、驳回、撤销、归档提醒模块合同到期提醒、付款节点提醒客户与供应商档案模块维护合同相对方信息方便按客户维度汇总合同附件管理模块合同扫描件、补充协议等文件的上传、下载、在线预览统计报表模块按合同类型、金额、部门、月度统计给管理层做数据决策权限模型用了最常用的 RBAC也就是基于角色的访问控制。角色分四种普通员工能新增合同、查看自己创建的合同业务主管能审批自己部门内的合同法务人员能查看所有合同、做法务审核系统管理员除了全部功能还能维护用户和角色权限。权限控制不仅是前端隐藏按钮后端每个接口都有对应的权限校验这一点在后面接口实现部分会细说。1.3 数据库表结构设计别在状态字段上偷懒合同系统的数据模型核心是合同主表周边挂审批记录、附件、提醒和操作日志。以 MySQL 为例我设计了这几张表user 用户表id、username、password_hash、role、department_id、status、created_atcontract 合同表id、contract_no、name、type、amount、party_a、party_b、start_date、end_date、status、department_id、created_by、file_path、remark、created_at、updated_atapproval 审批表id、contract_id、approver_id、action、comment、created_atattachment 附件表id、contract_id、filename、file_path、file_size、uploaded_by、created_atnotification 提醒表id、user_id、content、is_read、contract_id、created_at合同状态是一个需要特别谨慎设计的字段。我见过有人用字符串存中文状态比如审批中已归档这种方案在业务变复杂之后非常难受筛选要写中文匹配状态转移逻辑也要到处填字符串。这里我建议用整数字段管理状态配合一套状态机0草稿1待部门审批2部门审批通过3待法务审批4法务审批通过5已归档-1已驳回-2已作废用整数存状态的优势是排序、筛选、做状态机迁移判断都方便前端展示的时候再用字典映射成中文标签。状态机的设计价值在于它把合同的整个生命周期固定下来系统里不会出现从归档状态直接改回草稿这种非法操作。字段权限上还有一个容易忽略的点金额字段尤其是涉及多位小数时建议用 DECIMAL 类型存不要用 FLOAT。FLOAT 有精度损失合同金额一旦出现几毛钱的差异财务那边直接打回。付款日期和到期日期也要尽量精确到天数据在前端展示时再做格式化。2. 环境准备从零把 Vue Node.js 的窝搭好2.1 Node.js 安装和 npm 镜像配置先解决下载慢的问题开发环境第一步是装 Node.js。这里建议不要追求最新版选 LTS 版本最稳我这边用的是 Node 18。下载地址可以去 Node 官网也可以去国内镜像站下载慢的问题一句话解决。安装之后先检查是否真的装好了在终端执行两个命令node -v npm -v如果都能输出版本号说明安装成功。有一类特殊情况是下载了解压版或者绿色版没有自动写入环境变量这时候需要手动配置。新增一个系统变量NODE_HOME指向 Node 解压目录然后在Path变量里加上%NODE_HOME%和%NODE_HOME%\node_modules\npm\bin配置完重新开终端再看。npm 默认源在境外直接安装依赖经常卡住。我建议初始化之后马上把镜像源切到国内镜像npm config set registry https://registry.npmmirror.com npm config get registry这里有必要解释一下为什么换镜像不会影响后续使用npm 源本质上就是一个静态文件仓库镜像只是把仓库内容同步一份放到国内包名、版本号、依赖关系完全一致只是下载路径变了。还有一个忠告尽量不要用 cnpm。它在某些场景下会产生二进制包结构不一致的问题尤其是一个 node_modules 里混合了 npm 和 cnpm 安装的包排查起来非常闹心。用官方 npm 配镜像源速度和稳定性都能兼顾。2.2 用 Vite 创建 Vue 3 项目别再用 Vue CLI 了我早期做 vue 项目用的是 Vue CLI后来全面切到了 Vite。两者的区别直接打字说清楚Vue CLI 基于 Webpack冷启动和热更新在项目大了之后会变慢Vite 基于原生 ES Module开发服务器启动快、热更新快到几乎无感配置也更简洁。对于新项目直接用 Vite 就好。创建 Vue 3 项目的命令npm create vitelatest contract-web -- --template vue按提示进入目录安装依赖cd contract-web npm install npm install element-plus axios vue-router4 pinia要注意版本匹配的问题Vue 3 项目必须用 vue-router 的 4.x 版本和 pinia不能用 vue-router 3 和 vuex 的旧习惯往里面套。Vue 2 老项目才是 vue-router 3 vuex 的搭配两者不能混用。安装完成之后跑一下项目npm run dev浏览器打开终端输出的本地地址能看到 Vite 的默认首页说明开发环境没问题了。2.3 Windows 下 npm.ps1 无法加载脚本这个错一定要搞明白搜索热词里有一条非常典型的报错信息我猜很多人在 Windows 上都会遇到npm : 无法加载文件 D:\Program Files\nodejs\npm.ps1因为在此系统上禁止运行脚本这个错误的原因不是 npm 坏了而是 PowerShell 的执行策略默认是 Restricted禁止执行任何 .ps1 脚本。npm 在 PowerShell 里是通过 npm.ps1 这个脚本去调用的所以直接被拦住了。解决办法有两种。第一种是以管理员身份打开 PowerShell执行下面的命令把执行策略放开然后重新打开终端即可Set-ExecutionPolicy -ExecutionPolicy RemoteSigned选 RemoteSigned 而不是 Unrestricted 的原因是它只允许本机创建的脚本和来自可信发布者的签名脚本运行安全性上更友好。第二种办法是绕开 PowerShell直接用 cmd 或者 Windows Terminal 里的命令提示符来跑 npm同样可以正常执行。我见过有人遇到这个报错后反复重装 Node.js其实完全没必要。把执行策略改掉或者换个终端问题就解决了。2.4 后端项目骨架搭建Express 目录别拍脑袋堆后端项目我单独建一个目录比如 contract-server。在项目里初始化mkdir contract-server cd contract-server npm init -y npm install express cors jsonwebtoken bcryptjs multer mysql2 dayjsexpress 是 Web 框架cors 解决跨域jsonwebtoken 做登录态bcryptjs 做密码加密multer 处理文件上传mysql2 是 MySQL 驱动dayjs 处理时间。这些依赖基本覆盖了合同系统的后端需求。目录结构按关注点分层不搞花里胡哨的架构contract-server/ ├── app.js # 应用入口注册中间件和路由 ├── server.js # 启动 HTTP 服务 ├── routes/ # 路由层只做 URL 分发 │ ├── auth.js │ ├── contract.js │ ├── upload.js │ └── user.js ├── controllers/ # 控制器处理业务逻辑 ├── models/ # 数据访问封装 SQL ├── middleware/ # 中间件鉴权、错误处理、上传配置 ├── utils/ # 工具函数 └── uploads/ # 上传文件目录为什么这样分层核心原则是让每层只管一件事。路由层拿到请求后交给对应的 controllercontroller 里写业务判断model 层封装数据库操作。这样后续要加一个接口、改一个权限校验、换一个数据库影响范围都是可控的。如果所有逻辑都堆在一个文件里前两周写起来很爽后面维护就是灾难。后端开发过程中我习惯先把 app.js 搭起来const express require(express); const cors require(cors); const app express(); app.use(cors()); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.get(/api/health, (req, res) { res.json({ code: 0, message: ok }); }); module.exports app;然后在 server.js 里启动服务监听 3000 端口。3. 前端核心实现从登录页到合同台账3.1 路由设计页面结构要能撑起整个信息架构Vue 3 项目里用 vue-router 4 配置路由。合同系统的页面结构大概是这样的/login登录页/主布局套一个侧边栏和顶栏/dashboard工作台展示待办、合同到期提醒/contract/list合同台账列表/contract/detail/:id合同详情/contract/create新建合同/approval/list待我审批的列表/archive/list归档合同查询/statistics合同统计报表主布局用嵌套路由实现这样侧边栏和顶栏不用在每个页面重复渲染。路由配置文件里用一个 meta 字段标记页面标题和需要的权限码后续做面包屑和权限控制都会用到。创建合同入口可以有两种交互一种是跳转到独立页面一种是在列表页弹大表单。我更推荐独立页面因为合同字段多一个页面能把信息展示和校验做得更完整后续扩展附件上传、审批记录也有地方放。3.2 权限控制路由守卫是在前端做的最重要一道防线前端权限控制在合同系统里的做法是用路由守卫配合角色来判断。登录成功后后端返回用户信息中包含role字段前端把token和用户信息存到 pinia 里。每次路由跳转前在全局前置守卫里做检查router.beforeEach((to, from, next) { const token localStorage.getItem(token); if (to.path /login) { next(); return; } if (!token) { next(/login); return; } if (to.meta.roles !to.meta.roles.includes(store.user.role)) { next(/dashboard); return; } next(); });这个方案能挡住大部分非授权访问但必须强调前端控制只是用户体验层的优化真正的安全边界在后端。就算前端隐藏了按钮用户照样可以自己拼 URL 去调接口所以后端每个接口都必须做独立的权限校验。前端路由守卫的职责是让普通用户看不到、点不了没权限的功能而不是代替后端做鉴权。3.3 axios 拦截器封装统一处理错误比每个页面 try/catch 强得多项目中所有 HTTP 请求都走 axios我给 axios 做了一层封装核心是请求拦截器和响应拦截器。请求拦截器主要负责在每次请求带上 tokenservice.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer ${token}; } return config; });响应拦截器统一处理后端返回的数据结构。后端接口约定了统一的返回格式{ code: 0, data: ..., message: success }。code 为 0 代表成功非 0 代表业务异常。响应拦截器里判断 code成功就直接返回 data失败弹一个统一的错误提示遇到 401 就清掉本地 token 并跳转登录页。这样做的好处是页面里写接口调用时只需要关心成功的逻辑错误处理交给拦截器代码干净很多。不然每个页面都写一遍 401 处理、网络错误处理代码会非常冗余。3.4 合同表单、列表和状态流转核心业务代码怎么写合同列表页是整个系统使用频率最高的页面一次要展示的字段很多合同编号、合同名称、类型、合同金额、相对方、开始日期、结束日期、状态、操作人。数据量大之后必须做服务端分页也就是前端传page和pageSize给后端后端返回当前页数据和总条数前端表格拿到总条数渲染分页器。筛选条件我放在列表页顶部用 el-form 的内联模式字段包括合同类型、状态、金额区间、日期范围。筛选条件变化之后重置页码为 1 再请求列表避免停留在第 10 页但数据已经变化。这里有个细节日期范围组件返回的是一个数组传到后端时要拆成startDate和endDate两个参数后端查询时再把结束日期加一天作为边界。合同状态流转是业务核心我用计算属性来处理每个状态允许的操作按钮。比如草稿状态显示提交审批和编辑待部门审批显示撤回待法务审批业务人员只能查看法务人员显示通过和驳回已归档不显示任何操作按钮这样的按钮显隐直接用 computed 根据当前row.status和用户角色推导出来逻辑都在一个明确的地方维护比在模板里写一堆v-if强得多。表单校验是合同录入的重头戏。合同名称必填合同金额必须大于 0结束日期必须晚于开始日期合同编号不能重复。Element Plus 的 el-form 自带校验规则我用了一个比较取巧的方式提交时统一触发formRef.validate()只有全部通过才调接口这样不会出现用户填到一半就弹一堆红色提示的糟糕体验。3.5 文件上传与预览细节都在校验和权限上合同附件用 el-upload 组件上传地址指向后端/api/upload。组件配置里关键参数是headers必须带上 Authorization否则后端鉴权中间件会直接拒掉。上传成功之后把后端返回的文件路径存到表单的filePath字段里在详情页展示。文件预览的做法要看类型。图片直接返回静态地址就能看PDF 用iframe src文件地址就行Word、Excel 这类文件浏览器不能原生预览我采取了两个方案要么后端转 PDF 再预览要么前端直接给下载按钮。考虑到企业内部流程下载其实比在线预览用得更频繁所以我没有过度纠结预览方案先保证上传和下载的稳定可靠。文件上传有几条不能省的校验文件大小不能超过后端配置的阈值常见是 10MB后缀名要做白名单校验只允许 pdf、jpg、png、doc、docx、xls、xlsx上传目录要按日期分文件夹避免所有文件堆在一个目录下否则文件多了之后查找和备份都会很痛苦。3.6 几个 Vue 细节v-model、computed、watch 的实战用法表单绑定是 v-model 的主场。合同表单里有大量文本输入、下拉选择、日期选择都可以直接用 v-model 绑定到 reactive 表单对象。要注意 el-date-picker 的日期返回的是时间戳或者 Date 对象提交前用 dayjs 格式化否则数据库里存进去的是时间戳查询和展示都会出问题。computed 在合同列表页里用到的最多。比如金额格式化数据库里是 DECIMAL前端拿到的可能是个小数我用 computed 配合 toLocaleString 做千分位展示按钮显隐也靠 computed 推导合同剩余天数也用 computed 算还剩不到 30 天的标红提醒。watch 用在一个特定场景列表页的筛选条件变化后自动触发加载列表。这里要注意immediate: true让组件初始化时就能加载一次不然要手动调一次接口。监听对象属性时要考虑是否 deep我一般监听的是整个筛选对象写deep: true省心但要注意不要在 watch 里做太重的操作不然频繁触发会有性能问题。ref 和 reactive 的选择我的习惯是基础类型用 ref对象和数组用 reactive。在组件的 setup 里列表数据我用 ref因为它会被整个替换表单数据用 reactive因为它是一个会被修改多个字段的对象。这个选择不是为了秀 API而是让代码在读写时更自然减少不必要的.value散落各处。4. 后端核心实现REST API 与认证授权4.1 Express 中间件机制是理解后端代码的钥匙Express 的中间件模型可以理解成一条流水线每个请求进来依次经过一系列中间件每个中间件可以操作请求对象、响应对象然后决定是继续往下传还是直接返回响应。在实际项目里中间件主要用在几个地方全局 cors 处理、请求体解析、静态文件托管、日志、鉴权、错误处理。把这些逻辑从业务接口里抽出来接口只关心自己的业务是 Express 项目设计的基本功。举一个简单的日志中间件app.use((req, res, next) { console.log(${req.method} ${req.url} - ${new Date().toISOString()}); next(); });错误处理中间件要放在所有路由之后这样路由里抛出的异常才能被统一捕获而不是让 Node 进程直接崩掉app.use((err, req, res, next) { console.error(err); res.status(500).json({ code: 1, message: 服务器内部错误 }); });业务里如果出现可以预见的错误比如合同编号重复、权限不够不要往这个全局错误处理里抛应该在 controller 里捕获后返回对应的业务错误码用户看到的提示才能精确到合同编号已存在而不是一句服务器内部错误。4.2 JWT 登录鉴权和密码加密这段代码可以直接抄用户注册和登录是合同系统的第一个后端模块。密码存储绝对不能是明文我用 bcryptjs 做加密。注册时const bcrypt require(bcryptjs); const passwordHash bcrypt.hashSync(password, 10);登录时用bcrypt.compareSync(password, user.password_hash)校验。bcrypt 加密的特点是自带盐同一个密码每次加密结果都不同这样数据库泄露也无法直接反推出密码。登录成功之后生成 JWTconst jwt require(jsonwebtoken); const token jwt.sign( { id: user.id, role: user.role }, process.env.JWT_SECRET, { expiresIn: 2h } );JWT 的优势是无状态后端不用存 session前端拿到 token 后存在 localStorage每次请求在 Authorization 头里带上即可。但也要知道它的短板token 一旦签发在过期之前是没法主动撤销的。所以过期时间不能设置太长内部系统 2 小时比较合适如果业务上需要强制下线或者踢人就得引入更复杂的方案比如 token 黑名单或者 refresh token 机制。鉴权中间件写在 middleware 文件夹里被需要登录态的接口引用function auth(req, res, next) { const header req.headers.authorization || ; const token header.startsWith(Bearer ) ? header.slice(7) : null; if (!token) { return res.status(401).json({ code: 401, message: 未登录 }); } try { req.user jwt.verify(token, process.env.JWT_SECRET); next(); } catch (err) { return res.status(401).json({ code: 401, message: 登录已过期 }); } }要注意JWT_SECRET 一定不能写在代码里放在.env文件里并且加入.gitignore防止被提交到仓库。4.3 合同 CRUD 与多条件查询SQL 预编译是底线合同列表查询是后端接口里参数最多的一个。设计接口时我统一用 GET 请求参数包括page、pageSize、keyword搜索合同名称或编号、type、status、startDate、endDate。controller 里把参数传给 model 层model 层拼 SQL。拼 SQL 必须用 mysql2 的预编译参数不能用字符串拼接。字符串拼接 SQL 是最典型的注入漏洞用户输入一个; DROP TABLE...基本就能把库拆了。正确写法const sql SELECT * FROM contract WHERE 11 ${keyword ? AND (name LIKE ? OR contract_no LIKE ?) : } ${status ! undefined status ! ? AND status ? : } AND create_time BETWEEN ? AND ? ORDER BY created_at DESC LIMIT ? OFFSET ? ; const params [ ...(keyword ? [%${keyword}%, %${keyword}%] : []), ...(status ! undefined status ! ? [status] : []), startDate, endDate, pageSize, (page - 1) * pageSize ];用WHERE 11是一个拼接条件的技巧它本身没有任何性能开销却能省掉一堆第一个条件要不要加 AND的判断逻辑。这里有个边界问题日期范围查询时前端传来的endDate是当天 0 点如果数据库里存的是2025-01-15 10:30:00直接BETWEEN会漏掉当天 10 点之后的数据。所以查询时要给 endDate 加一天或者数据库按日期类型存储的字段要注意格式。这个坑我在项目中真实遇到过一次排查了半天才意识到是时间边界问题。4.4 文件上传与静态资源访问multer 配置要稳文件上传用 multer。合同系统的上传量不大但要求稳定。multer 的 diskStorage 把文件保存到本地磁盘配置上要处理两个问题重命名文件和保存路径。const multer require(multer); const path require(path); const fs require(fs); const storage multer.diskStorage({ destination(req, file, cb) { const datePath dayjs().format(YYYYMM); const uploadDir path.join(__dirname, ../uploads, datePath); if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir, { recursive: true }); } cb(null, uploadDir); }, filename(req, file, cb) { const ext path.extname(file.originalname); const uniqueName ${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}; cb(null, uniqueName); } }); const upload multer({ storage, limits: { fileSize: 10 * 1024 * 1024 }, fileFilter(req, file, cb) { const allow [.pdf, .jpg, .jpeg, .png, .doc, .docx, .xls, .xlsx]; const ext path.extname(file.originalname).toLowerCase(); if (!allow.includes(ext)) { return cb(new Error(文件类型不支持)); } cb(null, true); } });文件名用时间戳加随机数重新生成是为了避免中文文件名在不同系统上出现编码问题也避免两个用户上传同名的文件互相覆盖。上传接口里返回给前端的是可访问的 URL 路径例如/uploads/202501/1700000000-1234567890.pdf。同时后端把这串路径存到 contract 表的 file_path 字段下载时直接拼上服务器域名就行。静态资源访问要在 app.js 里注册app.use(/uploads, express.static(path.join(__dirname, uploads)));4.5 审批流与状态机设计把流转规则固定住合同审批的核心不是写前端页面而是把审批流程设计清楚。我用的是状态机模型。合同从草稿开始每一步操作都会触发状态迁移迁移条件在 controller 里做统一判断。以提交审批为例草稿状态的合同创建人可以提交if (contract.status ! 0) { return res.status(400).json({ code: 1, message: 当前状态不能提审 }); }提交之后状态从 0 变成 1同时在 approval 表插入一条提交审批的记录并把待办事项分配给部门审批人。部门审批人点了通过状态变成 2自动把待办转给法务点了驳回状态变成 -1退回创建人创建人可以修改后再次提审。法务审批通过后状态变成 4创建人进行归档操作状态变成 5。为什么用状态机而不是自由流转因为企业合同审批必须留痕、可控、有唯一事实来源。如果业务人员想怎么改就怎么改审批记录和状态对不上审计时没法交代。状态机把所有合法路径定死非法操作在接口层就被拦截掉了。审批记录表的存在让每一步操作都可以追溯谁在什么时间做了什么决定全部留档。审批流的待办处理前端是待我审批列表。后端查询逻辑是找出所有当前状态等于我这个角色下一步处理状态的合同加上审批表里指定了 approver 是我的记录。这里要注意审批记录不要只在状态变化时插入每次动作都插入才能形成完整的审批时间线。5. 前后端联调、部署以及实战中的坑5.1 开发环境跨域代理比直接放开 CORS 更舒服开发时前端跑在 5173 端口后端跑在 3000 端口浏览器里直接请求就会出现跨域问题。跨域的根源是同源策略浏览器限制跨源请求去读响应。我推荐的做法是前端配置 Vite 代理把/api开头的请求转发到后端。在vite.config.js里export default defineConfig({ server: { proxy: { /api: { target: http://localhost:3000, changeOrigin: true } } } });配置好之后前端请求/api/contract/listVite 的开发服务器会把它转发到http://localhost:3000/api/contract/list浏览器看到的请求是同源的跨域问题在开发阶段就消失了。后端同时配合 cors() 中间件做兜底这样即使有直接绕过代理的请求也能正常通过。有人会问直接让后端放开所有跨域不就行了开发环境可以但生产环境如果还用这种宽松策略就相当于敞开大门让别人随便调用接口。正确姿势是生产环境用 Nginx 做反向代理后端也不暴露到公网接口必须走代理转发。5.2 生产环境构建与部署pm2 守护 Node 进程前端构建npm run build构建完成后会生成 dist 目录里面是纯静态文件。部署方案我选的是 Nginx 托管前端静态文件加反向代理转发后端接口。Nginx 的关键配置server { listen 80; server_name contract.example.com; root /var/www/contract-web/dist; index index.html; location / { try_files $uri $uri/ /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; } location /uploads/ { proxy_pass http://127.0.0.1:3000/uploads/; } }try_files是为了支持 vue-router 的 history 模式否则刷新/contract/list这类前端路由时Nginx 会返回 404。location /api/里的proxy_pass结尾带不带斜杠行为差异很大带斜杠会把/api前缀去掉不带斜杠则保留这个一定要根据后端路由来配我一开始因为这里少写一个斜杠排查了半天。后端进程用 pm2 托管保证服务崩溃后能自动重启重启时不会影响同一个机器上的其他项目npm install -g pm2 pm2 start server.js --name contract-server pm2 save pm2 startuppm2 startup会生成开机自启的命令这样服务器重启后 Node 服务会自动跑起来。日志默认在~/.pm2/logs下线上排查问题全靠它。5.3 常见问题排查这几类坑几乎每个项目都会遇到端口被占用。启动后端时提示EADDRINUSE: Address already in use :::3000说明 3000 端口被别的进程占了。Windows 下用netstat -ano | findstr :3000查 PID然后taskkill /PID 进程号 /F结束掉Linux 下用lsof -i:3000查。前端请求接口报 404。先看请求路径是否带上了/api前缀再看 Nginx/Vite 代理是否匹配上了最后看后端路由是否正确。很多时候前端和后端对接口路径的约定不一致这种问题越早用接口文档约束越省事。接口返回 401。先确认请求头里有没有 Authorization再看 token 是否过期最后检查鉴权中间件的解析逻辑和签发逻辑是否用了同一个 secret。上传文件报文件类型不支持。改文件后缀能绕过部分校验但根本上还是要后端对文件内容做校验生产环境至少要检查 MIME 类型和 magic number前端校验只是友好的提示。MySQL 查询结果比预期少了几条。重点查时间字段的时区问题以及分页查询的total统计和当前页查询的 WHERE 条件是否完全一致。建议先用一条 SQL 把总数查出来再对明细 SQL 单独测试两边条件保持一致后再拼页码参数。5.4 性能与安全加固等出问题再补就晚了合同系统上线后随着合同数据越来越多性能和安全问题会逐渐暴露。我建议从一开始就做几件事数据量起来后查询性能瓶颈主要在建索引。contract 表的 contract_no 做唯一索引status、created_at、department_id 做普通索引。索引建少了数据量过几万条之后列表接口的响应时间会明显变长。接口层加参数校验。前端做了校验不代表后端可以信任。用 zod 或者 joi 在后端再校验一遍必填字段、金额范围、日期格式防止有人绕过前端直接调接口。登录接口加限流。用 express-rate-limit 限制同一个 IP 每小时的登录失败次数防止暴力破解。我一般配置 15 分钟最多 20 次登录尝试超过就拒绝一段时间。文件上传目录要禁止执行脚本Nginx 里对/uploads/路径只开放静态文件读取不解析任何动态脚本。JWT 密钥、数据库密码、静态资源访问密钥所有敏感配置放到.env文件并加入.gitignore绝对不能提交到代码仓库。合同管理系统的部署形态并不复杂但“能跑”和“跑得稳”之间差的就是这些细节索引、限流、错误处理、日志留存、备份策略。这些事不用一口气全部做完但在项目一开始就留出位置后面补起来会顺手很多。最后分享一点个人体会。做完这套系统我最大的感触是企业管理系统的难点往往不在技术而在业务流程的抽象和边界划分。合同状态机设计清楚权限模型梳理明白前后端接口约定统一编码只是把设计翻译成代码的过程。技术选型上Vue 加 Node.js 的组合对于中小型内部系统来说开发效率高维护成本低坑也比想象中少。真正容易出问题的反而是环境配置、时间边界、跨域代理、部署细节这类基础知识把基础啃扎实比堆砌高大上的架构更有用。如果文章里的某个细节能帮你少加班一次那这篇文章就值了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价