人健康菜譜生成系統(tǒng)全棧源碼解析)
1. 項(xiàng)目概述個(gè)人健康菜譜生成系統(tǒng)到底在解決什么問(wèn)題做這個(gè)個(gè)人健康菜譜生成系統(tǒng)其實(shí)一開(kāi)始只是想解決我自己每天吃飯的糾結(jié)。上班族做飯最煩的不是不會(huì)做而是打開(kāi)冰箱不知道今天能做什么查菜譜網(wǎng)站又一堆反人類的廣告和“適量鹽”描述。所以我干脆用 Node.js 和 Vue 寫(xiě)了這個(gè)全棧項(xiàng)目前端負(fù)責(zé)點(diǎn)菜、收藏、看詳情后端負(fù)責(zé)按用戶熱量目標(biāo)、口味偏好和忌口條件去生成每日菜譜整套源碼放在一個(gè)倉(cāng)庫(kù)里clone 下來(lái)就能跑。項(xiàng)目本身不復(fù)雜但麻雀雖小五臟俱全涉及 Vue 組件通信、動(dòng)態(tài)路由、Node 接口設(shè)計(jì)、數(shù)據(jù)庫(kù)建模、推薦邏輯和常見(jiàn)部署問(wèn)題。想練全棧的初學(xué)者、需要做課程設(shè)計(jì)的同學(xué)甚至只是想把家里食材利用起來(lái)的朋友都能從這套源碼里找到能直接用的東西。這個(gè)項(xiàng)目我一直放在本地方便自己改后來(lái)整理干凈之后把源碼拎了出來(lái)。標(biāo)題里寫(xiě)“項(xiàng)目源碼”說(shuō)明重心不只是講概念而是給你一套能跑通的結(jié)構(gòu)。你拿到手之后前端是標(biāo)準(zhǔn)的 Vue 3 Vite 工程后端是 Express SQLite沒(méi)有復(fù)雜的中間件和云服務(wù)依賴Windows、macOS、Ubuntu 都試過(guò)能跑。我會(huì)把從環(huán)境配置、目錄結(jié)構(gòu)、核心接口到前端交互的完整鏈路拆開(kāi)講重點(diǎn)講那些文檔里不會(huì)寫(xiě)、但實(shí)際開(kāi)發(fā)一定會(huì)踩的坑。必須強(qiáng)調(diào)一點(diǎn)系統(tǒng)里的熱量和營(yíng)養(yǎng)建議只是根據(jù)通用食物成分表估算的用來(lái)做日常參考沒(méi)問(wèn)題但不要當(dāng)作醫(yī)療建議。尤其是有慢性病或者孕期飲食需求的朋友拿這套系統(tǒng)當(dāng)工具可以重要決策請(qǐng)咨詢專業(yè)人士。1.1 這個(gè)項(xiàng)目適合誰(shuí)能學(xué)到什么我先說(shuō)句實(shí)話這個(gè)項(xiàng)目沒(méi)有上微服務(wù)也不搞高并發(fā)。它就是一個(gè)典型的中小型全棧項(xiàng)目適合一兩個(gè)人維護(hù)。選這個(gè)規(guī)模是有意的太復(fù)雜了勸退太簡(jiǎn)單了沒(méi)干貨。如果你正在學(xué) Vue背了路由、插槽、組件通信的面試題但沒(méi)實(shí)際項(xiàng)目經(jīng)驗(yàn)這套源碼能讓你看到這些概念是怎么串起來(lái)的如果你剛接觸 Node 后端能學(xué)到如何用 Express 把接口拆得清晰怎么連 SQLite怎么做登錄鑒權(quán)怎么處理菜譜封面圖上傳如果你純粹想解決每天吃什么把示例食材換掉導(dǎo)入自己常買(mǎi)的菜這套系統(tǒng)一樣能服務(wù)你。從學(xué)習(xí)角度看這個(gè)項(xiàng)目最大的價(jià)值在于“完整”。市面上的教程代碼大多是片段級(jí)一個(gè)登錄頁(yè)講三小時(shí)但沒(méi)有人告訴你登錄之后怎么跳轉(zhuǎn)、數(shù)據(jù)存在哪里、刷新頁(yè)面后 token 怎么恢復(fù)、前端調(diào)接口跨域怎么處理。這些恰恰是源碼項(xiàng)目最值錢(qián)的部分。我在整理代碼時(shí)特意保留了合理的 TODO 注釋和單元測(cè)試目錄就是為了讓后來(lái)者能順著思路繼續(xù)擴(kuò)展。1.2 技術(shù)選型為什么是 Node.js Vue 的全棧組合選擇 Node.js 和 Vue不是因?yàn)樗鼈z最先進(jìn)而是因?yàn)樗鼈冏钸m合這類項(xiàng)目的開(kāi)發(fā)狀態(tài)。后端用 Node.js意味著前后端都是 JavaScript/TypeScript一個(gè)開(kāi)發(fā)者不用頻繁切換語(yǔ)言心智。尤其是做菜譜推薦這種邏輯數(shù)據(jù)結(jié)構(gòu)是典型的對(duì)象數(shù)組操作JS 處理起來(lái)比 Java 短得多。前端用 Vue是因?yàn)?Vue 的響應(yīng)式系統(tǒng)和單文件組件設(shè)計(jì)對(duì)中小型項(xiàng)目非常友好模板寫(xiě)法接近原生 HTML新手拿起來(lái)不會(huì)像看某些框架那樣一頭霧水。有人會(huì)問(wèn)為什么不直接 Spring Boot Vue不是說(shuō)不行如果你要交一個(gè)“基于 Spring Boot 的商品管理系統(tǒng)”那一套也完全能跑。但在我這個(gè)場(chǎng)景里Node.js 的啟動(dòng)速度、輕量級(jí)內(nèi)存占用和 npm 生態(tài)讓我改代碼更爽。Express 路由寫(xiě)起來(lái)幾乎沒(méi)有儀式感SQLite 則是零配置的文件數(shù)據(jù)庫(kù)整個(gè)后端部署起來(lái)就是node server.js一條命令。等你把這套邏輯吃透了再用 Spring Boot 重寫(xiě)也只是換個(gè)殼核心推薦和建模思路完全一樣。2. 功能拆解與核心模塊設(shè)計(jì)菜譜不是瞎生成的2.1 用戶場(chǎng)景與功能清單這個(gè)系統(tǒng)的核心使用場(chǎng)景是這樣的晚上七點(diǎn)到家冰箱里有雞胸肉、西蘭花、豆腐、雞蛋你今天晚餐目標(biāo)熱量是 500 大卡不吃香菜但想吃點(diǎn)辣。系統(tǒng)會(huì)從菜譜庫(kù)里篩選出所有不包含香菜、主要食材能匹配上的菜再按熱量范圍過(guò)濾最后結(jié)合你過(guò)去一周點(diǎn)過(guò)什么挑出三道熱量合適、口味不重樣的菜推給你每道菜都標(biāo)注食材清單、大致做法和營(yíng)養(yǎng)素估算。整個(gè)功能清單我拆成了兩大塊。用戶側(cè)包括注冊(cè)登錄、個(gè)人資料維護(hù)、每日熱量目標(biāo)設(shè)置、口味偏好與忌口管理、菜譜瀏覽、菜譜收藏、菜譜詳情查看、菜譜生成歷史。管理側(cè)則簡(jiǎn)單一點(diǎn)包含食材庫(kù)管理、菜譜維護(hù)、封面圖上傳和生成日志查看。之所以把管理端也塞進(jìn)去是為了讓菜譜數(shù)據(jù)不是寫(xiě)死在代碼里而是可以從界面維護(hù)這樣對(duì)做課程設(shè)計(jì)和真實(shí)使用都更方便。功能設(shè)計(jì)的取舍也值得說(shuō)。我沒(méi)有做社區(qū)評(píng)論、點(diǎn)贊、分享這些社交功能不是不會(huì)做而是它們會(huì)顯著拉長(zhǎng)開(kāi)發(fā)周期。個(gè)人健康菜譜系統(tǒng)的重點(diǎn)應(yīng)該放在“生成”和“篩選”上如果生成質(zhì)量不行評(píng)論做出來(lái)也是空殼。所以我建議你拿到源碼后先把核心鏈路跑通再考慮加不加社交模塊。2.2 數(shù)據(jù)表設(shè)計(jì)與菜譜 JSON 結(jié)構(gòu)數(shù)據(jù)庫(kù)我用的是 SQLite文件就放在后端目錄的data/health.db里。表結(jié)構(gòu)設(shè)計(jì)的核心是食材和菜譜的多對(duì)多關(guān)系。一張菜譜由多個(gè)食材組成一個(gè)食材也可以出現(xiàn)在多道菜里所以不能簡(jiǎn)單在菜譜表里塞一個(gè)ingredients字段而是要拆三張表recipes、ingredients、recipe_ingredients。下面是簡(jiǎn)化后的建表 SQL你可以直接拿去用CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, target_calories INTEGER DEFAULT 1800, taste_tags TEXT DEFAULT , excluded_ingredients TEXT DEFAULT , created_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE ingredients ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, category TEXT, calory_per_100g REAL DEFAULT 0, unit TEXT DEFAULT g ); CREATE TABLE recipes ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, description TEXT, steps TEXT, cover_url TEXT, meal_type TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE recipe_ingredients ( id INTEGER PRIMARY KEY AUTOINCREMENT, recipe_id INTEGER NOT NULL, ingredient_id INTEGER NOT NULL, amount_g REAL DEFAULT 100, FOREIGN KEY(recipe_id) REFERENCES recipes(id), FOREIGN KEY(ingredient_id) REFERENCES ingredients(id) ); CREATE TABLE favorites ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, recipe_id INTEGER NOT NULL, created_at TEXT DEFAULT CURRENT_TIMESTAMP );拆表的直接好處是篩選和統(tǒng)計(jì)方便。比如要算出某道菜的熱量只需要把這道菜關(guān)聯(lián)的食材用量乘以對(duì)應(yīng)食材的每百克熱量再累加一個(gè)JOIN就能搞定。如果不拆表把食材名和用量塞成一個(gè) JSON 字符串那將來(lái)做“按食材查詢菜譜”和“熱量估算”會(huì)痛苦到懷疑人生。菜譜里的步驟我本來(lái)想用富文本編輯后來(lái)為了降低復(fù)雜度直接存成Text。前端詳情頁(yè)用\n分隔展示效果完全夠用。菜譜的示例數(shù)據(jù)在server/src/data/seed.js里包含了二十多道家常菜和一百個(gè)常見(jiàn)食材。你打開(kāi)這個(gè)文件就能看到一道菜的完整 JSON 結(jié)構(gòu){ title: 香煎雞胸肉配西蘭花, description: 高蛋白低脂肪適合減脂期晚餐, mealType: dinner, steps: 1. 雞胸肉用鹽和黑胡椒腌制15分鐘\n2. 平底鍋少油中火每面煎4分鐘\n3. 西蘭花焯水后一起裝盤(pán), ingredients: [ { name: 雞胸肉, amount: 150, unit: g }, { name: 西蘭花, amount: 200, unit: g }, { name: 橄欖油, amount: 5, unit: g } ] }2.3 推薦邏輯的關(guān)鍵標(biāo)簽匹配、排除條件與熱量估算菜譜推薦是這套系統(tǒng)的靈魂我做得很克制沒(méi)有用協(xié)同過(guò)濾或深度學(xué)習(xí)。原因很簡(jiǎn)單個(gè)人系統(tǒng)里用戶行為數(shù)據(jù)太少協(xié)同過(guò)濾在冷啟動(dòng)階段基本失效而且算出來(lái)的結(jié)果用戶看不懂“為什么推薦這道菜”。我選的是規(guī)則加隨機(jī)權(quán)重的方案推理過(guò)程透明代碼量少也容易調(diào)試。整個(gè)推薦流程分四步。第一步收集用戶當(dāng)前選擇的食材 ID 列表沒(méi)有選擇食材時(shí)就默認(rèn)全庫(kù)食材可用。第二步根據(jù)用戶資料里的excluded_ingredients排除包含忌口食材的菜譜比如用戶明確寫(xiě)了“不吃香菜”那所有關(guān)聯(lián)了香菜的菜譜直接過(guò)濾掉。第三步計(jì)算每道菜的熱量估算值篩掉超出目標(biāo)熱量范圍太多的菜。第四步對(duì)候選菜譜按口味偏好標(biāo)簽做加權(quán)隨機(jī)再確保同一道菜一周內(nèi)不重復(fù)推薦。熱量估算的代碼我是單獨(dú)抽成函數(shù)的因?yàn)檫@個(gè)函數(shù)既會(huì)被推薦接口用到也會(huì)在菜譜詳情頁(yè)展示營(yíng)養(yǎng)素時(shí)用到function estimateCalories(recipeId) { const rows db.prepare( SELECT i.calory_per_100g, ri.amount_g FROM recipe_ingredients ri JOIN ingredients i ON i.id ri.ingredient_id WHERE ri.recipe_id ? ).all(recipeId); const total rows.reduce((sum, row) { return sum (row.calory_per_100g * row.amount_g / 100); }, 0); return Math.round(total); }隨機(jī)加權(quán)的實(shí)現(xiàn)不復(fù)雜重點(diǎn)在于不要每次都隨機(jī)得完全一樣。我給每道菜維護(hù)了一個(gè)“最近被推薦次數(shù)”字段推薦次數(shù)越少的菜權(quán)重越高這樣用戶不會(huì)連續(xù)三天看到同樣的菜。另外口味標(biāo)簽我用的是逗號(hào)分隔的簡(jiǎn)單字符串比如“辣”“清淡”“高蛋白”匹配時(shí)只要做數(shù)組交集判斷即可。這套規(guī)則一開(kāi)始看著簡(jiǎn)陋但實(shí)際用下來(lái)生成結(jié)果很穩(wěn)定比那些號(hào)稱智能但結(jié)果隨機(jī)的方案靠譜得多。3. 后端實(shí)現(xiàn)Node.js 接口、數(shù)據(jù)庫(kù)與菜譜推薦邏輯3.1 源碼目錄結(jié)構(gòu)先認(rèn)識(shí)一下你的項(xiàng)目拿到源碼之后別急著npm install先把目錄結(jié)構(gòu)看清楚。我采用的是前后端分離但放在同一個(gè)倉(cāng)庫(kù)里的結(jié)構(gòu)避免了維護(hù)兩個(gè) Git 倉(cāng)庫(kù)的麻煩health-recipe-system/ ├── server/ │ ├── src/ │ │ ├── routes/ # 接口路由 │ │ │ ├── auth.js │ │ │ ├── recipes.js │ │ │ ├── ingredients.js │ │ │ └── favorites.js │ │ ├── middlewares/ │ │ │ └── authMiddleware.js │ │ ├── db/ │ │ │ ├── index.js │ │ │ └── seed.js │ │ ├── utils/ │ │ │ └── calorie.js │ │ └── app.js │ ├── data/ # SQLite 數(shù)據(jù)庫(kù)文件目錄 │ ├── uploads/ # 菜譜封面圖上傳目錄 │ ├── .env # 環(huán)境變量 │ └── package.json ├── web/ │ ├── src/ │ │ ├── views/ # 頁(yè)面組件 │ │ ├── components/ # 業(yè)務(wù)組件 │ │ ├── stores/ # Pinia 狀態(tài) │ │ ├── router/ # 路由配置 │ │ ├── api/ # axios 封裝 │ │ └── App.vue │ └── package.json └── README.mdserver/src/app.js是后端入口web/src/main.js是前端入口。把數(shù)據(jù)和代碼分離的好處是備份數(shù)據(jù)庫(kù)只需要拷一個(gè)data目錄。uploads目錄記得要在.gitignore里忽略掉不然傳幾張菜譜圖倉(cāng)庫(kù)就變得很臃腫。3.2 Node.js 環(huán)境準(zhǔn)備與 npm 配置這一步看著簡(jiǎn)單但我見(jiàn)過(guò)太多人卡在這里。先說(shuō) Node.js 版本項(xiàng)目建議用 LTS 版本目前推薦 18 以上20 也行不要用那種還在奇數(shù)版本號(hào)的嘗鮮版。Vite 在舊版 Node 上會(huì)直接報(bào)錯(cuò)而且報(bào)錯(cuò)信息很迷惑你排查半天發(fā)現(xiàn)是 Node 版本太老。安裝完成后務(wù)必在命令行里執(zhí)行兩個(gè)命令驗(yàn)證node -v npm -v如果提示找不到命令不是沒(méi)裝好就是安裝時(shí)沒(méi)有勾選“Add to PATH”。Windows 下最穩(wěn)妥的方式是重新運(yùn)行安裝包選擇修改安裝并勾選 PATH 選項(xiàng)。macOS 用戶我建議用 Homebrew 安裝Ubuntu 用戶可以apt install nodejs npm但裝完記得檢查版本有些發(fā)行版自帶版本偏舊。npm 裝依賴慢是國(guó)內(nèi)老生常談的問(wèn)題我習(xí)慣先配鏡像源npm config set registry https://registry.npmmirror.com這行命令改的是 npm 全局配置之后所有項(xiàng)目的依賴下載都會(huì)走鏡像。如果你在公司網(wǎng)絡(luò)環(huán)境可能還需要額外設(shè)置代理。我另外建議不要全局安裝一堆工具用npx跑腳手架命令更干凈比如后面創(chuàng)建 Vue 項(xiàng)目直接npm create vuelatest就不會(huì)污染全局包。3.3 后端核心代碼服務(wù)入口和健康檢查接口后端入口文件app.js本身不長(zhǎng)核心就是初始化 Express、掛載中間件、注冊(cè)路由const express require(express); const cors require(cors); const path require(path); const { initDB } require(./db); const authRoutes require(./routes/auth); const recipeRoutes require(./routes/recipes); const ingredientRoutes require(./routes/ingredients); const favoriteRoutes require(./routes/favorites); const app express(); app.use(cors()); app.use(express.json()); app.use(/uploads, express.static(path.join(__dirname, ../uploads))); app.use(/api/auth, authRoutes); app.use(/api/recipes, recipeRoutes); app.use(/api/ingredients, ingredientRoutes); app.use(/api/favorites, favoriteRoutes); app.get(/api/health, (req, res) { res.json({ code: 0, data: { uptime: process.uptime(), time: new Date() } }); }); const PORT process.env.PORT || 3000; initDB().then(() { app.listen(PORT, () { console.log([server] running at http://localhost:${PORT}); }); });注意express.json()這個(gè)中間件不能少否則后端收不到前端傳過(guò)來(lái)的 JSON 請(qǐng)求體。cors()在本地開(kāi)發(fā)時(shí)必須掛否則前端在localhost:5173調(diào)localhost:3000接口會(huì)被瀏覽器攔截。接口返回格式我統(tǒng)一用{ code, data, message }前端 axios 攔截器只要統(tǒng)一解析一次就行。這個(gè)習(xí)慣看起來(lái)不起眼但能讓前端錯(cuò)誤處理代碼減少一大半。項(xiàng)目里的數(shù)據(jù)庫(kù)操作我用了better-sqlite3這個(gè)庫(kù)它是同步 API寫(xiě)起來(lái)比sqlite3的異步回調(diào)舒服很多。初始化數(shù)據(jù)庫(kù)時(shí)如果表不存在就自動(dòng)建表如果食材表是空的就執(zhí)行種子數(shù)據(jù)導(dǎo)入這樣任何機(jī)器上 clone 下來(lái)運(yùn)行都是即跑即用。3.4 菜譜生成接口的實(shí)現(xiàn)細(xì)節(jié)菜譜生成是核心接口路徑是POST /api/recipes/generate。請(qǐng)求體會(huì)接收用戶這次選擇的食材 ID 數(shù)組、餐次類型、目標(biāo)熱量等參數(shù)。我簡(jiǎn)化后的核心邏輯如下router.post(/generate, authMiddleware, (req, res) { const { ingredientIds [], mealType, targetCalories } req.body; const user req.user; // 查詢所有菜譜并關(guān)聯(lián)出食材列表和熱量 const recipes db.prepare( SELECT r.*, GROUP_CONCAT(i.name) as ingredient_names FROM recipes r LEFT JOIN recipe_ingredients ri ON ri.recipe_id r.id LEFT JOIN ingredients i ON i.id ri.ingredient_id GROUP BY r.id ).all(); const excluded parseList(user.excluded_ingredients); // 第一步排除忌口 let candidates recipes.filter(recipe { const names recipe.ingredient_names ? recipe.ingredient_names.split(,) : []; return !excluded.some(item names.includes(item)); }); // 第二步如果指定了食材要求菜譜必須包含其中至少一個(gè) if (ingredientIds.length 0) { candidates candidates.filter(recipe { return recipe.ingredient_names ingredientIds.some(id recipe.ingredient_names.includes(id)); }); } // 第三步熱量過(guò)濾 const range targetCalories || user.target_calories || 1800; candidates candidates.filter(recipe { const cal estimateCalories(recipe.id); return cal range * 0.6 cal range * 1.2; }); // 第四步加權(quán)隨機(jī)選三道一周內(nèi)推薦過(guò)的權(quán)重減半 const weighted candidates.map(recipe { const recentCount getRecentRecommendCount(recipe.id, user.id); return { recipe, weight: Math.max(0.2, 1 - recentCount * 0.3) }; }); // 簡(jiǎn)單加權(quán)隨機(jī) const selected weighted .sort(() Math.random() - 0.5) .slice(0, Math.min(3, weighted.length)) .map(item item.recipe); res.json({ code: 0, data: selected }); });這段代碼我特意寫(xiě)得偏教學(xué)化實(shí)際源碼里會(huì)再封裝幾個(gè)函數(shù)。你可能會(huì)問(wèn)為什么先查出所有菜譜再在內(nèi)存里過(guò)濾因?yàn)槭纠龜?shù)據(jù)量只有幾十條這樣做最簡(jiǎn)單且容易理解。如果以后菜譜庫(kù)上萬(wàn)條就改成在 SQL 里做排除和篩選按食材表JOIN后加WHERE條件。個(gè)人項(xiàng)目?jī)?yōu)先保證可讀性性能問(wèn)題等真遇到了再優(yōu)化這是我一直堅(jiān)持的原則。還有個(gè)細(xì)節(jié)必須返回給前端熱量估算值否則前端卡片上沒(méi)數(shù)字可顯示。我在返回前給每道菜補(bǔ)充了calorie、protein、fat字段計(jì)算方式基于recipe_ingredients的用量和食材營(yíng)養(yǎng)素表。這些營(yíng)養(yǎng)字段即便不準(zhǔn)確也比沒(méi)有強(qiáng)因?yàn)樗芙o用戶一種“被認(rèn)真對(duì)待”的感覺(jué)。接口里還寫(xiě)了生成記錄記錄誰(shuí)在什么時(shí)間請(qǐng)求了哪些菜譜方便后續(xù)做“最近推薦不重復(fù)”的判斷。4. 前端實(shí)現(xiàn)Vue 頁(yè)面、路由與交互細(xì)節(jié)4.1 用 Vite 創(chuàng)建 Vue 3 項(xiàng)目并安裝依賴前端我選擇 Vue 3 Vite 的組合。Vite 開(kāi)發(fā)服務(wù)器啟動(dòng)非??煨薷拇a后熱更新幾乎是秒級(jí)比老牌的 vue-cli 體驗(yàn)好太多。創(chuàng)建項(xiàng)目的命令很簡(jiǎn)單但要注意npm create vuelatest運(yùn)行后會(huì)交互式問(wèn)你要不要 TypeScript、Router、Pinia 等我建議全部選是尤其是 Router 和 Pinia后面會(huì)用到。如果不想交互可以加--default參數(shù)。創(chuàng)建完成后進(jìn)入前端目錄安裝基礎(chǔ)依賴cd web npm install npm install axios vue-router4 pinia npm run dev這里有個(gè)容易踩的坑Vue 3 對(duì)應(yīng)的是vue-router4不是老項(xiàng)目的vue-router3。如果安裝時(shí)沒(méi)寫(xiě)版本號(hào)npm 默認(rèn)給你裝最新的4一般沒(méi)問(wèn)題但如果你之前項(xiàng)目里殘留老版本會(huì)出現(xiàn)路由組件渲染不出來(lái)的怪問(wèn)題。我遇到過(guò)一次最后是清掉node_modules和package-lock.json重新安裝才解決。裝依賴的時(shí)候如果控制臺(tái)刷出大量npm WARN先別慌只要npm run dev能跑起來(lái)就說(shuō)明依賴關(guān)系沒(méi)問(wèn)題。4.2 頁(yè)面路由與整體布局這個(gè)項(xiàng)目的頁(yè)面不多我按業(yè)務(wù)劃分了五個(gè)主要視圖首頁(yè)、菜譜生成頁(yè)、菜譜詳情頁(yè)、收藏頁(yè)、登錄注冊(cè)頁(yè)。路由配置放在src/router/index.js核心代碼如下import { createRouter, createWebHistory } from vue-router; const router createRouter({ history: createWebHistory(), routes: [ { path: /, name: home, component: () import(/views/HomePage.vue) }, { path: /generate, name: generate, component: () import(/views/GeneratePage.vue) }, { path: /recipes/:id, name: recipe-detail, component: () import(/views/RecipeDetailPage.vue) }, { path: /favorites, name: favorites, component: () import(/views/FavoritesPage.vue) }, { path: /login, name: login, component: () import(/views/LoginPage.vue) }, ] }); router.beforeEach((to, from, next) { const token localStorage.getItem(token); if (to.meta.requiresAuth !token) { next(/login); } else { next(); } }); export default router;我用了createWebHistory而不是createWebHashHistory地址欄看起來(lái)干凈。但代價(jià)是部署到 Nginx 時(shí)要做 try_files 重寫(xiě)這個(gè)后面講部署會(huì)提到。路由懶加載也是從這版才加上的之前用靜態(tài) import 把整個(gè)頁(yè)面都打到一個(gè)包里首屏加載很慢。改成() import()之后每個(gè)頁(yè)面單獨(dú)分包體驗(yàn)提升明顯。beforeEach路由守衛(wèi)里做了簡(jiǎn)單的登錄攔截收藏頁(yè)和生成歷史頁(yè)需要登錄才能訪問(wèn)這個(gè)對(duì)真實(shí)項(xiàng)目是剛需。整體布局我放在App.vue里頂部一個(gè)導(dǎo)航欄下面放router-view /。導(dǎo)航欄根據(jù)登錄狀態(tài)顯示“登錄/注冊(cè)”還是“退出登錄”用 Pinia 里的用戶狀態(tài)控制。這種全局布局方式簡(jiǎn)單改一個(gè)文件就能控制所有頁(yè)面的公共殼子。4.3 菜譜生成頁(yè)表單校驗(yàn)、請(qǐng)求狀態(tài)和卡片展示菜譜生成頁(yè)是這個(gè)項(xiàng)目里交互最復(fù)雜的一頁(yè)。左半部分是篩選表單右半部分是生成結(jié)果卡片列表。頁(yè)面模板骨架大致是這樣template div classgenerate-page form submit.preventhandleGenerate h3告訴我你的條件/h3 label餐次類型/label select v-modelform.mealType option valuebreakfast早餐/option option valuelunch午餐/option option valuedinner晚餐/option /select label目標(biāo)熱量大卡/label input typenumber v-model.numberform.targetCalories min300 max3000 / label可選食材/label IngredientSelector v-modelform.ingredientIds / button typesubmit :disabledloading {{ loading ? 生成中... : 生成菜譜 }} /button /form div classresult-area RecipeCard v-forrecipe in recipes :keyrecipe.id :reciperecipe collecthandleCollect / /div /div /template這里有兩個(gè)可以展開(kāi)說(shuō)的點(diǎn)。第一個(gè)是v-model.number修飾符它能把輸入框里的字符串轉(zhuǎn)成數(shù)字避免你在判斷targetCalories 0時(shí)踩“空字符串參與比較”的坑。第二個(gè)是IngredientSelector組件用v-model雙向綁定選中食材 ID 數(shù)組這涉及到子組件里如何觸發(fā)更新。我在這個(gè)組件里封裝了一個(gè)多選網(wǎng)格點(diǎn)擊食材卡片時(shí)切換選中狀態(tài)再通過(guò)emit(update:modelValue, newValue)把值傳回父組件。把這套機(jī)制搞清楚Vue 的組件通信基本就入門(mén)了。請(qǐng)求狀態(tài)處理也很重要。點(diǎn)擊生成后按鈕要置灰并顯示“生成中”防止用戶重復(fù)提交。請(qǐng)求失敗要區(qū)分超時(shí)和接口報(bào)錯(cuò)我在 axios 攔截器里統(tǒng)一處理了錯(cuò)誤提示。拿到結(jié)果后不要直接覆蓋整個(gè)列表先用 loading 遮罩擋住舊內(nèi)容等新結(jié)果回來(lái)再替換避免界面閃動(dòng)。這個(gè)小細(xì)節(jié)對(duì)體驗(yàn)影響非常大我最初沒(méi)做結(jié)果每次生成時(shí)頁(yè)面瘋狂跳動(dòng)觀感很差。4.4 收藏功能與組件復(fù)用技巧收藏功能我用了兩個(gè)端配合后端favorites表記錄用戶和菜譜的關(guān)聯(lián)關(guān)系前端 Pinia store 管理當(dāng)前頁(yè)面的收藏狀態(tài)。點(diǎn)擊收藏按鈕時(shí)先判斷是否登錄沒(méi)登錄就跳轉(zhuǎn)登錄頁(yè)登錄了就調(diào)接口。收藏成功后把按鈕狀態(tài)切換成實(shí)心再次點(diǎn)擊則取消收藏。這套交互在整個(gè)系統(tǒng)里會(huì)出現(xiàn)在菜譜卡片、詳情頁(yè)和收藏頁(yè)三處所以我把收藏按鈕抽成了獨(dú)立組件CollectButton避免在三個(gè)頁(yè)面里復(fù)制粘貼三份一樣的邏輯。CollectButton組件只接收recipeId和initialCollected兩個(gè) props內(nèi)部維護(hù)自己的collected狀態(tài)。但真實(shí)場(chǎng)景里詳情頁(yè)收藏后返回列表頁(yè)希望收藏狀態(tài)同步更新這就要用到 Pinia store 來(lái)共享狀態(tài)。我把收藏的菜譜 ID 集合放在 store 里任何組件提交收藏操作都會(huì)修改這個(gè)集合其他組件自然響應(yīng)更新。這個(gè)方法比事件總線干凈也比 localStorage 手動(dòng)同步靠譜。關(guān)于插槽我在RecipeCard組件里用了一個(gè)技巧。菜譜卡片在不同頁(yè)面展示的重點(diǎn)不一樣生成頁(yè)要顯示熱量和收藏按鈕首頁(yè)要顯示推薦理由收藏頁(yè)要顯示收藏時(shí)間。如果這些差異全用 props 塞進(jìn)去組件會(huì)變得很臃腫。我讓RecipeCard只負(fù)責(zé)渲染標(biāo)題、封面和描述然后把可替換區(qū)域開(kāi)放成插槽div classrecipe-card img :srcrecipe.coverUrl / div classcard-body h4{{ recipe.title }}/h4 p{{ recipe.description }}/p slot namefooter :reciperecipe span默認(rèn)底部?jī)?nèi)容/span /slot /div /div父組件使用的時(shí)候向具名插槽footer里塞不同的按鈕和時(shí)間信息不用改RecipeCard本身的代碼。很多初學(xué)者會(huì)覺(jué)得插槽是面試題里的抽象概念看到這里應(yīng)該能明白它就是給組件留的“自定義區(qū)域占位符”讓同一張卡片在不同上下文里呈現(xiàn)出不同細(xì)節(jié)。這套源碼里插槽用得不多但RecipeCard這一個(gè)案例已經(jīng)足夠看懂。5. 部署運(yùn)行與高頻問(wèn)題排查讓源碼在你的電腦上跑起來(lái)5.1 本地啟動(dòng)和打包部署流程本地跑起來(lái)只需要兩個(gè)終端窗口。第一個(gè)窗口啟動(dòng)后端cd server npm install npm run dev第二個(gè)窗口啟動(dòng)前端cd web npm install npm run dev前端默認(rèn)跑在5173端口后端跑在3000。我在 Vite 配置文件里加了開(kāi)發(fā)代理把所有/api請(qǐng)求轉(zhuǎn)發(fā)到http://localhost:3000這樣前端頁(yè)面里請(qǐng)求地址不用寫(xiě)完整的后端地址也繞開(kāi)了開(kāi)發(fā)環(huán)境跨域問(wèn)題。配置片段如下// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:3000, changeOrigin: true } } } });如果你想把項(xiàng)目部署到服務(wù)器我比較推薦的做法是前端先npm run build打包出dist目錄然后讓 Express 托管靜態(tài)文件。具體說(shuō)在后端app.js里加幾行代碼當(dāng)路由匹配不到/api接口時(shí)直接讀取前端dist下的文件返回。這樣整個(gè)應(yīng)用只需要啟動(dòng)一個(gè) Node 進(jìn)程不太需要單獨(dú)配 Nginx。對(duì)于個(gè)人項(xiàng)目演示場(chǎng)景這是最省事的方式。如果你更喜歡標(biāo)準(zhǔn)的 Nginx 部署那就讓前端靜態(tài)文件交給 Nginx后端接口仍然由 Node 承擔(dān)。需要注意兩個(gè)點(diǎn)一是 Nginx 里要配置將/api轉(zhuǎn)發(fā)到后端端口二是前端使用的createWebHistory模式要求所有未知路徑都重寫(xiě)到index.html否則刷新詳情頁(yè)會(huì) 404。我也見(jiàn)過(guò)有人把這個(gè) Vue 項(xiàng)目打包后直接丟進(jìn) Spring Boot 的static目錄思路一樣只要處理好接口地址和跨域就行。5.2 高頻報(bào)錯(cuò)速查表這部分內(nèi)容是我自己踩坑和幫朋友調(diào)項(xiàng)目時(shí)總結(jié)出來(lái)的包含幾個(gè)高頻問(wèn)題按現(xiàn)象、原因和解決方式整理成了表格錯(cuò)誤現(xiàn)象原因解決方式npm : 無(wú)法加載文件 ... npm.ps1因?yàn)樵诖讼到y(tǒng)上禁止運(yùn)行腳本PowerShell 默認(rèn)執(zhí)行策略限制腳本運(yùn)行以管理員身份打開(kāi) PowerShell執(zhí)行Set-ExecutionPolicy RemoteSigned不想改系統(tǒng)策略就直接用 CMD 運(yùn)行 npmnode: not found或node 不是內(nèi)部或外部命令Node.js 未安裝成功或沒(méi)有加入 PATH重新安裝 Node.js 并勾選 Add to PATH安裝完成后重啟終端前端啟動(dòng)后頁(yè)面能開(kāi)但接口報(bào) 404開(kāi)發(fā)代理沒(méi)生效或后端沒(méi)啟動(dòng)確認(rèn)后端日志有輸出檢查vite.config.js的 proxy 配置請(qǐng)求路徑必須帶/api前綴請(qǐng)求提示 CORS 錯(cuò)誤后端沒(méi)開(kāi)跨域或代理配置沒(méi)覆蓋當(dāng)前請(qǐng)求后端掛載cors()中間件若用了代理請(qǐng)求地址不要寫(xiě)http://localhost:3000直連SQLite 報(bào) database is locked多個(gè)進(jìn)程同時(shí)寫(xiě)數(shù)據(jù)庫(kù)文件開(kāi)發(fā)時(shí)不要同時(shí)開(kāi)兩個(gè)后端進(jìn)程生產(chǎn)環(huán)境可切換 PostgreSQL/MySQLCannot find module better-sqlite3原生模塊編譯失敗或沒(méi)正確安裝先刪除node_modules再執(zhí)行npm install還是不行就檢查 Node 版本是否過(guò)新退回 LTS菜譜封面圖片上傳后訪問(wèn) 404圖片沒(méi)有放在uploads目錄或路徑缺少/uploads靜態(tài)服務(wù)檢查app.js里express.static配置上傳目錄必須存在并有寫(xiě)入權(quán)限中文亂碼數(shù)據(jù)庫(kù)文件編碼或返回頭缺少 charsetSQLite 本身是 UTF-8亂碼一般發(fā)生在 Windows 老終端建議用 VS Code 終端運(yùn)行這些報(bào)錯(cuò)里最顯眼的就是 npm.ps1 禁止運(yùn)行腳本。說(shuō)實(shí)話我第一次遇到也懵好不容易裝好 Node運(yùn)行npm -v卻報(bào)錯(cuò)。后來(lái)查清楚這是 Windows PowerShell 為了保護(hù)系統(tǒng)默認(rèn)不讓執(zhí)行.ps1腳本。我自己的處理方式是只在當(dāng)前用戶范圍放寬執(zhí)行策略不去改系統(tǒng)級(jí)策略Set-ExecutionPolicy -Scope CurrentUser RemoteSigned執(zhí)行后重新打開(kāi)終端就好。如果你在公司電腦上受組策略限制改不了就老老實(shí)實(shí)用 CMD 或 Git Bash一樣能跑 npm這也是完全合規(guī)的方案。5.3 關(guān)于這套源碼我的后續(xù)規(guī)劃和個(gè)人建議源碼我還會(huì)繼續(xù)迭代但方向不是加更多炫酷功能而是把推薦質(zhì)量做得更細(xì)。比如現(xiàn)在熱量估算是基于靜態(tài)食材表以后我想接入更完整的營(yíng)養(yǎng)成分?jǐn)?shù)據(jù)庫(kù)把鹽、油、糖的用量也納入計(jì)算。另一個(gè)方向是記錄用戶對(duì)生成結(jié)果的反饋點(diǎn)了“喜歡”還是“換一道”這些反饋數(shù)據(jù)積累起來(lái)后規(guī)則系統(tǒng)能做得更智能。對(duì)于想拿這套源碼做課程設(shè)計(jì)或者畢業(yè)設(shè)計(jì)的同學(xué)我強(qiáng)烈建議你在“生成歷史”和“用戶反饋”上多下功夫這兩塊最容易做出差異化和工作量證明。最后分享一個(gè)我實(shí)際改代碼時(shí)的小技巧先把所有菜譜數(shù)據(jù)導(dǎo)出來(lái)把你自己日常會(huì)做的十道菜加進(jìn)去然后生成一次看看推薦結(jié)果。這一步能讓你迅速理解推薦規(guī)則對(duì)結(jié)果的真實(shí)影響也會(huì)發(fā)現(xiàn)一些數(shù)據(jù)問(wèn)題比如某道菜熱量算出來(lái)不合理。數(shù)據(jù)是這類系統(tǒng)的地基算法再花哨食材用量錄入不準(zhǔn)也沒(méi)用。你把它當(dāng)成一個(gè)“幫自己做飯的助手”來(lái)打磨就能真正用好這套代碼。