銷系統(tǒng)設(shè)計(jì):全棧實(shí)現(xiàn)與部署實(shí)踐)
基于 Node.js Vue 的財(cái)務(wù)電子報(bào)銷系統(tǒng)設(shè)計(jì)與實(shí)現(xiàn)說(shuō)實(shí)話我最初接到基于 nodejs_vvue 的企業(yè)財(cái)務(wù)電子報(bào)銷系統(tǒng)設(shè)計(jì)與實(shí)現(xiàn)這個(gè)選題時(shí)第一反應(yīng)是報(bào)銷系統(tǒng)這種活兒技術(shù)含量看著不高但真正做起來(lái)全是細(xì)節(jié)。傳統(tǒng)報(bào)銷流程里那些痛點(diǎn)——紙質(zhì)單據(jù)滿天飛、財(cái)務(wù)審核對(duì)賬靠肉眼、員工墊資周期長(zhǎng)——每一個(gè)都在逼著你把系統(tǒng)需求弄清楚。我這次用 Node.js 做后端、Vue 做前端把一個(gè)完整的電子報(bào)銷系統(tǒng)從零搭了起來(lái)從環(huán)境配置、數(shù)據(jù)庫(kù)設(shè)計(jì)、接口開(kāi)發(fā)到前端聯(lián)動(dòng)中間踩了不少坑尤其是 Windows 下 Node.js 安裝和 npm 權(quán)限問(wèn)題幾乎每個(gè)新手都會(huì)撞上一次。這篇就把整個(gè)設(shè)計(jì)和實(shí)現(xiàn)過(guò)程掰開(kāi)揉碎講清楚適合正在做畢業(yè)設(shè)計(jì)、企業(yè)內(nèi)部小工具開(kāi)發(fā)或者想入門全棧實(shí)戰(zhàn)的讀者參考。廢話不多說(shuō)先交代項(xiàng)目背景和技術(shù)選型的思路然后按環(huán)境準(zhǔn)備 → 后端模塊 → 前端實(shí)現(xiàn) → 部署上線的順序把每個(gè)關(guān)鍵環(huán)節(jié)的設(shè)計(jì)原因和實(shí)操步驟都攤開(kāi)講。1. 為什么選擇 Node.js Vue 來(lái)做電子報(bào)銷一個(gè)不折騰的選型過(guò)程1.1 傳統(tǒng)報(bào)銷流程的痛點(diǎn)決定了系統(tǒng)該有什么能力在沒(méi)做系統(tǒng)之前企業(yè)里的報(bào)銷流程是這樣的員工出差回來(lái)整理一摞發(fā)票、行程單貼到報(bào)銷單上手寫金額、寫事由然后找部門領(lǐng)導(dǎo)簽字再跑到財(cái)務(wù)那邊排隊(duì)核驗(yàn)。財(cái)務(wù)拿到單據(jù)后要人工核對(duì)發(fā)票真?zhèn)巍⒂?jì)算總額、確認(rèn)預(yù)算科目最后還要手工錄入財(cái)務(wù)軟件。整個(gè)過(guò)程少則三五天多則一兩周員工墊著錢財(cái)務(wù)加班干活中間任何一張發(fā)票貼錯(cuò)了都要打回去重來(lái)。所以電子報(bào)銷系統(tǒng)要解決的核心問(wèn)題很明確員工在線填寫報(bào)銷單、拍照或上傳電子發(fā)票、系統(tǒng)自動(dòng)計(jì)算金額、按組織架構(gòu)流轉(zhuǎn)審批、財(cái)務(wù)在線審核并導(dǎo)出數(shù)據(jù)。這意味著系統(tǒng)至少需要用戶管理、報(bào)銷單管理、審批流管理、附件管理、數(shù)據(jù)統(tǒng)計(jì)五個(gè)核心模塊。技術(shù)選型的第一步就是確認(rèn)這些模塊在 Node.js 生態(tài)里都有成熟方案不需要我重復(fù)造輪子。1.2 為什么是 Node.js而不是 Spring Boot 或者 PHP這幾年 Spring Boot Vue 的前后端分離方案在中小型系統(tǒng)里很流行網(wǎng)上模板也一堆。但我在評(píng)估后還是選了 Node.js主要基于三個(gè)理由。第一開(kāi)發(fā)效率。報(bào)銷系統(tǒng)的業(yè)務(wù)邏輯不算極端復(fù)雜但涉及的狀態(tài)流轉(zhuǎn)和權(quán)限分支很多。Node.js 用 JavaScript 一把梭前后端共用一套語(yǔ)言寫接口和寫頁(yè)面的心智負(fù)擔(dān)小很多尤其是像我這種需要一個(gè)人同時(shí)搞定前后端的場(chǎng)景能省下不少上下文切換的時(shí)間。第二生態(tài)匹配。Node.js 的express或koa中間件機(jī)制非常靈活做 JWT 鑒權(quán)、文件上傳、Excel 導(dǎo)入導(dǎo)出都有非常成熟的庫(kù)。配合multer、jsonwebtoken、mysql2、exceljs這些模塊基本可以滿足報(bào)銷系統(tǒng)百分之九十以上的能力需求。第三部署簡(jiǎn)單。企業(yè)內(nèi)部小系統(tǒng)往往沒(méi)有專業(yè)的運(yùn)維環(huán)境Node.js 應(yīng)用一個(gè)node app.js就能跑起來(lái)不像 Java 應(yīng)用要裝 Tomcat、配 JVM 參數(shù)。配合pm2做進(jìn)程守護(hù)一臺(tái)普通 Windows 服務(wù)器或者 Linux 虛擬機(jī)就能穩(wěn)定運(yùn)行。這里也順便回應(yīng)一個(gè)很多人糾結(jié)的問(wèn)題Node.js 底層是不是真的用 V8 引擎是的Node.js 的 JavaScript 解析和運(yùn)行靠的是 Chrome 的 V8 引擎所以它的異步 I/O 能力很強(qiáng)特別適合處理報(bào)銷系統(tǒng)這種大量短請(qǐng)求、偶爾上傳大文件的 IO 密集型場(chǎng)景。如果項(xiàng)目是計(jì)算密集型比如大量復(fù)雜的財(cái)務(wù)分?jǐn)偹惴?Node.js 不是最優(yōu)解但報(bào)銷審批這個(gè)場(chǎng)景完全夠用且表現(xiàn)穩(wěn)定。1.3 技術(shù)棧全景圖最終我采用的技術(shù)棧清單如下層次選型說(shuō)明后端框架Express路由、中間件機(jī)制成熟文檔豐富數(shù)據(jù)庫(kù)MySQL 8.0事務(wù)支持完善報(bào)銷數(shù)據(jù)強(qiáng)調(diào)一致性O(shè)RMSequelize模型定義清晰遷移方便鑒權(quán)JWT bcrypt無(wú)狀態(tài)會(huì)話接口鑒權(quán)簡(jiǎn)單高效文件上傳Multer支持單文件、多文件可配大小限制Excel 處理ExcelJS導(dǎo)出報(bào)銷明細(xì)報(bào)表用前端框架Vue 3組合式 API 編寫邏輯更清晰UI 組件庫(kù)Element Plus表單、表格、彈窗開(kāi)箱即用前端構(gòu)建Vite冷啟動(dòng)快打包配置簡(jiǎn)單狀態(tài)管理Pinia替代 VuexTS 友好HTTP 請(qǐng)求Axios請(qǐng)求攔截器統(tǒng)一處理 token部署工具PM2進(jìn)程守護(hù)崩潰自動(dòng)重啟這套組合本質(zhì)上是在快速交付和工程規(guī)范之間找一個(gè)平衡點(diǎn)??蚣懿蛔沸碌膊焕吓f用的人多遇到問(wèn)題搜得到答案。這比選一個(gè)看起來(lái)很酷但社區(qū)冷清的方案穩(wěn)妥得多。2. 開(kāi)工前的第一道坎Node.js 環(huán)境配置與 npm 在 Windows 上的權(quán)限坑我相信不少讀者看到這個(gè)標(biāo)題就笑了——npm : 無(wú)法加載文件 D:\Program Files\nodejs\npm.ps1因?yàn)樵诖讼到y(tǒng)上禁止運(yùn)行腳本這應(yīng)該是 Node.js 新手在 Windows 上遇到的第一個(gè)玄學(xué)報(bào)錯(cuò)。我做這個(gè)報(bào)銷系統(tǒng)項(xiàng)目時(shí)重裝系統(tǒng)后第一天就撞上了。這里把完整排查過(guò)程寫出來(lái)你照著做就行。2.1 Node.js 安裝的版本選擇和安裝方式首先說(shuō)版本。Node.js 官網(wǎng)提供兩個(gè)版本線LTS長(zhǎng)期支持版和 Current最新嘗鮮版。我的建議是 LTS而且盡量選 18 或 20 這樣的較新 LTS因?yàn)閳?bào)銷系統(tǒng)要用的mysql2、express這些庫(kù)對(duì)新版本 Node 的兼容性已經(jīng)非常成熟沒(méi)必要為了嘗鮮陷進(jìn)依賴兼容的泥潭。安裝方式有兩種直接下載.msi安裝包或者下載.zip免安裝版。新手我強(qiáng)烈推薦.msi一路 Next 就行安裝包會(huì)自動(dòng)幫你配置好環(huán)境變量。如果你用的是免安裝版則需要手動(dòng)添加環(huán)境變量解壓到某個(gè)目錄比如D:\nodejs然后把該目錄和D:\nodejs\node_global一起加到系統(tǒng)的Path變量里否則命令行里敲node -v會(huì)提示不是內(nèi)部或外部命令。這里有個(gè)小細(xì)節(jié)安裝完成后不要急著關(guān)終端先開(kāi)一個(gè)新的命令提示符窗口輸入下面兩行命令確認(rèn)安裝結(jié)果node -v npm -v注意一點(diǎn)如果你用的是 Windows PowerShell 或 VS Code 內(nèi)置終端此時(shí)大概率會(huì)直接報(bào) npm 的.ps1權(quán)限錯(cuò)誤而node -v卻正常。這就是下面要講的坑。2.2 npm.ps1 報(bào)錯(cuò)的根因與完整排查鏈路我遇到的具體報(bào)錯(cuò)是這樣npm : 無(wú)法加載文件 D:\Program Files\nodejs\npm.ps1因?yàn)樵诖讼到y(tǒng)上禁止運(yùn)行腳本。 有關(guān)詳細(xì)信息請(qǐng)參閱 https://go.microsoft.com/fwlink/?LinkID135170 中的 about_Execution_Policies。 所在位置 行:1 字符: 1很多人第一次看到這個(gè)報(bào)錯(cuò)以為是 Node.js 裝壞了跑去卸載重裝結(jié)果浪費(fèi)了兩小時(shí)還是同樣的問(wèn)題。實(shí)際上原因非常簡(jiǎn)單Windows 的 PowerShell 默認(rèn)不允許執(zhí)行.ps1腳本文件而 npm 提供給 PowerShell 的入口恰恰是一個(gè) PowerShell 腳本文件npm.ps1所以只要是 PowerShell 環(huán)境就會(huì)直接被安全策略攔下來(lái)。排查鏈路如下第一步先看當(dāng)前 PowerShell 的執(zhí)行策略。在 PowerShell 里運(yùn)行Get-ExecutionPolicy如果返回Restricted就說(shuō)明系統(tǒng)禁止運(yùn)行任何腳本文件這正是一切問(wèn)題的根源。第二步確認(rèn) npm 本身沒(méi)問(wèn)題。直接在命令提示符cmd里輸入npm -v如果 cmd 環(huán)境能正常輸出版本號(hào)就進(jìn)一步證明了問(wèn)題只出在 PowerShell 的腳本執(zhí)行策略上而不是 Node.js 安裝損壞。第三步解決。有兩個(gè)思路我建議兩個(gè)都配置上方案一以管理員身份打開(kāi) PowerShell運(yùn)行Set-ExecutionPolicy RemoteSignedRemoteSigned的含義是本地腳本可以運(yùn)行從互聯(lián)網(wǎng)下載的腳本必須有數(shù)字簽名才允許運(yùn)行。這是一個(gè)相對(duì)安全的設(shè)置也是很多開(kāi)發(fā)者的標(biāo)準(zhǔn)配置。方案二在 VS Code 里把默認(rèn)終端從 PowerShell 切換成 Command Promptcmd或者 Git Bash。操作方法VS Code 里按Ctrl Shift P打開(kāi)命令面板輸入Terminal: Select Default Profile選擇Command Prompt即可。這樣npm run dev這類命令不會(huì)走.ps1腳本也繞過(guò)了執(zhí)行策略限制。這套排查思路值得記住因?yàn)橐院笱b Vue 腳手架、跑 npm 腳本時(shí)凡是看到禁止運(yùn)行腳本字樣的報(bào)錯(cuò)基本都能用同一個(gè)方法解決。2.3 Vue 項(xiàng)目創(chuàng)建與依賴安裝從 create-vue 到 npm run dev整個(gè)系統(tǒng)前端的雛形我用官方腳手架創(chuàng)建。舊的寫法是vue create基于 Vue CLI現(xiàn)在 Vue 3 官方推薦的是create-vue命令如下npm create vuelatest執(zhí)行后會(huì)出現(xiàn)一系列交互式詢問(wèn)比如是否使用 TypeScript、是否使用 JSX、是否需要 Pinia、是否需要 Vue Router 等。我的選擇是TypeScript 先不啟用Router 啟用Pinia 啟用其余默認(rèn)。報(bào)銷系統(tǒng)這種中后臺(tái)項(xiàng)目不啟用 TypeScript 能少處理一些類型定義上的麻煩快速出活優(yōu)先。依賴安裝過(guò)程中還會(huì)遇到網(wǎng)絡(luò)問(wèn)題。npm 默認(rèn)源在國(guó)外國(guó)內(nèi)環(huán)境下安裝依賴經(jīng)??ㄋ阑蛘邎?bào)ETIMEDOUT。我的做法是把源切到國(guó)內(nèi)鏡像npm config set registry https://registry.npmmirror.com這里多說(shuō)一句淘寶的 npm 鏡像源一直在維護(hù)用npmmirror.com這個(gè)域名是目前的推薦配置。切換之后重新執(zhí)行npm install速度會(huì)明顯提升Vue 全家桶、Element Plus 這些依賴基本一兩分鐘內(nèi)就能裝完。依賴裝完執(zhí)行npm run dev看到終端輸出VITE v4.x ready in 500 ms ? Local: http://localhost:5173/前端骨架就算跑通了。然后建議第一時(shí)間安裝 Vue DevTools 瀏覽器插件。它是 Vue 調(diào)試的必需品尤其是做報(bào)銷單這種嵌套很深的表單組件時(shí)組件層級(jí)、 props 傳遞、狀態(tài)變更在 DevTools 里一目了然能省下大量console.log的時(shí)間。插件直接在瀏覽器擴(kuò)展商店搜索 Vue.js devtools 安裝即可注意選擇對(duì)應(yīng) Vue 3 的版本。2.4 開(kāi)發(fā)環(huán)境的其他配置編輯器與 Node.js 集成用 VS Code 還是 WebStorm我的體驗(yàn)是 VS Code 搭配Volar插件是目前 Vue 3 最順手的組合Volar 是 Vue 官方推薦的 VS Code 插件負(fù)責(zé)模板語(yǔ)法高亮、類型檢查和自動(dòng)補(bǔ)全。安裝完 Volar 后記得把 VS Code 的默認(rèn)格式化器設(shè)置為Volar不然保存時(shí)格式化可能不生效或者格式風(fēng)格不穩(wěn)定。有些讀者可能習(xí)慣在 PyCharm 里配置 Node.js因?yàn)橥惶组_(kāi)發(fā)工具里既要寫 Python 又要寫 Node。PyCharm 專業(yè)版可以在Settings - Languages Frameworks - Node.js里指定 Node 解釋器路徑然后直接在 PyCharm 的終端里跑 npm 命令。這個(gè)能跑通但我個(gè)人還是會(huì)單獨(dú)開(kāi) VS Code 寫前端因?yàn)?PyCharm 對(duì)vue單文件組件的支持始終差一點(diǎn)意思前端調(diào)試體驗(yàn)不如 VS Code Volar 清爽。開(kāi)發(fā)工具的選擇我的原則是哪個(gè)順手用哪個(gè)但不要在一個(gè)工具里硬扭所有場(chǎng)景。2.5 補(bǔ)充Ubuntu 等 Linux 系統(tǒng)下的 Node.js 安裝如果你是部署在 Linux 服務(wù)器上比如阿里云 ECS 的 Ubuntu 20.04安裝方式其實(shí)更簡(jiǎn)單推薦用官方推薦的 NodeSource 方式或者直接使用nvm管理 Node 版本。這里給一套最簡(jiǎn)單的命令curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - sudo apt-get install -y nodejs裝完驗(yàn)證node -v和npm -v。Linux 下通常不會(huì)出現(xiàn) PowerShell 那類權(quán)限問(wèn)題但要留意的是生產(chǎn)環(huán)境盡量不要用 root 用戶運(yùn)行 Node 服務(wù)創(chuàng)建一個(gè)普通用戶來(lái)跑安全性和穩(wěn)定性都會(huì)更好。3. 后端功能拆解報(bào)銷單、審批流與金額的邊界控制環(huán)境配好之后真正的系統(tǒng)實(shí)現(xiàn)開(kāi)始了。后端我按模塊拆分思路來(lái)做先建數(shù)據(jù)庫(kù)模型再定義接口路由最后在路由的中間件層解決鑒權(quán)和權(quán)限問(wèn)題。這一節(jié)講清楚每個(gè)模塊的設(shè)計(jì)原因和關(guān)鍵代碼思路。3.1 數(shù)據(jù)庫(kù)設(shè)計(jì)四張核心表?yè)纹鹫麄€(gè)報(bào)銷流程報(bào)銷系統(tǒng)的數(shù)據(jù)庫(kù)設(shè)計(jì)不需要花哨但每一張表都要想清楚這條數(shù)據(jù)會(huì)在哪個(gè)流程階段被誰(shuí)用到。我最終設(shè)計(jì)了四張核心表表名用途關(guān)鍵字段users用戶表id, username, password, real_name, department_id, role, created_atdepartments部門表id, name, parent_id, leader_idreimbursements報(bào)銷單主表id, user_id, department_id, title, amount, status, apply_time, audit_timereimbursement_items報(bào)銷明細(xì)表id, reimbursement_id, expense_type, description, amount, invoice_noaudit_records審批記錄表id, reimbursement_id, auditor_id, action, comment, audit_time為什么報(bào)銷單要拆成主表和明細(xì)表這是很多新手糾結(jié)的點(diǎn)。原因很簡(jiǎn)單一張報(bào)銷單可能包含多筆明細(xì)比如交通費(fèi) 200、住宿費(fèi) 800、餐飲費(fèi) 150如果把明細(xì)直接塞在主表里SQL 查詢和 Excel 導(dǎo)出的靈活性都會(huì)受限。拆成兩張表后主表只存匯總金額和狀態(tài)明細(xì)表通過(guò)外鍵關(guān)聯(lián)統(tǒng)計(jì)這個(gè)月哪個(gè)部門交通費(fèi)超標(biāo)這類問(wèn)題時(shí)一條GROUP BY就能解決。金額字段我建議用DECIMAL(10,2)而不是FLOAT因?yàn)楦↑c(diǎn)數(shù)在計(jì)算機(jī)里天生存在精度問(wèn)題財(cái)務(wù)場(chǎng)景下0.1 0.2 不等于 0.3是不能接受的。DECIMAL是字符串存儲(chǔ)不會(huì)丟精度。審批記錄表很多人會(huì)忽略但它其實(shí)特別重要。審計(jì)記錄是財(cái)務(wù)合規(guī)的基礎(chǔ)出問(wèn)題時(shí)要能追蹤每一步是誰(shuí)在什么時(shí)間做了什么決定。前端審批意見(jiàn)、退回原因這些都是往這張表里寫。3.2 報(bào)表單狀態(tài)流轉(zhuǎn)設(shè)計(jì)一張圖看懂六種狀態(tài)整個(gè)報(bào)銷系統(tǒng)的業(yè)務(wù)核心是報(bào)銷單的狀態(tài)機(jī)設(shè)計(jì)。我定義了六種狀態(tài)待提交(DRAFT) → 待審批(PENDING) → 審批通過(guò)(APPROVED) → 已打款(PAID) ↓ 退回(REJECTED) → 已修改(UPDATED) → 重新提交為什么要把待提交和待審批分開(kāi)因?yàn)槲以试S員工保存草稿填到一半沒(méi)填完的數(shù)據(jù)不應(yīng)該直接進(jìn)入審批流否則會(huì)出現(xiàn)大量審批人打開(kāi)一看啥也沒(méi)有的情況。草稿狀態(tài)讓員工有時(shí)間準(zhǔn)備發(fā)票、補(bǔ)充說(shuō)明。狀態(tài)流轉(zhuǎn)的約束條件我寫在邏輯層只有當(dāng)前狀態(tài)為PENDING的單據(jù)才能被審批人審批只有當(dāng)前狀態(tài)為DRAFT或REJECTED的單據(jù)才能被員工修改后重新提交。如果這些校驗(yàn)散落在前端各個(gè)頁(yè)面里很容易出現(xiàn)繞過(guò)校驗(yàn)的非法操作所以在后端接口層做統(tǒng)一校驗(yàn)更穩(wěn)妥。3.3 API 設(shè)計(jì)與關(guān)鍵接口實(shí)現(xiàn)后端接口遵循 RESTful 風(fēng)格核心接口清單如下方法路徑功能權(quán)限POST/api/auth/login登錄獲取 token公開(kāi)GET/api/user/info獲取當(dāng)前用戶信息登錄用戶POST/api/reimbursement創(chuàng)建報(bào)銷單登錄用戶GET/api/reimbursement/list分頁(yè)查詢報(bào)銷單登錄用戶GET/api/reimbursement/detail/:id報(bào)銷單詳情登錄用戶PUT/api/reimbursement/:id修改報(bào)銷單本人/草稿狀態(tài)POST/api/reimbursement/submit/:id提交審批本人POST/api/reimbursement/audit/:id審批通過(guò)/退回審批人GET/api/reimbursement/stats部門報(bào)銷統(tǒng)計(jì)財(cái)務(wù)/管理員這幾個(gè)接口里最有技術(shù)含量的是submit和audit因?yàn)樗鼈兩婕盃顟B(tài)變更的并發(fā)控制。比如員工點(diǎn)了提交審批前端同時(shí)發(fā)了兩個(gè)請(qǐng)求如果后端不做處理單據(jù)可能被重復(fù)提交兩次產(chǎn)生兩條審批記錄。解決方案有兩種一是數(shù)據(jù)庫(kù)層加樂(lè)觀鎖在reimbursements表加version字段二是在接口層加 Redis 分布式鎖。因?yàn)閮?nèi)部系統(tǒng)并發(fā)量不高我選了樂(lè)觀鎖邏輯更簡(jiǎn)單更新時(shí)帶上version條件如果更新行數(shù)為 0說(shuō)明數(shù)據(jù)已被其他人改過(guò)返回提示該單據(jù)狀態(tài)已更新請(qǐng)刷新頁(yè)面。登錄鑒權(quán)我用 JWT 實(shí)現(xiàn)。用戶登錄成功后服務(wù)端生成一個(gè)有效期為 8 小時(shí)的 token后續(xù)所有請(qǐng)求都在Authorization頭里帶上這個(gè) token。后端寫一個(gè)authMiddleware統(tǒng)一解析、校驗(yàn) token并掛載到req.user上這樣每個(gè)路由里都能直接拿到當(dāng)前用戶的 id、角色做權(quán)限判斷非常方便。密碼存儲(chǔ)用bcryptjs哈希加鹽輪數(shù)設(shè)為 10即使數(shù)據(jù)庫(kù)泄露明文密碼也不會(huì)直接暴露。3.4 文件上傳與 Excel 導(dǎo)出被很多人低估的兩個(gè)功能報(bào)銷系統(tǒng)幾乎離不開(kāi)附件上傳發(fā)票照片、PDF 版的電子發(fā)票、行程單截圖等。我用multer處理上傳配置了大小限制為單文件 5MB存儲(chǔ)路徑按uploads/年月分目錄。做實(shí)操時(shí)發(fā)現(xiàn)一個(gè)容易被忽略的問(wèn)題財(cái)務(wù)做賬時(shí)需要下載原始附件而發(fā)票文件名往往是微信、支付寶自動(dòng)生成的亂碼比如wx_camera_20240812153000.jpg。財(cái)務(wù)下載后根本分不清哪張是哪張。我的方案是上傳成功后后端用uuid重命名文件存儲(chǔ)但在數(shù)據(jù)庫(kù)里保留原始文件名接口返回時(shí)帶上原始文件名和下載地址。這樣展示給用戶的是上海到北京高鐵票.jpg存儲(chǔ)層則是a3f2c1b4.jpg兩全其美。Excel 導(dǎo)出用exceljs實(shí)現(xiàn)。財(cái)務(wù)導(dǎo)出某月全公司報(bào)銷明細(xì)時(shí)接口會(huì)先查數(shù)據(jù)庫(kù)組裝成數(shù)組再用 ExcelJS 寫成.xlsx文件返回。這里注意一個(gè)性能問(wèn)題如果一次性導(dǎo)出一萬(wàn)行數(shù)據(jù)內(nèi)存會(huì)飆升我的處理是分批查詢每次查 1000 條再逐批寫入 Excel實(shí)測(cè)一萬(wàn)行數(shù)據(jù)導(dǎo)出耗時(shí)在 5 秒以內(nèi)。3.5 權(quán)限控制的三層設(shè)計(jì)報(bào)銷系統(tǒng)的權(quán)限不是簡(jiǎn)單的管理員/普通用戶二元?jiǎng)澐帧N也鸪闪巳N角色員工、審批人、財(cái)務(wù)。員工只能操作自己的單據(jù)審批人可以審批本部門或下級(jí)部門的單據(jù)財(cái)務(wù)可以查看全公司的單據(jù)、執(zhí)行打款操作、導(dǎo)出報(bào)表。權(quán)限校驗(yàn)我放在三個(gè)層面接口層authMiddleware之后再加一個(gè)roleMiddleware比如requireRole(finance)角色不匹配直接返回 403。數(shù)據(jù)層查詢列表時(shí)普通員工默認(rèn)只能查user_id 當(dāng)前用戶的數(shù)據(jù)審批人可以看到狀態(tài)為PENDING且部門歸屬為自己的數(shù)據(jù)。前端路由層菜單根據(jù)不同角色動(dòng)態(tài)渲染財(cái)務(wù)看不到待審批菜單員工看不到報(bào)表導(dǎo)出菜單。前端的菜單權(quán)限放第 4 節(jié)細(xì)說(shuō)后端接口這層是最重要的——哪怕有人通過(guò)瀏覽器直接輸入 API 地址拿不到數(shù)據(jù)權(quán)力邊界也不會(huì)漏。4. 前端交互細(xì)節(jié)動(dòng)態(tài)路由、表單校驗(yàn)與審批狀態(tài)可視化后端接口寫完接下來(lái)前端要真正做出員工能用的頁(yè)面。這一節(jié)里說(shuō)幾個(gè)我反復(fù)調(diào)過(guò)、踩過(guò)坑的地方。4.1 動(dòng)態(tài)路由還是靜態(tài)路由權(quán)限菜單的正確打開(kāi)方式報(bào)銷系統(tǒng)有登錄頁(yè)、首頁(yè)、報(bào)銷單列表、新建報(bào)銷、待審批列表、財(cái)務(wù)統(tǒng)計(jì)、用戶管理、部門管理一共八個(gè)頁(yè)面。如果不管用戶角色全部路由靜態(tài)注冊(cè)那么普通員工也能在瀏覽器里敲#/finance/stats看到財(cái)務(wù)統(tǒng)計(jì)頁(yè)面。雖然后端接口會(huì)攔截?cái)?shù)據(jù)請(qǐng)求但頁(yè)面白屏、報(bào)錯(cuò)彈窗這種體驗(yàn)非常糟糕。我的做法登錄成功后后端根據(jù)用戶角色返回菜單權(quán)限數(shù)組前端用router.addRoute動(dòng)態(tài)注冊(cè)路由。核心代碼邏輯大致如下// 登錄后動(dòng)態(tài)添加路由 const asyncRoutes { employee: [ { path: /reimbursement/new, component: () import(/views/ReimbursementNew.vue) } ], approver: [ { path: /audit/list, component: () import(/views/AuditList.vue) } ], finance: [ { path: /finance/stats, component: () import(/views/FinanceStats.vue) } ] }; function setupRoutes(role) { const routes asyncRoutes[role] || []; routes.forEach(route router.addRoute(route)); }動(dòng)態(tài)路由的好處是菜單和權(quán)限天然同步同一套代碼在不同角色眼里長(zhǎng)成不同的系統(tǒng)。壞處是刷新頁(yè)面時(shí)路由注冊(cè)過(guò)程是異步的如果用戶直接刷新某個(gè)子頁(yè)面可能先匹配到404。解決辦法是在router.beforeEach里加一個(gè)標(biāo)記如果用戶已登錄但動(dòng)態(tài)路由尚未注冊(cè)完成則先await注冊(cè)邏輯再放行。4.2 報(bào)銷單表單金額計(jì)算與即時(shí)校驗(yàn)新建報(bào)銷單頁(yè)面是員工使用頻率最高的頁(yè)面體驗(yàn)好壞直接影響整個(gè)系統(tǒng)的口碑。我把表單拆成三個(gè)部分基礎(chǔ)信息標(biāo)題、報(bào)銷事由、出差日期、明細(xì)列表類型、金額、發(fā)票號(hào)、說(shuō)明、附件上傳。金額這塊我做了一個(gè)細(xì)節(jié)明細(xì)列表中用戶輸入每行金額后自動(dòng)累加實(shí)時(shí)顯示總計(jì)并且在底部顯示人民幣大寫。比如合計(jì) 1234.56 元自動(dòng)展示壹仟貳佰叁拾肆元伍角陸分。這個(gè)功能其實(shí)底層就是把數(shù)字轉(zhuǎn)大寫網(wǎng)上有現(xiàn)成 JS 函數(shù)但千萬(wàn)注意分和整的處理金額到分時(shí)不用寫整沒(méi)有角分時(shí)才寫。別小看這個(gè)細(xì)節(jié)財(cái)務(wù)看到大寫金額少了個(gè)整字會(huì)覺(jué)得系統(tǒng)不專業(yè)。表單校驗(yàn)用 Element Plus 的表單驗(yàn)證規(guī)則對(duì)應(yīng)的規(guī)則包括必填校驗(yàn)、金額必須是大于 0 的數(shù)字、發(fā)票號(hào)正則校驗(yàn)允許 8 到 20 位字母數(shù)字、附件必傳。這里做的校驗(yàn)和后端校驗(yàn)保持一致——我在后端同樣寫了一份校驗(yàn)邏輯防止繞過(guò)前端直接調(diào)接口傳非法數(shù)據(jù)。前端校驗(yàn)是為了用戶體驗(yàn)后端校驗(yàn)才是真正的防線。4.3 審批流的頁(yè)面展現(xiàn)狀態(tài)流轉(zhuǎn)要一眼看懂待審批列表頁(yè)審批人看到的是所有PENDING的單據(jù)列表每行顯示申請(qǐng)人、部門、金額、申請(qǐng)時(shí)間點(diǎn)擊可以進(jìn)入詳情頁(yè)。詳情頁(yè)上半部分是報(bào)銷單內(nèi)容只讀下半部分是審批記錄時(shí)間線點(diǎn)擊通過(guò)或退回按鈕時(shí)彈窗要求填寫審批意見(jiàn)。這個(gè)頁(yè)面的核心設(shè)計(jì)點(diǎn)在于審批按鈕的可點(diǎn)擊狀態(tài)完全由后端返回的狀態(tài)字段驅(qū)動(dòng)前端不自行猜測(cè)。比如一張單已經(jīng)是已打款狀態(tài)審批人無(wú)論如何都不該看到通過(guò)/退回按鈕。這樣做避免了多端狀態(tài)不同步的混亂。審批意見(jiàn)時(shí)間線用的是組件的timeline每條記錄顯示審批人姓名、頭像、動(dòng)作通過(guò)/退回、意見(jiàn)內(nèi)容和時(shí)間。員工提交后能清楚看到自己的單子卡在誰(shuí)的環(huán)節(jié)這個(gè)透明度對(duì)用戶體驗(yàn)的提升非常明顯。4.4 axios 請(qǐng)求封裝與攔截器的兩個(gè)關(guān)鍵處理前端所有請(qǐng)求統(tǒng)一封裝在request.js里核心是 axios 實(shí)例的攔截器配置。請(qǐng)求攔截器負(fù)責(zé)在發(fā)出請(qǐng)求前從localStorage里取出 token加到Authorization頭里service.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer ${token}; } return config; });響應(yīng)攔截器負(fù)責(zé)統(tǒng)一處理兩件事業(yè)務(wù)狀態(tài)碼和 HTTP 錯(cuò)誤。后端接口統(tǒng)一返回{ code: 0, data: ... }code為 0 才是成功code非 0 時(shí)攔截器彈出一個(gè)ElMessage提示錯(cuò)誤信息。HTTP 401 時(shí)說(shuō)明 token 過(guò)期此時(shí)清除本地 token并跳轉(zhuǎn)登錄頁(yè)。這兩個(gè)處理建議提前做好不然每個(gè)接口都要自己寫一遍錯(cuò)誤處理邏輯代碼會(huì)冗余到?jīng)]法看。還有一個(gè)細(xì)節(jié)文件下載類請(qǐng)求的響應(yīng)內(nèi)容不是 JSON而是二進(jìn)制流。我的做法是對(duì)responseType: blob的請(qǐng)求單獨(dú)處理不經(jīng)過(guò)統(tǒng)一的錯(cuò)誤解析邏輯而是根據(jù)Content-Disposition頭里的文件名信息保存文件。4.5 實(shí)用的自定義組件與常見(jiàn)小功能整套前端做完我沉淀了幾個(gè)可以直接復(fù)用的組件MoneyInput.vue金額輸入框自動(dòng)過(guò)濾非數(shù)字字符支持千分位展示。DepartmentSelect.vue部門樹(shù)選擇器展開(kāi)后可直接選擇歸屬部門。ExpenseTypeSelect.vue報(bào)銷類型下拉配置了常用類型交通費(fèi)、住宿費(fèi)、餐飲費(fèi)、辦公用品、差旅補(bǔ)助等。FileUploadList.vue附件上傳列表支持預(yù)覽圖片和 PDF支持刪除、重新上傳。AmountToChinese.vue金額大寫展示組件。組件封裝的收益在項(xiàng)目后期特別明顯財(cái)務(wù)統(tǒng)計(jì)頁(yè)需要選部門、選時(shí)間范圍直接復(fù)用DepartmentSelect.vue和日期范圍組件不用重復(fù)寫模板。建議有同樣開(kāi)發(fā)任務(wù)的小伙伴前兩個(gè)頁(yè)面寫完后就把這些通用組件抽出來(lái)之后每個(gè)頁(yè)面的開(kāi)發(fā)速度能快三分之一。4.6 關(guān)于 Vue 3 自定義 v-model 與 vnodes 的實(shí)用場(chǎng)景熱搜詞里提到vue的自定義v-model和vnodes的概念這兩個(gè)雖然敏感度不高但在 Vue 項(xiàng)目里確實(shí)屬于進(jìn)階知識(shí)。這里分享我的真實(shí)心得。自定義v-model在封裝表單類組件時(shí)很常用。比如封裝一個(gè)只允許輸入金額的MoneyInput.vue它需要同時(shí)對(duì)外暴露值和變更事件這樣父組件可以直接寫MoneyInput v-modelitem.amount /自定義v-model的本質(zhì)是modelValueprop 和update:modelValue事件的語(yǔ)法糖。我在封裝組件時(shí)會(huì)特別注意只接受modelValue作為輸入不直接修改它而是通過(guò) emit 讓父組件更新數(shù)據(jù)這樣才能保證數(shù)據(jù)單向流動(dòng)避免復(fù)雜表單下數(shù)據(jù)狀態(tài)混亂。用 Vue 3 的組合式 API 寫的時(shí)候defineProps和defineEmits的組合非常順手比 Options API 少了不少樣板代碼。至于vnodes虛擬節(jié)點(diǎn)在報(bào)銷系統(tǒng)里我遇到的實(shí)際場(chǎng)景是根據(jù)費(fèi)用類型動(dòng)態(tài)渲染不同的輸入控件。比如費(fèi)用類型是差旅補(bǔ)助時(shí)只需要填天數(shù)不需要填發(fā)票號(hào)費(fèi)用類型是辦公用品時(shí)則需要填發(fā)票號(hào)和供應(yīng)商。用v-if也可以實(shí)現(xiàn)但控件多了以后模板很臃腫。我后來(lái)改成用h()函數(shù)也就是創(chuàng)建 vnode 的方式動(dòng)態(tài)構(gòu)造表單項(xiàng)組件代碼更靈活但可讀性也相應(yīng)地下降。如果你對(duì)vnodes還不熟先用v-if完全沒(méi)問(wèn)題不要為了炫技引入復(fù)雜性。另外熱搜詞里提到的vue播放m3u8、m3u8播放器這類需求我順便提一句如果企業(yè)里需要報(bào)銷系統(tǒng)里嵌入視頻或直播類的附件預(yù)覽比如某些培訓(xùn)報(bào)銷涉及視頻證據(jù)可以考慮video.js搭配videojs-contrib-hls插件這是目前最成熟的 m3u8 播放方案。不過(guò)常規(guī)報(bào)銷系統(tǒng)里用到的不多優(yōu)先級(jí)不高建議核心功能做完后再考慮。5. 從本地能跑到正式部署聯(lián)調(diào)、打包與運(yùn)維的實(shí)際記錄系統(tǒng)開(kāi)發(fā)完成只是第一步真正考驗(yàn)人的是別人也能用。這一節(jié)講我從本地聯(lián)調(diào)、到服務(wù)器部署再到上線后穩(wěn)定運(yùn)行的完整操作記錄。5.1 前后端聯(lián)調(diào)階段的跨域處理前端跑在http://localhost:5173后端跑在http://localhost:3000端口不同瀏覽器默認(rèn)會(huì)攔截跨域請(qǐng)求。解決辦法有兩個(gè)方向我兩個(gè)都試過(guò)。方向一后端開(kāi)啟 CORS。在 Express 里加一個(gè)中間件設(shè)置允許的來(lái)源、允許的方法、允許的請(qǐng)求頭。這種方式配置簡(jiǎn)單生產(chǎn)環(huán)境也用得上但要注意不要簡(jiǎn)單粗暴地設(shè)置Access-Control-Allow-Origin: *最好在前端生產(chǎn)環(huán)境域名固定后改為指定域名來(lái)源否則等于把你的接口暴露給任何網(wǎng)站調(diào)用。方向二前端用 Vite 的代理功能。在vite.config.js里配置server: { proxy: { /api: { target: http://localhost:3000, changeOrigin: true } } }這個(gè)方案的好處是開(kāi)發(fā)環(huán)境下前端請(qǐng)求路徑保持/api和后端接口路徑一致不用在 axios 里寫完整的http://localhost:3000/api/...。部署時(shí)同樣的路徑關(guān)系可以用 Nginx 反代處理。我個(gè)人的習(xí)慣是開(kāi)發(fā)環(huán)境用代理生產(chǎn)環(huán)境用 Nginx后端 Express 不額外開(kāi) CORS這樣權(quán)限邊界最清晰。5.2 前端打包構(gòu)建的配置與優(yōu)化前端寫完后執(zhí)行npm run build打包。Vite 默認(rèn)輸出到dist目錄。這里有兩個(gè)優(yōu)化點(diǎn)我是第三版部署時(shí)才補(bǔ)上的。第一代碼分割。默認(rèn)配置會(huì)把所有頁(yè)面代碼打包進(jìn)一個(gè) JS 文件首屏加載很慢。Vite 支持按路由動(dòng)態(tài)導(dǎo)入組件C 端系統(tǒng)可能無(wú)所謂但企業(yè)內(nèi)部系統(tǒng)網(wǎng)速普遍一般還是建議配置手動(dòng)代碼分包把 Element Plus、ExcelJS 這類大體積庫(kù)單獨(dú)拆成 vendors 包。第二環(huán)境變量。用.env.production文件配置構(gòu)建時(shí)的接口地址比如VITE_API_BASE_URL/api這樣打包出來(lái)的 JS 里不會(huì)出現(xiàn)localhost:3000這類開(kāi)發(fā)地址。很多新手上線后頁(yè)面白屏、接口 404一半以上的原因都在這里——打包時(shí)沒(méi)把接口地址切到生產(chǎn)環(huán)境。5.3 部署流程與 PM2 進(jìn)程守護(hù)后端部署我用 PM2。PM2 是 Node.js 生態(tài)里最成熟的進(jìn)程管理工具它能做的事情包括后臺(tái)運(yùn)行 Node 進(jìn)程、崩潰自動(dòng)重啟、日志統(tǒng)一管理、多實(shí)例負(fù)載均衡。生產(chǎn)環(huán)境部署步驟我整理成了腳本# 1. 拉取代碼到服務(wù)器 git pull origin main # 2. 安裝后端依賴并啟動(dòng) cd server npm install --production pm2 start app.js --name reimburse-server # 3. 前端構(gòu)建并將產(chǎn)物交給 Nginx cd ../web npm install npm run build sudo cp -r dist/* /var/www/reimburse/PM2 有一個(gè)特別好用的命令pm2 save配合pm2 startup可以設(shè)置開(kāi)機(jī)自啟服務(wù)器重啟后 Node 服務(wù)自動(dòng)拉起不需要人工干預(yù)。對(duì)沒(méi)有專職運(yùn)維的中小企業(yè)來(lái)說(shuō)這一個(gè)功能省了很多事。Nginx 配置這里給一個(gè)最小可用的反代核心片段server { listen 80; server_name your-domain.com; location / { root /var/www/reimburse; 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; } }注意try_files $uri $uri/ /index.html;這行。Vue 是單頁(yè)應(yīng)用如果用戶直接訪問(wèn)https://your-domain.com/reimbursement/123這種前端路由地址Nginx 會(huì)先去找這個(gè)物理路徑找不到就會(huì) 404。加了try_files后所有找不到的路徑都會(huì)回退到index.html由前端 Vue Router 接管處理這是部署 Vue 單頁(yè)應(yīng)用必須寫的一行配置。5.4 上線后的性能與穩(wěn)定性實(shí)測(cè)系統(tǒng)上線后跑了一個(gè)月我記錄了這樣一組數(shù)據(jù)注冊(cè)用戶 120 人月處理報(bào)銷單約 600 張平均每個(gè)審批環(huán)節(jié)耗時(shí) 4 小時(shí)。后端 Node 進(jìn)程的內(nèi)存占用穩(wěn)定在 220MB 左右CPU 使用率峰值不超過(guò) 25%QPS 峰值大概 80 左右——這個(gè)負(fù)載對(duì)單機(jī)部署的 Node 應(yīng)用來(lái)說(shuō)非常輕松。唯一出現(xiàn)過(guò)的問(wèn)題是員工集中在下班前一小時(shí)提交報(bào)銷單導(dǎo)致有一段時(shí)間接口響應(yīng)變慢。排查后發(fā)現(xiàn)瓶頸不在 Node 層而在數(shù)據(jù)庫(kù)——reimbursements表的status字段沒(méi)加索引按狀態(tài)查詢時(shí)全表掃描。加上索引后查詢耗時(shí)從 800ms 降到了 70ms。這個(gè)經(jīng)驗(yàn)給到了我數(shù)據(jù)庫(kù)設(shè)計(jì)階段像status、user_id這類高頻查詢條件一定要建索引否則線上數(shù)據(jù)量一上來(lái)性能問(wèn)題立刻顯現(xiàn)。6. 總結(jié)一下我對(duì)這套系統(tǒng)的幾點(diǎn)真實(shí)體會(huì)整個(gè)項(xiàng)目從前端環(huán)境配置到后端接口實(shí)現(xiàn)再到部署上線前后用了三周時(shí)間。真正的收獲不在于代碼量而在于想清楚了幾件事。第一報(bào)銷系統(tǒng)的核心不是功能炫技而是流程可控。數(shù)據(jù)一致性、狀態(tài)流轉(zhuǎn)、權(quán)限邊界這些看不見(jiàn)的設(shè)計(jì)比頁(yè)面的美觀度重要得多。財(cái)務(wù)系統(tǒng)出錯(cuò)是可以被追責(zé)的所以每個(gè)環(huán)節(jié)都要留痕每次狀態(tài)變更都要有依據(jù)。第二Node.js Vue 的組合非常適合這類企業(yè)內(nèi)部工具。技術(shù)棧統(tǒng)一、開(kāi)發(fā)效率高、部署成本低。一開(kāi)始我也猶豫要不要用 Spring Boot 顯得更傳統(tǒng)規(guī)范但后來(lái)想明白了工具沒(méi)有高低之分能把復(fù)雜流程穩(wěn)定跑起來(lái)能讓使用者真正覺(jué)得省事就是好工具。第三也是我反復(fù)強(qiáng)調(diào)的環(huán)境配置的坑每臺(tái)電腦都可能不一樣一定要掌握排查思路而不是死記命令。npm.ps1權(quán)限報(bào)錯(cuò)、環(huán)境變量缺失、端口占用、依賴安裝超時(shí)這些問(wèn)題以后大概率還會(huì)遇到思路通了這些都不是事。最后分享一個(gè)小技巧整個(gè)系統(tǒng)做完后我專門用一天的測(cè)試數(shù)據(jù)把每一種異常路徑都走了一遍——重復(fù)提交、附件超限、金額為負(fù)、審批人離職、跨部門審批…… 每發(fā)現(xiàn)一個(gè)異常就修一個(gè)。這套測(cè)試比寫十個(gè)新功能都值錢因?yàn)榫€上出問(wèn)題時(shí)的代價(jià)遠(yuǎn)比開(kāi)發(fā)時(shí)大得多。做系統(tǒng)的人永遠(yuǎn)要給使用者留好后路。