资讯动态

Vue3 + Naive UI 后台管理系统搭建:主题、权限与工程化实践

发布时间:2026/9/10 6:57:44 来源:尧图企业网站定制
简介这是一套基于Vue3和Naive UI组件库开发的后台管理系统项目代号vn-admin面向有一定前端基础、希望掌握现代后台管理界面搭建流程的开发者。压缩包内共66个文件以js逻辑文件、vue组件文件、svg图标资源为主同时包含json配置、css样式、html入口、项目说明文档等整体体积仅776KB结构紧凑。目前已有1351人学习下载适合作为企业级后台开发的学习蓝本。项目完整覆盖路由守卫、Vuex状态管理、组件化布局、Composition API组合式写法并广泛使用n-button、n-table、n-form等Naive UI组件同时封装了axios接口调用、自定义主题配置并配有mock模拟数据与基础测试部署配置便于读者从零梳理前端工程化思路。代码目录按业务功能拆分包含layout布局、views页面、api接口、store状态、router路由等模块层次清晰可直接参考其组织方式并应用到真实项目也可作为Vue3进阶学习与工程实践的良好范例。1. 光是 Vue3 Naive UI 这个组合就值得按产品级标准来搭拿到“基于 Vue3, 使用 Naive UI 组件库开发的后台管理系统.zip”这种命名时我一般不会急着解压看源码而是先在脑子里过一遍这套技术栈的常驻问题菜单和路由怎么联动、暗色模式怎么切、表格表单怎么少写样板代码、权限控制放在哪一层。Naive UI 在 Vue3 生态里算少有的“开箱即带暗色主题”的组件库类型推导也比早期组件库舒服所以用它做后台管理系统真正需要开工的不是写页面而是先把工程底座、主题、状态和请求层定好。这个方案适合准备接手这类项目、或者正要做技术选型的人按“初始化 → 布局 → 权限 → 高频页面 → 构建收尾”的顺序讲全部命令和配置都是可以直接落到项目里的形态。2. 用 Vite 搭好 Vue3 Naive UI 后台管理系统的底座Vue3 项目的脚手架早就不是 vue-cli 一统天下了。现在看到“基于 Vue3”的后台管理系统第一候选方案是 Vite TypeScript。Naive UI 本身不挑构建工具但配套的按需加载插件是为 Vite 设计的webpack 那边还得自己找 babel 插件。所以下面按 Vite 路径来。2.1 创建工程并安装依赖Vite 官方脚手架支持直接生成 vue-ts 模板省去手动配置 tsconfig 和 shims-vue.d.ts 的步骤。命令如下# 创建 Vue3 TypeScript 工程 npm create vitelatest admin-dashboard -- --template vue-ts cd admin-dashboard npm install # 运行时依赖 npm install naive-ui vue-router4 pinia axios # 构建期自动引入插件 npm install -D unplugin-auto-import unplugin-vue-components第一行创建了一个名为 admin-dashboard 的 Vue3 TypeScript 工程npm install 安装基础依赖随后把运行时依赖和构建期插件分开安装。naive-ui 是组件库vue-router4 是路由pinia 是状态管理axios 做请求层。unplugin-auto-import 和 unplugin-vue-components 负责不用手动 import 地使用 Vue API 和 Naive UI 组件。下表是当前后台管理系统里常见的依赖分工照着装不会踩大坑依赖用途需要手动引入的典型导入naive-ui表单、表格、布局、反馈组件模板中直接使用 n- 前缀组件vue-router4路由映射、路由守卫createRouter / createWebHistorypinia跨页面状态、用户信息、菜单defineStoreaxios后端接口请求、拦截器创建 axios 实例unplugin-auto-import自动导入 Vue 组合式 API无需主动 import ref/computedunplugin-vue-components自动按需导入组件库配合 NaiveUiResolver 使用2.2 在 vite.config.ts 里配置按需引入和路径别名Naive UI 支持全量引入但后台管理系统页面多、组件杂全量打包会让首屏带上大量不需要的组件。常见做法是用 unplugin-vue-components 的 NaiveUiResolver让模板里出现的 n-button、n-table 等组件按需注册。vite.config.ts 典型配置如下import { defineConfig } from vite import vue from vitejs/plugin-vue import AutoImport from unplugin-auto-import/vite import Components from unplugin-vue-components/vite import { NaiveUiResolver } from unplugin-vue-components/resolvers import path from node:path export default defineConfig({ plugins: [ vue(), AutoImport({ // 自动导入 vue / vue-router / pinia 的 API imports: [vue, vue-router, pinia], dts: src/types/auto-imports.d.ts }), Components({ // 识别模板中的 n- 前缀组件 resolvers: [NaiveUiResolver()], dts: src/types/components.d.ts }) ], resolve: { alias: { // src 别名 : path.resolve(__dirname, src) } } })AutoImport 的 imports 配置了 vue、vue-router、pinia 之后组件里直接用 ref、computed、useRoute、storeToRefs 都不会报未定义。dts 参数会生成类型声明文件建议把它纳入 tsconfig 的 include 范围否则编辑器会划红线。Components 里的 NaiveUiResolver 会自动识别模板中的 n- 前缀组件同时生成 components.d.ts让模板里的组件类型可查。注意这段配置文件如果直接放到原生 ESM 环境下__dirname是不存在的需要先写const __dirname fileURLToPath(new URL(., import.meta.url))否则启动即报错。按需引入只解决模板中直接使用的组件。naive-ui 中有一些函数式 API例如 message、dialog、notification它们不在模板里出现建议还是显式从 naive-ui 里导入。否则在组件外调用message.error时容易拿到未挂载的实例。2.3 环境变量和后端代理后台管理系统必然要面对本地联调。把接口地址放进 .env.development而不是写死在 request 文件里换一个人接手时就不会到处翻代码# .env.development VITE_API_BASE/api接口统一以 /api 开头本地开发通过 Vite 的 server.proxy 转发到后端实际地址。这样 axios 里的 baseURL 可以保持简洁也方便后端网关在线上做同样的路径前缀server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ) } } }target 指向后端服务地址changeOrigin 让请求头里的 Host 与 target 一致rewrite 去掉 /api 前缀。如果后端接口本身就带 /api把 rewrite 删掉即可。注意 proxy 只在开发模式生效生产环境通常由 Nginx 或者网关层解决。2.4 建议的 src 目录结构页面还没写先把目录分成 api、components、layouts、router、store、views、utils。给一个最小但真实的结构src/ api/ # 按模块拆接口如 auth.ts、order.ts components/ # 通用业务组件 layouts/ # 后台主框架 router/ # 路由配置与守卫 store/ # pinia 模块 views/ # 页面按模块建子目录 utils/ # 非业务工具函数api 单独放的意义是组件里不出现字符串 url接口出问题只需要查 api 层。layouts 放侧边栏、头部、内容区骨架避免每个页面重复写上下文。这样结构在工程里跑起来后后续加主题、加路由权限都不会推倒重来。3. NConfigProvider 统一暗色模式与后台管理系统布局框架Naive UI 和很多组件库最大的差异在于主题是对象而不是一堆 CSS 变量。全局主题通过 NConfigProvider 注入组件内部根据 theme 对象完成换肤因此做暗色模式不需要覆盖几百个类名。后台管理系统的布局则需要把侧边栏、头部、内容区、菜单四者串成一套可复用的“外壳”。3.1 全局语言、日期和主题覆写组件库默认英文日期类组件尤其明显。很多 Vue3 后台管理系统第一次运行时表格分页是英文、日期选择器是英文原因就是只装了组件没配 locale。在 App.vue 外层统一处理script setup langts import { computed, ref } from vue import { NConfigProvider, NMessageProvider, darkTheme, zhCN, dateZhCN } from naive-ui const isDark ref(false) // 亮色模式传 null暗色模式传 darkTheme const theme computed(() (isDark.value ? darkTheme : null)) // 覆写全局设计变量 const themeOverrides { common: { primaryColor: #2d8cf0, infoColor: #2080f0, borderRadius: 4px } } /script template n-config-provider :themetheme :theme-overridesthemeOverrides :localezhCN :date-localedateZhCN n-message-provider router-view / /n-message-provider /n-config-provider /templatetheme 为 null 时组件库走默认亮色主题为 darkTheme 时整体切换暗色。isDark 可以挂在 Pinia 里的设置模块让保存的用户偏好跨页面生效。themeOverrides 不是覆盖 CSS而是覆盖 Naive UI 设计变量。common 里的 primaryColor 会同步作用于按钮、选中菜单、链接等场景。locale 和 dateLocale 用它自带的 zhCN、dateZhCN中文后台不会出现英文日期控件。NMessageProvider 是必要的后面调用 message API 时它负责把消息实例挂载到当前上下文。主题覆写变量比较多实际项目只需要盯几个最常用的变量名作用示例值common.primaryColor全局主色#2d8cf0common.borderRadius控件圆角4pxcommon.bodyColor页面底色#f5f7fa3.2 用 NLayout 和 NMenu 搭侧边栏后台管理系统的页面结构高度相似建议直接做一个 MainLayout 组件把路由视图丢进 content 区template n-layout has-sider styleheight: 100vh n-layout-sider bordered collapse-modewidth :collapsedcollapsed :width220 :collapsed-width64 div classlogoAdmin/div n-menu :collapsedcollapsed :valueactiveKey :optionsmenuOptions update:valuehandleMenuSelect / /n-layout-sider n-layout n-layout-header bordered styleheight: 64px n-space aligncenter styleheight: 100%; padding: 0 16px n-button quaternary clickcollapsed !collapsed菜单开关/n-button /n-space /n-layout-header n-layout-content content-stylepadding: 16px styleheight: calc(100vh - 64px) router-view v-slot{ Component } keep-alive :max10 component :isComponent / /keep-alive /router-view /n-layout-content /n-layout /n-layout /templaten-layout-sider 的 collapse-mode 设为 width折叠时宽度由 collapsed-width 控制n-menu 的 collapsed 要与侧边栏状态一致。n-layout-content 的内容区高度用 calc 减去 header 高度避免出现滚动条错位。n-menu 接收的 options 是一组 MenuOptionvalue 与当前路由 path 对应点击菜单时通过 router.push 跳转刷新页面后 activeKey 根据当前路由回填。菜单项的最佳来源不是手写 JSON而是把路由表转成 MenuOption。因为后台管理系统的页面通常有权限过滤菜单与路由保持同一份数据源才不容易出现“能跳转但菜单没入口”的情况。转换函数可以收敛到 utilsimport type { RouteRecordRaw } from vue-router import type { MenuOption } from naive-ui export function mapRoutesToMenu(routes: RouteRecordRaw[]): MenuOption[] { return routes .filter((route) !route.meta?.hidden) .map((route) { const option: MenuOption { // key 直接使用路由 path后面回显不需要再维护映射 label: route.meta?.title ?? String(route.name ?? ), key: route.path, icon: route.meta?.icon } if (route.children?.length) { option.children mapRoutesToMenu(route.children) } return option }) }这里说明一下 return 的逻辑route.meta.title用于显示中文菜单名icon 可以是函数返回 h(NIcon)。路由组件懒加载后children 列在路由配置里但不会因为写在菜单而提前加载页面组件。3.3 keep-alive 与组件 name 的关系使用 keep-alive 缓存页面时一个很隐蔽的问题会找上门明明设置了 include页面还是每次刷新。原因是 Vue3 的script setup组件默认没有 namekeep-alive 的 include 匹配不到。解决方式是用 Vue 3.3 提供的 defineOptionsscript setup langts defineOptions({ name: OrderList }) /script缓存列表页后切到详情再返回分页条件不会丢失这对后台管理系统的高频操作帮助很大。注意 name 必须和 include 数组里的字符串一致否则缓存依旧无效。如果缓存不上第一件事不是去调 keep-alive 配置而是确认页面组件有没有显式 name。4. Pinia 管理登录态路由权限落在 beforeEach后台管理系统几乎躲不开登录、菜单权限、按钮权限。Vue3 下状态管理首选 Pinia不是因为它是“新的”而是它去掉了 Vuex 的 mutation 约束让登录态这种逻辑在 setup store 里写起来更顺手。权限控制不建议只在前端路由配置一个静态 roles 数组而是让登录后根据当前用户动态生成可访问路由。4.1 Auth Storetoken 与用户信息先写一个最基础的 auth storetoken 持久化到 localStorage保证页面刷新后登录态不丢import { defineStore } from pinia import { loginApi, getUserInfoApi } from /api/auth import type { LoginParams, UserInfo } from /api/auth/types export const useAuthStore defineStore(auth, () { const token ref(localStorage.getItem(token) || ) const userInfo refUserInfo | null(null) function setToken(value: string) { token.value value localStorage.setItem(token, value) } async function login(payload: LoginParams) { const data await loginApi(payload) setToken(data.token) userInfo.value data.userInfo return data } async function fetchUserInfo() { const data await getUserInfoApi() userInfo.value data return data } function logout() { token.value userInfo.value null localStorage.removeItem(token) } return { token, userInfo, setToken, login, fetchUserInfo, logout } })setup store 里用 ref 定义状态用函数写操作return 的对象暴露给组件。注意登录接口返回的字段需要和后端约定token、userInfo 最好一次返回如果登录后还要单独拉用户信息可以在 login 里调用 fetchUserInfo。logout 只需要清掉本地状态跳转交给 router 的守卫做。4.2 Axios 封装拦截器统一处理 token 和 401不推荐每个页面直接 fetch因为后端接口变动频繁统一封装后所有错误处理只写一份。下面的 request.ts 是常见做法import axios from axios import type { AxiosInstance, InternalAxiosRequestConfig } from axios import { useAuthStore } from /stores/auth import { message } from naive-ui const request: AxiosInstance axios.create({ baseURL: import.meta.env.VITE_API_BASE, timeout: 15000 }) request.interceptors.request.use((config: InternalAxiosRequestConfig) { const authStore useAuthStore() // 每个请求带上登录态 if (authStore.token) { config.headers.Authorization Bearer ${authStore.token} } return config }) request.interceptors.response.use( (response) response.data, (error) { // 401 统一登出并回到登录页 if (error.response?.status 401) { const authStore useAuthStore() authStore.logout() window.location.replace(/login) } else { message.error(error.response?.data?.message || 请求失败) } return Promise.reject(error) } ) export default requestaxios 实例的 baseURL 从环境变量读取这样 Vite 代理在开发和生产可以指向不同网关。请求拦截器里给每个请求加 Authorization后端按 Bearer token 解析。响应拦截器直接返回 response.data调用方拿到的就是接口业务数据不需要每次写resp.data.data。401 是一种特殊语义这里主动登出并跳到登录页其他错误让 message 弹错。实际项目中如果后端使用 Refresh Token 机制401 并不总是登出而是尝试刷新 token 后重放请求。此时需要把请求队列存起来在新 token 回来后按同一份 config 再次 request。首次实现建议先做简单版避免刷新机制引入竞态。4.3 路由守卫动态路由要防止死循环菜单和页面都应由后端权限决定前端只留下登录页和公共页。常见做法是用户登录成功后再请求一份路由数据通过 router.addRoute 动态注册。守卫这样写router.beforeEach(async (to) { const authStore useAuthStore() if (to.path /login) { return authStore.token ? /dashboard : true } if (!authStore.token) { return { path: /login, query: { redirect: to.fullPath } } } // 动态路由还没加载时先拉取再重新进入当前路由 if (!authStore.dynamicRoutesLoaded) { const routes await authStore.fetchDynamicRoutes() routes.forEach((route) router.addRoute(route)) return { ...to, replace: true } } return true })fetchDynamicRoutes 请求后端返回的页面路径和组件路径前端把字符串转成 import.meta.glob 里的组件生成 RouteRecordRaw。这里的返回{ ...to, replace: true }很关键路由刚刚 add 完成如果不重新进入一次当前页面匹配到的组件还是旧的直接跳会 404。replace 会替换当前 history 记录避免用户按返回又回到空白页。由于 addRoute 后再次进入守卫时 dynamicRoutesLoaded 已经是 true所以不会发生无限循环。按钮级权限不要放在路由元信息里做全局判断而是抽一个 hasPermission 指令或工具函数在模板里根据当前用户按钮列表控制 v-if。为什么这层不放进路由守卫因为按钮不存在独立 url路由并不能感知它是否被渲染。动态路由方案各有取舍方案优点缺点前端全量路由配置简单权限不可控代码可被直接访问后端返回菜单前端渲染固定组件权限集中在后端需要维护 path 与组件映射后端返回完整路由权限最严格前后端开发节奏强绑定后台管理系统建议采用第二种菜单权限灵活前端又能保持组件的懒加载能力。5. NDataTable、NForm 与 ECharts 组成后台常用页面后台管理系统的“页面”大多由表格、筛选表单、详情弹窗、看板图表组成。Naive UI 的 NDataTable 把列定义和表格渲染解耦NForm 的 rules 模型和 Element 系列风格类似切换成本不高。需要再提升体验的是数据加载时机和图表按需注册。5.1 列表页的分页、加载状态与 remote 模式列表页最常见的错误是把所有数据一次拉到前端再“假分页”。数据量一涨接口响应和内存都很吃亏。标准做法是接口分页分页变化后重新请求。代码一般在 pages 里写成这样script setup langts import { h, onMounted, ref } from vue import type { DataTableColumns } from naive-ui interface OrderItem { orderNo: string status: number statusText: string } const data refOrderItem[]([]) const loading ref(false) const page ref(1) const pageSize ref(20) const total ref(0) const columns: DataTableColumnsOrderItem [ { title: 订单号, key: orderNo }, { title: 状态, key: status, // 用 render 渲染状态文本避免模板里再包一层 render: (row) h(span, row.statusText) } ] async function loadData() { loading.value true try { const result await getOrders({ page: page.value, pageSize: pageSize.value }) data.value result.list total.value result.total } finally { loading.value false } } // remote 模式下页码变化要手动刷新 function handlePageChange(nextPage: number) { page.value nextPage loadData() } function handlePageSizeChange(nextSize: number) { pageSize.value nextSize page.value 1 loadData() } onMounted(loadData) /script template n-data-table remote :columnscolumns :datadata :loadingloading :pagination{ page, pageSize, itemCount: total, onChange: handlePageChange, onUpdatePageSize: handlePageSizeChange } / /templateremote 表示分页状态由外部接管NDataTable 不自己改页码。pagination 对象里传入 page、pageSize、itemCount再通过 onChange / onUpdatePageSize 回调通知列表刷新。total 来自接口不能在回调里自己累加。对于筛选条件通常用一个响应式 searchParams 对象组装请求参数不要频繁修改地址栏 query筛选表单提交和重置都复用同一个 loadData。5.2 NForm 校验rules 里的 trigger 不能随手写表单弹窗是后台系统最密集的交互。Naive UI 的 NForm 校验依赖表单控件的 value 同步修改给一个最小可复用的例script setup langts import { ref } from vue import type { FormInst, FormRules } from naive-ui const formRef refFormInst | null(null) const model ref({ username: , role: null }) const rules: FormRules { username: { required: true, message: 请输入用户名, trigger: [input, blur] }, role: { required: true, type: number, message: 请选择角色, trigger: [change] } } async function submit() { // 校验失败时 validate 会 reject await formRef.value?.validate() // 校验通过后提交 } /script template n-form refformRef :modelmodel :rulesrules n-form-item label用户名 pathusername n-input v-model:valuemodel.username / /n-form-item n-form-item label角色 pathrole n-select v-model:valuemodel.role :options[{ label: 管理员, value: 1 }] / /n-form-item /n-form /templaterules 的每条规则里trigger 必须匹配控件实际触发事件文本类输入要监听 input 和 blur选择类只能监听 change。如果把 select 的 trigger 写成 input表单不校验这是日期/下拉组件的常见坑。validate 在出现错误时会 reject提交函数用 await 包裹后只有在校验通过才会继续执行创建或更新接口。自定义校验与必填并不冲突例如确认密码可以通过 validator 比较两个字段。trigger适用控件说明inputn-input输入过程中触发blur文本类控件失焦校验changen-select / n-date-picker值改变后触发5.3 数据看板用 ECharts 按需注册图表页面别直接import * as echarts from echarts。ECharts 整体包比大部分组件库还重后台看板又是多图表页面最后会拖慢首屏。正确姿势是从 echarts/core 引入所需图表和组件import * as echarts from echarts/core import { BarChart, LineChart, ScatterChart } from echarts/charts import { GridComponent, TooltipComponent, LegendComponent } from echarts/components import { CanvasRenderer } from echarts/renderers // 只注册当前看板用到的图表类型 echarts.use([ BarChart, LineChart, ScatterChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer ])需要展示气泡图时把 ScatterChart 注册进来即可不需要额外引入整个包。主题切换时直接在当前图表的 setOption 里更新背景色方案不要一直 init 新实例。组件卸载前必须调用 dispose否则页面切走后再回来会生成多个 canvas且事件监听不清理。更稳妥的是封装一个useChart(elRef, option)组合式函数在 onBeforeUnmount 统一处理。5.4 列表懒加载与滚动触底加载Vue3 后台管理系统里常见的是无限滚动列表监听滚动容器时注意 target尽量用原生事件而不是节流否则交互卡顿。一个短实现// 滚动容器近似到底部时触发加载 function handleScroll(event: Event) { const el event.target as HTMLElement const distanceToBottom el.scrollHeight - el.scrollTop - el.clientHeight if (distanceToBottom 50 !loading.value) { loadMore() } }距离底部小于 50px 时自动加载下一页loading 状态能避免请求重叠。如果用 NScrollbarscroll 事件要绑在内容层不能绑在外面容器上否则需要在 n-scrollbar 上设置对应事件或使用 ref 查找实例。这算一个容易被忽略的细节。6. 构建前的类型检查与产物拆包后台管理系统功能越多构建期越容易隐藏问题。最常见的是模板里未定义的变量、类型不匹配在运行时才暴露。Naive UI 组件多构建产物也会变大最后一步做两件事先类型检查再打包然后把大依赖拆成独立 chunk。6.1 把 vue-tsc 放进 build 命令package.json 里的 build 命令建议直接替换成{ scripts: { build: vue-tsc --noEmit vite build } }vue-tsc 会检查 .vue 文件里 script 和 template 的类型例如 NDataTable 的 columns 类型写错会在 CI 阶段直接报错。第一次跑大概率会冒出一堆接口类型缺定义的问题这不是工具出错而是项目一直缺少类型收口。把接口返回类型对应补齐后后续重构就安全得多。6.2 拆分 vendor chunk而不是把所有依赖打成一包路由级懒加载已经把 views 拆成了独立 js但 naive-ui、echarts 这种大库建议单独拆出来否则每个页面产物都带一份自己的依赖。在 vite.config.ts 里加 build.rollupOptions.output.manualChunksbuild: { rollupOptions: { output: { manualChunks: { // 核心依赖拆成单独文件 vue: [vue, vue-router, pinia], naive: [naive-ui], echarts: [echarts] } } } }manualChunks 的对象 key 是产物文件名value 是依赖包名。打包后生成 vue.js、naive.js、echarts.js。用户访问第二个页面时只要依赖版本不变浏览器会直接用缓存。注意拆包粒度不要到具体组件那会制造大量小文件。HTTP/2 环境下文件数量影响小但高并发场景下还是要权衡。6.3 验证产物不能只看 build 成功本地跑出 dist 后用vite preview预览一次重点看路由懒加载的页面切换、暗色主题是否闪烁、中文 locale 是否生效。Naive UI 组件如果没写 locale日期控件在暗色模式下会显示英文月份需要在 NConfigProvider 上确认已经传了 dateZhCN。把 dist 放到任意静态服务器时还要确认 history 路由需要 fallback用 nginx 时配置 try_files 指向 index.html否则刷新子路由会报 404。本文还有配套的精品资源点击获取

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

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

免费获取报价