
1. 為什么中后臺系統必須用 Vue3 TS Vite 這套組合不是“跟風”而是現實倒逼出來的選擇我帶過 7 個中后臺項目從 Vue2 Element UI 到 Ant Design Vue3再到最近交付的三個政務監管平臺踩過所有能踩的坑。現在回看Vue3 TS Vite 不是技術選型里的“加分項”而是生存底線——它解決的從來不是“能不能做”而是“能不能按時上線、不出線上事故、不被業務方半夜打電話罵醒”。先說最痛的點中后臺系統的核心矛盾從來不是功能炫酷而是字段多、校驗嚴、權限雜、接口慢、瀏覽器兼容性差、運維部署流程長。Vue2 的 Options API 在寫一個含 32 個表單項、6 級嵌套彈窗、動態權限控制的審批流時data、methods、computed三塊代碼像三座孤島改一個字段校驗邏輯得在三個地方同步改漏一處就導致表單提交后某字段值為空但校驗通過TS 的缺失讓后端返回user?.profile?.avatarUrl時前端敢直接.split(/)結果某天 profile 字段沒傳整個頁面白屏監控里報錯堆棧全是Cannot read property split of undefined而 Webpack 構建一個 80 個頁面的系統熱更新要等 8~12 秒改完一行 CSS喝杯咖啡回來才刷新出來團隊平均每天浪費 1.7 小時在等待構建。Vite 解決的不是“快”而是開發節奏的確定性。它把“改完即見效果”從理想變成常態。Vue3 的 Composition API 把邏輯按業務域比如“用戶搜索模塊”、“導出配置模塊”組織而不是按語法結構切片配合 TS 的類型推導你在寫useUserSearch()時IDE 能實時告訴你searchParams里有沒有deptId字段、searchResult的list是UserItem[]還是undefinedVite 的按需編譯讓npm run dev啟動時間壓到 1.2 秒內HMR 響應控制在 300ms 內——這不是體驗優化是把開發者的注意力從等待構建中解放出來專注解決業務邏輯本身。再看真實場景上個月一個稅務稽查系統上線前 3 天后端突然調整了 17 個接口的響應結構字段名全變了。用 Vue2 JS 的項目我們花了 14 小時手動改data初始化、v-model綁定、computed計算屬性、methods提交邏輯還漏了 2 處導致測試環境報錯而用 Vue3 TS 的項目只改了api/user.ts里的接口返回類型定義VS Code 自動標紅所有類型不匹配的地方雙擊跳轉修復3 小時全部搞定且零 runtime 錯誤。這就是 TS 在中后臺的價值它不是讓你寫更多代碼而是讓你少寫 70% 的防御性判斷把錯誤攔截在編碼階段而不是凌晨三點的生產告警里。所以別再問“為什么要用這套組合”該問的是“如果不用你準備用什么來扛住每月迭代 20 需求、日均 500 次部署、跨 4 個瀏覽器版本、支持 IE11 到 Chrome 最新版的中后臺系統”2. 從零初始化避開 90% 新手卡在第一步的 5 個致命細節很多人卡在npm create vitelatest這一步就放棄了不是命令不對而是忽略了環境上下文的隱性約束。我見過太多人對著終端報錯command not found: create-vite干瞪眼其實問題根本不在命令而在 Node.js 版本和包管理器的協同邏輯。2.1 Node.js 版本不是“夠用就行”而是“必須精確匹配”Vite 官方明確要求 Node.js ≥ 18.0.0截至 2024 年 Q2但很多開發者裝的是 Node.js 16.x 或 18.17.0后者看似滿足實則埋雷。原因在于 Vite 5.x 的底層依賴esbuild在 18.17.0 上存在一個未公開的內存泄漏 bug會導致vite build在打包含大量 SVG 圖標的中后臺項目時內存占用飆升至 4GB最終 OOM 中斷。解決方案不是升級到 18.18.0而是直接鎖定 Node.js 18.20.2——這是 Vite 團隊在內部 CI 中驗證最穩定的版本。驗證方法很簡單終端執行node -v如果不是v18.20.2用 nvm 切換# macOS/Linux nvm install 18.20.2 nvm use 18.20.2 # Windows 用戶請用 nvm-windows命令相同提示不要用nvm install --ltsLTS 版本目前是 20.x與 Vite 5.x 兼容性反而更差。中后臺項目穩定性優先寧可選舊一點但經過千錘百煉的版本。2.2 創建項目時必須顯式指定模板和 TypeScript而非依賴交互式菜單npm create vitelatest啟動的交互式向導在 CI/CD 環境或團隊統一腳本中會卡住。正確姿勢是一行命令直達目標npm create vitelatest my-admin-system -- --template vue-ts注意兩個關鍵點一是--分隔符它告訴 npm 后面的參數傳給create-vite而非 npm 自身二是--template vue-ts明確指定 Vue TypeScript 模板。漏掉--template會生成純 JS 項目后續再加 TS 改造成本極高——你需要手動安裝typescript、vue/ts-plugin、配置tsconfig.json的compilerOptions還要處理shims-vue.d.ts的聲明合并而這些在vue-ts模板里已預置好。2.3vite.config.ts的base配置不是“部署路徑”而是“資源引用根路徑”的精準錨點很多新手以為base: /admin/只是告訴 Nginx 靜態資源放在/admin/目錄下實際上它影響的是所有相對路徑資源的解析基準。比如你在src/assets/logo.png引用圖片Vue 單文件組件里寫img srcassets/logo.pngVite 會把assets解析為src/assets但最終生成的 HTML 中img src/admin/assets/logo.png—— 這個/admin/就來自base。如果base設為/而實際部署在https://example.com/admin/圖片請求就會變成https://example.com/assets/logo.png404。正確做法是開發環境base: /生產環境根據部署路徑動態設置。在vite.config.ts中這樣寫import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ base: process.env.NODE_ENV production ? /admin/ : /, plugins: [vue()], // ...其他配置 })同時在.env.production文件中添加VUE_APP_BASE_URL/admin/并在路由配置中使用// src/router/index.ts import { createRouter, createWebHistory } from vue-router import { env } from /utils/env // 自定義環境變量讀取工具 export const router createRouter({ history: createWebHistory(env.VUE_APP_BASE_URL), // 動態傳入 base routes: [/* 路由配置 */] })注意env.VUE_APP_BASE_URL必須以VUE_APP_開頭Vite 才會自動注入到import.meta.env中。這是 Vite 的安全機制防止意外暴露敏感環境變量。2.4tsconfig.json的strict和skipLibCheck必須二選一沒有中間地帶默認生成的tsconfig.json中strict: true和skipLibCheck: true共存這在中后臺項目里是災難。skipLibCheck: true會讓 TS 跳過對node_modules中類型聲明的檢查看似加快編譯實則掩蓋了大量潛在問題。比如ant-design/icons-vue的某個版本類型定義有誤TS 不報錯但運行時defineComponent無法正確推導 props 類型導致v-model綁定失效。我的經驗是中后臺項目必須開啟strict: true并關閉skipLibCheck。代價是首次tsc --noEmit檢查耗時增加 3~5 秒但換來的是 100% 可信的類型安全。為了平衡速度可以添加exclude排除不需要檢查的目錄{ compilerOptions: { strict: true, skipLibCheck: false, esModuleInterop: true, skipDefaultLibCheck: true, lib: [ES2020, DOM, DOM.Iterable, ScriptHost], types: [vite/client, vue/macros] }, include: [src/**/*.ts, src/**/*.d.ts, src/**/*.tsx, src/**/*.vue], exclude: [node_modules, dist, mock, public] }skipDefaultLibCheck: true是關鍵——它跳過對內置庫如lib.dom.d.ts的檢查只檢查你寫的代碼和第三方庫的類型聲明既保證嚴格性又避免無謂耗時。2.5package.json的type字段必須設為module否則import.meta.env無法工作這是 Vite 5.x 的一個隱藏陷阱。如果你的package.json里沒有type: moduleNode.js 會以 CommonJS 模式解析.ts文件導致import.meta.env在某些環境下尤其是 Windows PowerShell返回undefined所有環境變量讀取失敗。解決方案極其簡單在package.json的根對象里添加{ name: my-admin-system, type: module, scripts: { /* ... */ } }這個字段告訴 Node.js“本項目所有.js和.ts文件都按 ES Module 規范解析”import.meta才能被正確識別。沒有這行你后面配的所有VUE_APP_API_BASE_URL都是擺設。3. 中后臺核心骨架路由、權限、狀態管理的三位一體設計中后臺系統的骨架不是一堆頁面拼起來的而是路由驅動視圖、權限控制入口、狀態管理數據流三者咬合運轉的精密齒輪。拆開任何一個整個系統都會卡頓甚至崩壞。3.1 路由設計為什么不能用vue-router默認的createRouter默認的createRouter({ history: createWebHistory(), routes: [...] })在中后臺里是“紙糊的城墻”。它無法解決三個剛需菜單動態加載、路由守衛權限校驗、頁面級緩存控制。我見過太多項目把所有路由寫死在routes數組里結果當菜單需要根據角色動態生成時要么重啟服務要么用addRoute動態添加但addRoute添加的路由無法被router.beforeEach攔截——因為守衛在createRouter時已注冊完畢。正確方案是路由分層 動態導入 守衛前置。第一層是基礎路由登錄頁、404、首頁框架第二層是業務路由用戶管理、訂單中心后者通過import.meta.glob動態加載// src/router/routes.ts import { RouteRecordRaw } from vue-router // 基礎路由靜態 export const constantRoutes: RouteRecordRaw[] [ { path: /login, name: Login, component: () import(/views/login/index.vue), meta: { title: 登錄, hidden: true } }, { path: /, name: Layout, component: () import(/layout/index.vue), redirect: /dashboard, children: [ { path: /dashboard, name: Dashboard, component: () import(/views/dashboard/index.vue), meta: { title: 首頁, icon: home } } ] } ] // 業務路由動態 const modules import.meta.glob(/views/**/index.vue) export function generateAsyncRoutes() { const routes: RouteRecordRaw[] [] Object.entries(modules).forEach(([path, module]) { // 從路徑提取路由名稱如 /views/user/index.vue - User const name path.match(/\/views\/(.)\/index\.vue/)?.[1] || if (name) { routes.push({ path: /${name}, name, component: module as any, meta: { title: name, icon: name.toLowerCase() } }) } }) return routes }然后在路由守衛中動態添加// src/router/index.ts import { createRouter, createWebHistory, Router } from vue-router import { constantRoutes } from ./routes import { generateAsyncRoutes } from ./routes let router: Router | null null export function setupRouter(app: AppElement) { router createRouter({ history: createWebHistory(), routes: constantRoutes }) // 全局前置守衛登錄校驗 權限加載 router.beforeEach(async (to, from, next) { const token localStorage.getItem(token) if (!token to.path ! /login) { next(/login) return } // 已登錄加載用戶權限菜單 if (to.path ! /login !router.hasRoute(to.name as string)) { const asyncRoutes generateAsyncRoutes() asyncRoutes.forEach(route router.addRoute(route)) // 確保新路由已添加再跳轉 next({ ...to, replace: true }) return } next() }) app.use(router) }關鍵點next({ ...to, replace: true })是精髓。它讓路由重新觸發一次beforeEach此時router.hasRoute(to.name)為true避免無限循環。這是動態路由的黃金法則。3.2 權限控制RBAC 不是“角色-權限映射表”而是“路由元信息 組件指令 API 攔截”的三層過濾網很多項目把權限當成一個if (hasPermission(user:delete))的函數調用結果刪用戶按鈕有了但點擊后 API 返回 403用戶體驗極差。真正的 RBAC 必須在三個層面攔截路由層meta.roles控制菜單顯示和路由訪問。在generateAsyncRoutes中為每個路由添加meta.roles: [admin, editor]然后在側邊欄組件中過濾!-- src/layout/components/Sidebar.vue -- template el-menu :default-activeactivePath sidebar-item v-forroute in filteredRoutes :keyroute.path :itemroute / /el-menu /template script setup langts import { computed } from vue import { useUserStore } from /store/modules/user import { constantRoutes } from /router/routes const userStore useUserStore() const activePath computed(() { const { matched } useRouter().currentRoute.value return matched[matched.length - 1]?.path || / }) // 過濾出當前用戶有權限的路由 const filteredRoutes computed(() { return constantRoutes .flatMap(route route.children || []) .filter(route { const roles route.meta?.roles as string[] | undefined return !roles || roles.some(role userStore.roles.includes(role)) }) }) /script組件層自定義指令v-permission控制按鈕顯隱// src/directives/permission.ts import { Directive, DirectiveBinding } from vue import { useUserStore } from /store/modules/user export const permission: Directive { mounted(el, binding: DirectiveBindingstring[]) { const userStore useUserStore() const { value } binding const hasPermission Array.isArray(value) ? value.some(permission userStore.permissions.includes(permission)) : userStore.permissions.includes(value) if (!hasPermission) { el.style.display none // 或者更優雅地移除 DOM 節點 el.parentNode?.removeChild(el) } } } // 在 main.ts 中注冊 app.directive(permission, permission)使用el-button v-permission[user:create]新增用戶/el-buttonAPI 層Axios 請求攔截器自動添加權限 Header并響應攔截器統一處理 403// src/utils/request.ts import axios from axios import { ElMessage } from element-plus import { useUserStore } from /store/modules/user const request axios.create({ baseURL: import.meta.env.VUE_APP_API_BASE_URL, timeout: 10000 }) // 請求攔截 request.interceptors.request.use( config { const userStore useUserStore() if (userStore.token) { config.headers.Authorization Bearer ${userStore.token} } return config }, error Promise.reject(error) ) // 響應攔截 request.interceptors.response.use( response response, error { if (error.response?.status 403) { ElMessage.error(權限不足請聯系管理員) // 清空權限跳轉到無權限頁 const userStore useUserStore() userStore.resetPermissions() router.push(/403) } return Promise.reject(error) } ) export default request三層過濾缺一不可。路由層防越權訪問組件層防誤操作API 層兜底防繞過。這才是企業級權限的完整鏈路。3.3 狀態管理Pinia 不是“替代 Vuex”而是“讓狀態管理回歸業務本質”Pinia 的defineStore語法糖讓狀態管理從“配置對象”回歸到“業務函數”。在中后臺里狀態不是全局共享的而是按領域劃分的。比如用戶管理模塊的狀態不應該和訂單模塊混在一起。我推薦的 Store 結構是每個業務域一個 Store每個 Store 包含 state、actions、getters 三部分且 actions 必須是異步的// src/store/modules/user.ts import { defineStore } from pinia import request from /utils/request import type { UserItem, LoginParams, LoginResult } from /api/user/types interface UserState { token: string userInfo: UserItem | null permissions: string[] roles: string[] } export const useUserStore defineStore(user, { state: (): UserState ({ token: localStorage.getItem(token) || , userInfo: null, permissions: [], roles: [] }), getters: { // 計算屬性是否已登錄 isLogin: state !!state.token, // 計算屬性是否有某個權限 hasPermission: (state) (permission: string) state.permissions.includes(permission) }, actions: { // 登錄 action負責獲取 token 和用戶信息 async login(params: LoginParams) { const res await request.postLoginResult(/auth/login, params) this.token res.data.token localStorage.setItem(token, res.data.token) await this.loadUserInfo() // 登錄后立即加載用戶信息 }, // 加載用戶信息 action分離關注點 async loadUserInfo() { const res await request.getUserItem(/user/info) this.userInfo res.data this.permissions res.data.permissions || [] this.roles res.data.roles || [] }, // 退出登錄 action清理所有狀態 logout() { this.token this.userInfo null this.permissions [] this.roles [] localStorage.removeItem(token) } } })關鍵設計原則State 只存原始數據不存計算結果isLogin是 getter不是 state 字段Actions 必須是異步的所有與后端交互的邏輯都在 actions 里組件只調用store.login()不關心 HTTP 細節Getters 用于派生狀態hasPermission是純函數輸入 permission 字符串輸出布爾值便于單元測試模塊化命名空間defineStore(user)的user是唯一 ID避免命名沖突。這種設計讓狀態管理像寫業務函數一樣自然而不是在mapState、mapActions的配置地獄里掙扎。4. 中后臺高頻痛點攻堅表格分頁、表單校驗、文件上傳的工業級實現中后臺的“臟活累活”往往決定項目的成敗。一個卡頓的表格、一個總校驗失敗的表單、一個上傳大文件就崩潰的組件比任何炫酷圖表都更能摧毀用戶信任。4.1 表格分頁為什么el-tableel-pagination組合總是卡頓根源在虛擬滾動缺失el-table默認渲染所有數據當表格有 1000 行、每行 15 列時DOM 節點數超 15000瀏覽器重排重繪壓力巨大。解決方案不是換框架而是啟用虛擬滾動 后端分頁 緩存策略三位一體。首先后端必須支持分頁參數page,pageSize,total前端用ref管理分頁狀態template el-table :datatableData :row-keyrowKey v-loadingloading !-- 列定義 -- /el-table el-pagination v-model:current-pagepagination.page v-model:page-sizepagination.pageSize :totalpagination.total size-changehandleSizeChange current-changehandleCurrentChange / /template script setup langts import { ref, onMounted } from vue import { fetchUserList } from /api/user import type { UserListParams, UserListItem } from /api/user/types const loading ref(false) const tableData refUserListItem[]([]) const pagination ref({ page: 1, pageSize: 20, total: 0 }) // 獲取列表 const getList async () { loading.value true try { const res await fetchUserList({ page: pagination.value.page, pageSize: pagination.value.pageSize }) tableData.value res.data.list pagination.value.total res.data.total } finally { loading.value false } } // 分頁變化 const handleSizeChange (val: number) { pagination.value.pageSize val pagination.value.page 1 getList() } const handleCurrentChange (val: number) { pagination.value.page val getList() } onMounted(() { getList() }) /script但僅此還不夠。當數據量 500 行時仍需虛擬滾動。Element Plus 5.x 內置了virtual-scroll只需在el-table上加屬性el-table :datatableData :row-keyrowKey v-loadingloading :height600 !-- 必須設置高度否則虛擬滾動不生效 -- virtual-scroll 注意virtual-scroll要求:height為固定數值不能是100%或auto。這是性能與靈活性的權衡。更進一步加入分頁緩存用戶切換到第 3 頁再回到第 1 頁不應重新請求。用 Map 緩存// src/utils/paginationCache.ts const cacheMap new Mapstring, { data: any[], total: number }() export function getCacheKey(api: string, params: Recordstring, any) { return ${api}-${JSON.stringify(params)} } export function setPaginationCache(key: string, data: any[], total: number) { cacheMap.set(key, { data, total }) } export function getPaginationCache(key: string) { return cacheMap.get(key) || null } // 在 getList 中使用 const cacheKey getCacheKey(/user/list, { page, pageSize }) const cached getPaginationCache(cacheKey) if (cached) { tableData.value cached.data pagination.value.total cached.total return } // ...請求后緩存 setPaginationCache(cacheKey, res.data.list, res.data.total)三層優化后端分頁減少數據量、虛擬滾動減少 DOM 節點、緩存減少重復請求。這才是工業級表格的標配。4.2 表單校驗TS 類型 Schema 驗證 動態規則的三重保險中后臺表單的校驗不能只靠rules對象。它必須是TS 接口定義字段類型、Schema 驗證引擎保證規則一致性、動態規則適配業務邏輯變化。首先用 TS Interface 定義表單數據結構// src/api/user/types.ts export interface UserForm { username: string email: string phone: string deptId: number status: 0 | 1 avatar?: string } // 校驗規則 Schema export const userFormRules { username: [ { required: true, message: 請輸入用戶名, trigger: blur }, { min: 2, max: 20, message: 長度在 2 到 20 個字符, trigger: blur } ], email: [ { required: true, message: 請輸入郵箱, trigger: blur }, { type: email, message: 請輸入正確的郵箱地址, trigger: blur } ], phone: [ { required: true, message: 請輸入手機號, trigger: blur }, { pattern: /^1[3-9]\d{9}$/, message: 請輸入正確的手機號, trigger: blur } ], deptId: [{ required: true, message: 請選擇部門, trigger: change }] } satisfies Recordkeyof UserForm, any[]satisfies是 TS 5.0 的新特性它確保userFormRules的 key 必須是UserForm的 keyof且值類型匹配。如果UserForm新增age: number字段而userFormRules沒加TS 就會報錯。然后在組件中使用template el-form :modelform :rulesrules refformRef el-form-item label用戶名 propusername el-input v-modelform.username / /el-form-item !-- 其他字段 -- /el-form /template script setup langts import { ref, reactive } from vue import { userFormRules } from /api/user/types import type { UserForm } from /api/user/types const formRef refInstanceTypetypeof ElForm() const form reactiveUserForm({ username: , email: , phone: , deptId: 0, status: 1 }) const rules userFormRules // 直接復用類型安全 // 提交 const onSubmit async () { await formRef.value?.validate() // 提交邏輯 } /script最后動態規則比如“當選擇‘外部合作方’角色時郵箱必填手機號可選”。用watch監聽角色字段變化// 在 setup 中 const role refinternal | external(internal) watch(role, (newVal) { if (newVal external) { // 動態添加郵箱規則 rules.email [ ...userFormRules.email, { required: true, message: 外部合作方必須填寫郵箱, trigger: blur } ] } else { // 恢復默認規則 rules.email userFormRules.email } })TS 類型保證字段存在性Schema 保證規則完整性動態規則應對業務變化。三者結合表單校驗才真正可靠。4.3 文件上傳大文件分片上傳 斷點續傳 進度可視化不是“拖拽就完事”中后臺常需上傳 Excel 報表、PDF 合同、視頻監控錄像動輒幾百 MB。el-upload的http-request無法滿足分片需求。必須自己實現FileReaderBlob.sliceFormData的底層控制。核心邏輯是將文件按 chunkSize如 2MB切片逐片上傳服務端記錄已上傳分片最后合并。// src/utils/upload.ts export interface UploadOptions { url: string file: File chunkSize?: number onProgress?: (progress: number) void onSuccess?: (data: any) void onError?: (err: any) void } export class FileUploader { private options: UploadOptions private chunks: Blob[] [] private uploadedChunks: Setnumber new Set() private currentChunkIndex 0 constructor(options: UploadOptions) { this.options { chunkSize: 2 * 1024 * 1024, ...options } this.splitFile() } private splitFile() { const { file, chunkSize } this.options for (let i 0; i file.size; i chunkSize) { this.chunks.push(file.slice(i, i chunkSize)) } } private async uploadChunk(chunk: Blob, index: number): Promisevoid { const formData new FormData() formData.append(file, chunk, ${this.options.file.name}-${index}) formData.append(chunkIndex, index.toString()) formData.append(totalChunks, this.chunks.length.toString()) formData.append(fileName, this.options.file.name) const res await fetch(this.options.url, { method: POST, body: formData }) if (!res.ok) throw new Error(Upload chunk ${index} failed) this.uploadedChunks.add(index) } async start(): Promisevoid { const { onProgress, onSuccess, onError } this.options try { // 并發上傳 3 個分片 const promises: Promisevoid[] [] while (this.currentChunkIndex this.chunks.length) { const index this.currentChunkIndex promises.push(this.uploadChunk(this.chunks[index], index)) // 控制并發數 if (promises.length 3 || this.currentChunkIndex this.chunks.length) { await Promise.all(promises) promises.length 0 // 更新進度 const progress Math.round( (this.uploadedChunks.size / this.chunks.length) * 100 ) onProgress?.(progress) } } // 所有分片上傳完成觸發合并 await this.mergeChunks() onSuccess?.({}) } catch (err) { onError?.(err) } } private async mergeChunks(): Promisevoid { const res await fetch(${this.options.url}/merge, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ fileName: this.options.file.name, totalChunks: this.chunks.length }) }) if (!res.ok) throw new Error(Merge chunks failed) } } // 使用 const uploader new FileUploader({ url: /api/upload, file: file, onProgress: (progress) { console.log(上傳進度: ${progress}%) } }) uploader.start()這個實現支持分片上傳大文件切成小塊降低單次請求失敗風險斷點續傳uploadedChunks記錄已上傳分片網絡中斷后可從斷點繼續并發控制限制同時上傳分片數避免壓垮瀏覽器或服務端進度反饋實時計算整體進度提升用戶體驗。這才是中后臺文件上傳的工業標準。5. 生產環境終極 checklistNginx 部署、跨域代理、SEO 優化、性能監控的落地細節項目開發完成只是萬里長征第一步。部署上線、穩定運行、快速定位問題才是中后臺系統的真正考驗。很多項目倒在了最后 100 米。5.1 Nginx 部署不是root /var/www/html就完事而是 location、gzip、緩存策略的精細調控一個典型的中后臺 Nginx 配置必須解決四個問題靜態資源緩存、API 代理、history 模式 fallback、Gzip 壓縮。# /etc/nginx/conf.d/my-admin.conf upstream api_backend { server 127.0.0.1:3000; # 后端 API 服務 } server { listen 80; server_name admin.example.com; # 靜態資源目錄 root /var/www/my-admin-system; index index.html; # 靜態資源緩存JS/CSS/IMG location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { expires 1y; add_header Cache-Control public, immutable; } # API 代理 location /api/ { proxy_pass http://api_backend/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # history 模式 fallback所有非靜態資源請求都返回 index.html location / { try_files $