☰
招投标系统开发实践:Node.js与Vue构建B端业务系统全流程
2026/10/13 8:00:46 网站建设 项目流程

做招投标系统这两年,我最大的感受是:它不像电商那样追求极致并发,反倒更像一个“流程零件特别多、角色边界特别清、文件流转特别严肃”的业务系统。也正因为这个特点,我最终选定了Node.js加Vue这套组合来落地整套系统。今天把整个项目的关键路径从头捋一遍,从选型逻辑、环境搭建、核心业务建模,到前端路由权限、视频流和地图集成,再到打包部署和桌面端扩展,把踩过的坑和沉淀下来的做法都写出来,希望能帮到正在做类似B端业务系统、或者想用Vue和Node.js扛起一个真实项目的朋友。

这套系统覆盖了招投标业务的主流程:招标人发布公告,投标人在线报名和递交标书,到时间后开标直播,专家在线评标,最后定标与公示。前端基于Vue 3加Vite,后端是Node.js,数据库用MySQL,文件存储上结合了本地磁盘和MinIO。全文不空谈理论,只讲我实际验证过的东西。

1. 为什么招投标这条线最终选定了Node.js加Vue

1.1 招投标业务的真实特征,决定了技术基调

招投标系统最容易被外行低估的地方,是它的“流程重量”。一个项目从发布招标公告到最终中标公示,中间涉及招标人、投标人、评标专家、监管方好几类角色,每个角色看到的功能界面完全不同;同一个标段的生命周期又横跨报名、投递、开标、评标、定标多个阶段。开发这种系统的最大风险并不是某一瞬间的高并发,而是状态错乱和文件丢失。

所以我在选型时优先考虑两件事:一是前端能不能把多步骤、多角色的复杂交互拆得足够清楚,二是有没有一套轻量可控的后端方案,能够把文件传输、权限校验、状态流转这些“脏活累活”接得住。Vue的组件化和响应式设计在处理这种复杂交互时有天然优势,而Node.js的异步非阻塞模型,让大文件写入、进度通知这类I/O密集操作实现起来非常舒服。我们上线后遇到过投标截止前集中上传标书的场景,几十个文件并发写入,Node.js单机轻松顶住,这在我以前用Java同步模型时反而要做更多线程池调优。

1.2 为什么不是Spring Boot,也不是纯前端假接口

那段时间“springboot vue前后端分离”确实是技术社区的热搜词,很多团队习惯走Java后端路线。我不否认Spring Boot在企业级市场很成熟、团队好招人、中间件也多。但回到这个项目本身,业务量级并没有到需要Java重型事务框架撑场面的程度,真正需要的是响应速度快、前后端数据结构统一、开发迭代敏捷的架构。

用Node.js最直接的好处是语言统一。前端同事不用切换思维就能读懂后端逻辑,接口字段怎么定义,前后端直接用TypeScript类型定义对齐,少了很多“这个字段名到底指什么”的扯皮。另一层好处是Node.js生态与前端工具链无缝衔接,比如权限这块后端返回菜单树,前端用同一套结构渲染路由,两边几乎没有认知落差。

我也不建议所有团队都放弃Java。如果团队本身以Java为主,或者系统需要跟复杂工作流引擎、财务系统深度集成,那Spring Boot依然是稳妥选项。但从零搭建一套招投标系统,Node.js和Vue这套组合在开发效率和维护成本上确实更轻巧。我当时评估的几个关键维度如下:

对比维度Node.js + VueSpring Boot + Vue
团队语言统一度前后端都可TypeScript,认知一致前后端语言不同,接口文档要求更高
文件并发上传处理异步I/O模型,内存占用可控依赖线程池与异步配置
多人协作上手速度全栈通吃,单兵也能扛需要更细的分工
与前端构建工具链Vite、npm生态无缝衔接需要通过网关或Maven集成

1.3 选型本质是在团队能力和业务确定性之间权衡

聊到这里必须泼一盆冷水:技术选型从来不是“哪个更好”的问题,而是“哪个更适合你当前的条件”。我在这个项目上选Node.js,核心原因是团队里前端能力强、Java经验薄弱,且系统的业务规则主要围绕状态流转和文件处理,并没有复杂到需要分布式事务或重型中间件。

如果你遇到的情况是团队Java沉淀深厚,或者甲方明确要求以Java技术栈验收交付,那就不必为了“前后端统一”强行换Node.js。另外,招投标这类系统往往还涉及与第三方CA认证、电子签章、短信网关对接,这些服务的官方SDK通常Java和Node.js都提供,但Java生态的行业案例确实更多。选型时要把这些现实因素一起算进去,不能只盯着技术上的理想状态。

2. 动手前先过一遍:Node环境、npm权限和脚手架里的老坑

2.1 Node版本管理与环境变量配置

很多人上来就双击安装包装了最新版Node.js,用一段时间才发现某个旧项目跑不起来,或者某次升级后全局工具全废了。我的建议是第一步就装nvm-windows,用它管理多个Node版本,避免日后切换版本的血泪史。开发这套招投标系统时我固定在Node.js 18 LTS版本,LTS版本稳定性优先,完全没必要追新。

装完之后环境变量要单独检查。安装包默认会在PATH里写入Node目录,这点一般没问题,真正容易出问题的是npm全局包的路径。我习惯把全局模块和缓存目录单独指定,避免C盘逐渐被占满:

npm config set prefix "D:\nodejs\node_global" npm config set cache "D:\nodejs\node_cache"

然后在系统环境变量里补充NODE_HOME=D:\nodejs,并把%NODE_HOME%\node_global加入PATH。配置完成后随手执行node -v和npm -v验证一下。这一步看起来基础,但很多后来“npm装不上包”的疑难杂症,根源就是环境变量里混入了旧版本路径或权限不对。

2.2 PowerShell与npm.ps1执行策略的完整排查

开发前端项目,Windows上最常遇到的报错之一就是:

npm : 无法加载文件 D:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本。

第一次看到这个我也愣了一下,以为Node装坏了。其实根源很简单:Windows PowerShell默认执行策略是Restricted,不允许直接运行.ps1脚本,而npm在PowerShell里实际上是通过npm.ps1这个脚本触发的。解决方式有三种,按推荐顺序排:

  1. 以管理员身份打开PowerShell,执行Set-ExecutionPolicy RemoteSigned。这个策略下本地脚本不受限制,从远程下载的脚本需要签名,安全性较高,我现在主力使用这种方式。
  2. 如果只是临时跑命令,直接打开CMD窗口即可。CMD不检查PowerShell执行策略,npm命令照常使用。
  3. 想彻底关闭限制可以用Set-ExecutionPolicy Unrestricted,但会有一定安全风险,不建议。

执行后用Get-ExecutionPolicy确认返回RemoteSigned。这个坑对新手极其不友好,但排起来就一分钟的事。后来我每次帮同事配环境,都会提前把这个报错截图放进文档。

2.3 Vue脚手架选择:Vite才是当下主流

以前创建Vue项目习惯用vue create,也就是Vue CLI,底层是webpack,全量编译导致开发服务器启动极慢。现在官方脚手架create-vue已经默认走Vite,基于ESM按需编译,项目越大体验差距越明显。创建招投标前端项目时,我用的是:

npm create vue@latest

选项里勾上TypeScript、Vue Router、Pinia,后面写动态路由和状态管理会顺畅很多。另外调试阶段记得装Vue Devtools插件,Vue 3对应的是6.x版本。它不仅能看组件树和props,还能直接检查路由信息和Pinia状态。招投标页面之间传参特别频繁,出问题时打开Devtools看路由记录,比打console强十倍。

3. 招投标业务的核心建模:状态机、标书文件与角色权限

3.1 用状态机管理从公告到公示的流转

招投标系统最不能含糊的就是业务状态。如果一段招标流程里的状态字段直接散落在业务代码里写死,后期加一个“投标截止延期”的需求,要改的地方会让人崩溃。

我在设计时把招标项目这一实体拆成了明确的状态链:草稿、已发布、报名中、投标中、已开标、评标中、已定标、已公示、已签约,加上单独的终止和作废状态。后端用一个统一的状态机模块做转换校验,每个状态的可达路径都是显式定义的。

const TENDER_STATUS = { DRAFT: 'draft', PUBLISHED: 'published', ENROLLING: 'enrolling', BIDDING: 'bidding', OPENING: 'opening', EVALUATING: 'evaluating', DECIDED: 'decided', ANNOUNCED: 'announced' }; const TRANSITIONS = { [TENDER_STATUS.DRAFT]: [TENDER_STATUS.PUBLISHED, TENDER_STATUS.CANCELLED], [TENDER_STATUS.PUBLISHED]: [TENDER_STATUS.ENROLLING, TENDER_STATUS.BIDDING], [TENDER_STATUS.ENROLLING]: [TENDER_STATUS.BIDDING, TENDER_STATUS.CANCELLED], // 其余状态转换依业务规则补全 }; function canTransition(from, to) { return TRANSITIONS[from]?.includes(to) || false; }

用这套方式定义业务规则后,任何状态迁移都先在中间件里做合法性校验,非法迁移直接拒绝,比在业务代码里到处写if判断可靠得多。前端也维护同一套枚举,不同角色看到的操作按钮由当前状态决定,状态加角色双条件控制界面,误操作的概率大幅下降。

3.2 标书文件上传不能只是“传上去”

投标文件是招投标系统的生命线,前端上传体验和存储安全性同等重要。技术选型上,Node.js端我用multer配合流式写入,避免一次性把大文件加载进内存。文件名不要直接用原始名,而是生成项目编号加时间戳加随机串,路径上按项目分目录。

const multer = require('multer'); const storage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, path.join(UPLOAD_ROOT, req.params.projectId)); }, filename: (req, file, cb) => { const ext = path.extname(file.originalname); cb(null, `${req.params.projectId}_${Date.now()}_${randomId()}${ext}`); } });

中小规模系统用本地磁盘加定时备份就能扛住;文件量大或需要跨机房容灾时,直接对接MinIO或云OSS,Node.js社区都有成熟SDK。不管用哪种存储,我都会在数据库里记录文件MD5和大小,上传完成后后端重新计算哈希比对,防止传输过程中文件损坏。这个细节做下来,评标专家那边关于“标书打不开”的工单基本绝迹。

3.3 角色权限落到代码的形态

招投标天然是多角色系统,至少包括管理员、招标代理、投标人、评标专家、监管人员。我的权限模型没有绕弯,就是经典RBAC:用户属于角色,角色绑定菜单权限和操作权限,后端在接口层通过中间件做校验,前端根据返回的菜单树渲染页面并过滤按钮。

这里必须强调一点:前端隐藏按钮只是体验层的处理,真正的权限校验必须落在接口层。我见过不少项目把操作按钮用v-if藏起来就以为有权限了,实际上别人直接调接口就能绕过。我在Node.js端用一个中间件对涉及敏感操作的路由统一校验,前端只是做视觉配合。比如提交投标文件这个动作,后端会校验当前用户所属角色、对应报名记录、当前时间是否在投标窗口内,三层校验都通过才放行。

4. 前端侧真正要花心思的地方:路由权限和状态管理

4.1 动态路由:权限菜单由后端决定

招投标系统中,不同角色登录后看到的菜单完全不同。投标人看到的是“我的报名”“标书制作”“项目检索”,评标专家看到的是“待评标列表”“我的评分记录”。如果这些路由写死在静态配置里,虽然也能用,但每次加角色或调菜单都要改代码重新发布。

我采用的是登录后动态生成路由的方案。用户登录成功后,后端返回一份权限菜单树,前端根据菜单树的组件标识,用import.meta.glob批量加载对应组件,然后逐级调用router.addRoute()注册。

const viewModules = import.meta.glob('../views/**/*.vue'); function buildRoute(item) { return { path: item.path, name: item.name, component: item.component ? viewModules[`../views/${item.component}.vue`] : undefined, meta: item.meta, children: item.children ? item.children.map(child => buildRoute(child)) : [] }; } menuTree.forEach(item => { router.addRoute(buildRoute(item)); });

这里有公认的两坑。第一个是刷新页面后动态路由丢失,因为刷新时Pinia里没有登录态,还没去后端拉菜单。我的解决方式是在全局路由守卫beforeEach里判断:本地有token但store中没有路由记录,就先请求用户信息和菜单,拿到后再执行next({ ...to, replace: true })重新进入目标路由,注意用replace避免循环。第二个坑是动态路由和静态路由重名导致警告,最好把404页面放到动态路由注册完之后再添加。

4.2 路由参数怎么传,其实关系到刷新体验

“vue路由参数”是搜索量很高的话题,招投标场景尤其明显。从列表页进入项目详情、从项目详情进入投标文件制作页,中间要传项目ID、标段ID、招标公告ID一堆参数。我用两种方式组合处理:路径参数适合“详情页”这类强业务关系的页面,例如/tender/detail/:id;query参数适合附带筛选条件或临时上下文的情况,例如?type=public&page=2。

很多新手偏好用编程式params传参,因为看起来不在URL里暴露。但要注意,Vue Router的params在页面刷新时并不保证还会留在路由记录中,如果后续逻辑依赖这个参数,刷新就会出问题。我的经验是:涉及业务主键的,一律走路径参数或query参数;params只用来传组件内部瞬时状态,比如弹窗打开前的回跳位置。这样用户无论怎么刷新,核心业务上下文都不会丢。招投标场景里用户常常一个流程走一半去翻公告,再回来还得回到原页面,这个设计很关键。

4.3 状态管理:选择Pinia并控制存储边界

Vue官方生态现在对Vue 3明确推荐Pinia,Vuex虽然还能用,但新项目没必要再走老路。Pinia用起来比Vuex简练,定义store就是纯TS对象,也天然支持setup写法。招投标系统里,我把状态主要划分为三类:登录用户与权限(userStore)、页面流程中的草稿数据(formStore)、全局UI状态(uiStore)。

重点提醒存储边界。用户权限数据登录后放store,同时持久化到localStorage方便刷新时快速恢复;但投标文件的草稿数据不适合长期放在store里,量大,占用内存,刷新就丢。我选择进入“标书制作”页面时从后端加载草稿,填写过程中的中间态用watch定期自动保存到后端,这样浏览器意外崩溃最多丢失几秒输入。用过这类功能的人都清楚,投标人填报几十页商务资格信息时,最怕的就是“白填了”。

5. 招投标场景下的视频流与地图集成,比想象中更刚需

5.1 开标直播:用m3u8方案把实时画面送进评审流程

招投标系统如果只做表单和文件上传,后期会明显感觉少了临场感。最典型的需求在开标环节:有些项目需要公开直播开标过程,让投标人在线观看唱标、公开报价等环节。现实中大量技术咨询集中到“vue播放m3u8播放器”,正是这类业务需求推动的。

HLS协议把整条直播流切成一堆ts切片文件,m3u8是播放列表文件,浏览器端按列表顺序拉取切片播放。它的优势是天然支持自适应码率和Apple全系设备,缺点是延迟有几秒,但对于开标直播来说完全不是问题。

在Vue里我建议直接使用hls.js,代码很轻:

import Hls from 'hls.js'; function playHls(url, videoElement) { if (Hls.isSupported()) { const hls = new Hls(); hls.loadSource(url); hls.attachMedia(videoElement); hls.on(Hls.Events.ERROR, (_, data) => { // 记录错误并尝试自动重连 }); } else if (videoElement.canPlayType('application/vnd.apple.mpegurl')) { videoElement.src = url; } }

封装成组件后,开标页面直接挂直播窗口,再把播放器实例放到Pinia管理,关闭页面时记得调用destroy()。如果流地址需要鉴权,通常会在URL上带签名或token,拼接时要用encodeURIComponent处理特殊字符,否则很容易出现偶发黑屏。

5.2 在Vue里集成腾讯地图的推荐封装

招投标系统里地图并不是核心功能,但一旦要展示开标场地、评标地点、线下服务网点就成了刚需。我用的是腾讯地图JavaScript SDK,在Vue项目里不推荐每次进入页面都动态插入script标签,而是做一个单例加载函数,全局只加载一次,用Promise缓存加载完成状态:

let mapReadyPromise = null; function loadTMap() { if (mapReadyPromise) return mapReadyPromise; mapReadyPromise = new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = `https://map.qq.com/api/gljs?v=1.exp&key=${MAP_KEY}`; script.onload = () => resolve(window.qqmapsdk); script.onerror = reject; document.head.appendChild(script); }); return mapReadyPromise; }

接着封装一个TMap.vue组件,在onMounted里等Promise完成后初始化地图,用watch监听标记点数组变化刷新Marker。注意销毁组件时调用地图实例的销毁方法来释放资源,否则在管理后台反复跳转会明显增加浏览器内存。开标场地列表页加入地图视图后,投标人不用再切到导航App搜地址,体感好了很多。

6. 打包、部署与桌面端:线上环境里的最后一公里

6.1 Vite构建配置:不止是执行npm run build

很多项目开发时一切顺利,一执行npm run build就冒出问题。第一个要留意的是部署路径。系统部署在域名根路径时base用默认的/没问题;部署到子目录,比如https://xxx.com/tender/,就必须在vite.config里设置base: '/tender/',否则index.html里的静态资源会404。

第二个是构建产物体积。招投标前端因为引入Element Plus、地图SDK、视频播放器,第一次打包主chunk有时接近两三MB,首屏很慢。我做了两处改进:用build.rollupOptions把vue、element-plus、hls.js拆成独立chunk,利用浏览器并行加载和缓存;再配合nginx的gzip压缩,首屏体感提升明显。

// vite.config.js export default { base: '/tender/', build: { chunkSizeWarningLimit: 1500, rollupOptions: { output: { manualChunks: { vue: ['vue', 'vue-router', 'pinia'], ui: ['element-plus'], media: ['hls.js'] } } } } };

6.2 Nginx部署与history路由回退

前端用了history模式后,直接刷新某个二级路径会404,因为nginx默认去找对应的真实文件目录。解决办法是加try_files回退:

location /tender/ { alias /var/www/tender/; try_files $uri $uri/ /tender/index.html; }

同时把 /api 路径反向代理到Node.js服务端口。这里建议在nginx层开启gzip和合理的静态资源缓存,避免每次刷新都重新请求几MB的JS。Node服务本身建议用pm2守护进程,至少保证崩溃自动重启以及日志切分。

6.3 用Electron把系统搬进桌面端

后期有几个客户提出,希望系统能像本地软件一样打开,离线时也能查看部分公告内容。正好项目是Vue SPA,打包成桌面应用最省事的路径是Electron。我用的是vite-plugin-electron方案,在Vite构建时同时产出桌面端入口。

这里分享一个踩过的坑:Electron渲染进程加载开发服务器和生产构建文件的方式完全不同,主进程代码里要区分环境:

// electron/main.js if (!app.isPackaged && process.env.VITE_DEV_SERVER_URL) { win.loadURL(process.env.VITE_DEV_SERVER_URL); } else { win.loadFile('dist/index.html'); }

主进程与渲染进程通信要用IPC,我通过preload脚本用contextBridge暴露白名单API,比如打开本地标书文件、读取本地配置。不要把Node.js的fs直接暴露给渲染进程,那等于给应用开了后门。Electron路由用history模式打包后刷新二级页面一样会白屏,要么改用hash模式,要么增加自定义协议拦截。这多花了一两天处理,但桌面端带来的信任感是Web页面替代不了的。尤其面向政企客户验收时,一个能双击安装、带桌面图标的客户端,说服力完全不一样。

做到后期,我最大的一个体会是:招投标系统的技术难点往往不在某个高深框架,而在于能不能把业务状态、文件安全、角色权限这些底层设计想清楚再动手。很多看起来琐碎的坑,比如npm执行策略、动态路由刷新丢失、History模式部署404,其实都是建筑在基础设计之上的“基础设施问题”,提前规划能少走很多弯路。如果你也正在用Vue和Node.js做这类行业系统,建议从状态机的边界设计开始,先把业务流程吃透,再谈页面和接口的优雅。方向对了,后面会顺很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询