简介:基于Vue3与Element-Plus构建的图书管理系统设计源码,面向需要快速搭建图书管理功能的前端学习者和初级工程师,可支撑图书信息维护、借阅归还、读者管理等常见业务场景。压缩包共33个文件,主要由14个Vue组件、10个JavaScript脚本、2个JSON配置文件及入口页面、静态资源组成,整体约2.41MB;Vue组件负责界面展示与用户交互,JavaScript脚本实现业务逻辑,JSON文件保存系统配置,目录结构清晰便于阅读。该工程采用vue-router处理页面导航、vue-pinia管理应用状态,并附带browserslistrc、.gitignore等工程化配置,适合作为课程设计、毕业设计或技术学习的参考项目。已有384人学习下载,源码开放程度高,可根据自身需求扩展图书分类、统计报表等附加功能,对理解组件化开发与状态管理具有实用价值。
1. 基于 Vue3 与 Element-Plus 的图书管理系统源码拆解
图书管理系统经常被当成练手项目,但它实际上覆盖了 vue3 后台管理系统几乎所有核心场景:登录鉴权、带条件的列表查询、跨组件状态共享、借还书的状态流转。这套基于 Vue3 与 Element-Plus 的源码共 33 个文件,由 14 个 Vue 组件、10 个 JavaScript 脚本和 2 个 JSON 配置组成,规模不大,但目录分工完整——从路由、状态管理到请求封装,每一层都能找到对应文件。
它要解决的痛点很具体:管理员需要在一个界面里完成图书维护、读者管理、借书还书与超期判断,而这些操作天然依赖“当前登录者是谁”这个全局状态。用 vue-router 管页面导航、pinia 管登录态、Element-Plus 提供表格和表单组件,正好以最少的代码覆盖全部需求。
这套源码适合两类人:刚学完 Vue3 基础、想找一个完整工程来理解组合方式的人;以及从后端转前端、想快速掌握现代前端工程化结构的开发者。下面按入口、分层、业务模块、部署的顺序拆解,关键文件给出可直接抄走的代码。
2. 目录分层与工程边界:Vue3 项目的职责划分
先明确一个观察结论:看这类 Vue3 项目的源码,推荐的阅读顺序是 main.js → App.vue → router → views → components → store → network/api。这套源码恰好按这个链路组织,每个目录都承担了明确职责,下面逐层说明。
2.1 入口与构建配置:main.js、babel.config.js 与依赖命名
main.js 是 Vue3 应用的启动点,常见做法是 createApp 创建实例后,依次注册 pinia、router,再 use 掉 Element-Plus 组件库,最后 mount 到 #app。这个项目里 Element-Plus 走全量引入,因为组件规模不大,全量引入的打包体积增量可以接受,还省去了按需引入需要额外配置 unplugin-vue-components 的步骤。
// src/main.js —— Vue3 入口最常见的组织方式 import { createApp } from 'vue' import App from './App.vue' import router from './router' import { createPinia } from 'pinia' import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' const app = createApp(App) app.use(createPinia()) app.use(router) app.use(ElementPlus) app.mount('#app')这里有一点必须提醒:项目内依赖字段写的是 vue-pinia,但 npm 上实际的包名是 pinia,API 入口是 createPinia。如果照抄依赖名去安装,会得到 404 报错;babel.config.js 的存在也说明了这个项目基于 Vue CLI(webpack)构建,而不是 Vite,所以启动命令是 npm run serve,不是 npm run dev。看到这两个文件,基本就能判断项目的构建体系,后面的浏览器兼容配置也是围绕 webpack 走的。
2.2 network 与 api 双目录:请求链路为什么拆两层
很多 Vue3 初学者会把 axios 请求直接写进组件,这套源码没有。src/network 下有 index.js、user.js、book.js、borrow.js、reader.js,src/api 下有 global.js。双目录结构的常见分工是:network 负责创建 axios 实例并配置拦截器,比如请求头自动带 token、响应统一剥离外壳;api 层负责把后端接口定义成函数,组件只调用函数,不直接接触 axios。
// src/network/index.js —— axios 实例与拦截器的常见封装 import axios from 'axios' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) config.headers.Authorization = `Bearer ${token}` return config }) request.interceptors.response.use( res => res.data, err => Promise.reject(err) ) export default request参数说明:baseURL 定为 /api 是前后端分离项目最常见约定,开发环境由构建工具的 devServer.proxy 转发到真实后端,生产环境由 nginx 把 /api 反代到后端服务。响应拦截器统一返回 res.data 后,network/book.js 里每个接口函数会非常短,但代价是丢失响应头信息,所以分页总数这类数据要让后端放进 body 而不是 header。
| network 文件 | 职责 | 备注 |
|---|---|---|
| index.js | axios 实例、拦截器 | 所有请求的唯一出口 |
| user.js | 登录、用户信息接口 | 配合 userStore 使用 |
| book.js | 图书增删改查接口 | 图书管理模块调用 |
| borrow.js | 借书、还书、借阅记录接口 | 支撑三个借还组件 |
| reader.js | 读者信息接口 | 读者管理使用 |
2.3 views 与 components 的边界:页面容器与业务组件
views 目录下有 Nav.vue、Header.vue、Login.vue、Content.vue、Home.vue,components 下有 BookManage.vue、BookQuery.vue、Borrow.vue、Borrowbooks.vue、BackBooks.vue、Reader.vue、User.vue。区分依据很直接:views 里是路由直接对应的页面组件,负责布局与组装;components 里是被页面引用的业务组件,比如 BookQuery 是查询表单,BookManage 是图书列表表格,它们被挂载到 Home 或 Content 内部。
这里有一个后台管理系统里非常经典的组合:Header.vue 与 Nav.vue 构成“顶部导航 + 侧边菜单 + 内容区”布局。Element-Plus 的 el-menu 开启 router 模式后,菜单项点击会直接触发 vue-router 跳转;进阶做法是把菜单和页签联动,路由变化时自动新增 tab,关闭 tab 时回退到上一个路由,Content.vue 正是这类联动的落点。这个项目的组件数量不多,很适合看清联动逻辑。
2.4 store 与 router:pinia 状态管理与路由守卫
src/store/userStore.js 是唯一的 pinia store,管理 token、用户信息与登录态。为什么图书管理系统也需要全局状态?因为 BookManage、Borrow、Reader 都要知道当前登录者是谁,借书时要记录操作人、还书时要校验身份。如果每个组件都去 localStorage 读一遍,代码会散落且不好维护,pinia 的做法是把登录态收敛成一个 userStore,任何组件调用 useUserStore() 拿到的是同一份响应式数据。
// src/store/userStore.js —— pinia 状态管理的典型写法 import { defineStore } from 'pinia' export const useUserStore = defineStore('user', { state: () => ({ token: localStorage.getItem('token') || '', userInfo: JSON.parse(localStorage.getItem('userInfo') || 'null') }), getters: { isLoggedIn: state => !!state.token }, actions: { setLogin(data) { this.token = data.token this.userInfo = data.userInfo localStorage.setItem('token', data.token) localStorage.setItem('userInfo', JSON.stringify(data.userInfo)) }, logout() { this.token = '' this.userInfo = null localStorage.removeItem('token') localStorage.removeItem('userInfo') } } })这段代码有两个值得抄的设计:state 初始化时直接从 localStorage 读取,刷新页面登录态不丢;getters 里的 isLoggedIn 是组件里 computed 的替代,把“是否登录”的判定收敛在 store 内,多个组件复用同一逻辑。router/index.js 的全局前置守卫会读取这个 getter,未登录就重定向到 Login.vue,具体实现放在第 4 章展开。
3. 图书管理模块实战:BookManage 与 BookQuery 的 CRUD 实现
图书管理的核心是增删改查。这个项目把“查询”单独拆成 BookQuery.vue,把“列表与维护”放进 BookManage.vue,两个组件通过父子事件联动。
3.1 查询表单与列表的联动模式
BookQuery 负责收集查询条件:书名、ISBN、分类、借阅状态。常见做法是 BookQuery 通过 emit('search', form) 把条件抛给父组件,父组件把条件传给 BookManage,由 BookManage 发起请求。为什么不把搜索框直接放进 BookManage?因为 Reader 组件和借阅列表也存在按读者筛选的需求,把查询拆成独立组件后,同一套表单可以复用到不同场景。
// BookQuery.vue —— 查询表单的提交与重置 const formRef = ref(null) const queryForm = reactive({ bookName: '', isbn: '', category: '', status: '' }) const handleSearch = () => { emit('search', { ...queryForm }) } const handleReset = () => { formRef.value?.resetFields() emit('search', { bookName: '', isbn: '', category: '', status: '' }) }参数说明:queryForm 用 reactive 而不是 ref,是因为多个字段需要整体传递,reactive 展开后结构更干净。formRef.value?.resetFields() 是 Element-Plus 表单的受控重置方式,会自动清除校验状态并把字段恢复为初始值,可选链必须写,因为表单可能尚未渲染完毕。重置之后也发射一次 search 事件,这样列表能立刻回到无筛选状态,而不需要用户手动再点一次查询。图书状态在接口层通常用数字表示,展示层的映射关系见下表。
| 图书状态值 | 含义 | 对应展示 |
|---|---|---|
| 0 | 借出 | warning 标签 |
| 1 | 在馆 | success 标签 |
3.2 el-table 与 el-pagination:列表加载和分页状态
BookManage 的核心是 el-table。很多 Vue3 新手会把分页参数写死在接口函数里,正确做法是分页状态由组件维护,翻页时带着条件重新请求。
<el-table :data="bookList" v-loading="loading" border> <el-table-column prop="bookName" label="书名" min-width="160" /> <el-table-column prop="isbn" label="ISBN" width="160" /> <el-table-column prop="category" label="分类" width="100" /> <el-table-column prop="status" label="状态" width="100"> <template #default="{ row }"> <el-tag :type="row.status === 1 ? 'success' : 'warning'"> {{ row.status === 1 ? '在馆' : '借出' }} </el-tag> </template> </el-table-column> <el-table-column label="操作" width="160" fixed="right"> <template #default="{ row }"> <el-button link type="primary" @click="openEdit(row)">编辑</el-button> <el-button link type="danger" @click="handleDelete(row)">删除</el-button> </template> </el-table-column> </el-table> <el-pagination v-model:current-page="page" v-model:page-size="pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next" @change="loadList" />这段模板里有两个容易被忽略的点。status 字段用 el-tag 做映射,比直接显示 0/1 可读性强,状态字段保留数字是为了和后端数据结构对齐,展示层的翻译交给前端。el-pagination 使用 v-model:current-page 与 v-model:page-size 双向绑定,在 Vue3 中它等价于 :current-page 加 @update:current-page;Element-Plus 的 change 事件会同时携带新的页码与每页条数,直接绑定 loadList 就能在翻页和修改每页条数时复用同一套查询逻辑。
3.3 弹窗表单与校验:新增与编辑共用一套表单
新增和编辑共用一个 el-dialog 表单,区别只在提交时是否携带 id。切换逻辑通常是:openCreate 时把表单重置为初始值,title 设为“新增图书”;openEdit(row) 时把该行数据铺进表单。这里最容易写出重复代码的地方,是两套 submit 函数,实际上只需用一个 handleSubmit 判断 bookForm.id 是否存在。
const dialogVisible = ref(false) const dialogTitle = ref('新增图书') const formRef = ref(null) const bookForm = reactive({ id: null, bookName: '', isbn: '', category: '', author: '' }) const rules = { bookName: [{ required: true, message: '书名不能为空', trigger: 'blur' }], isbn: [ { required: true, message: 'ISBN 不能为空', trigger: 'blur' }, { pattern: /^(?:\d{10}|\d{13})$/, message: 'ISBN 格式不正确', trigger: 'blur' } ] } const openEdit = (row) => { dialogTitle.value = '编辑图书' Object.assign(bookForm, row) dialogVisible.value = true } const handleSubmit = async () => { await formRef.value.validate() if (bookForm.id) { await updateBook(bookForm) } else { await createBook(bookForm) } ElMessage.success(bookForm.id ? '更新成功' : '新增成功') dialogVisible.value = false loadList() }参数说明:Object.assign(bookForm, row) 是编辑场景的标准写法,比逐个字段赋值省代码且不易漏字段;formRef.value.validate() 必须在判断 id 之前执行,否则无效表单也会提交,validate 失败时会 reject,正好被外层的 try/catch 或 async 错误处理接住。isbn 校验同时兼容 10 位和 13 位,这是图书系统常见做法,trigger 选 blur 表示失焦时才校验,用户还在输入时不打扰。handleSubmit 末尾调用 loadList 重新拉取当前页,新增和编辑后的列表一致性靠这一句保证。
3.4 删除确认与当前页回退
删除的交互推荐 ElMessageBox.confirm,因为它能统一处理异步确认结果。除了删除后重载列表,还有一个容易漏掉的边界:如果当前页只剩一条记录且不在第一页,删除后 total 减少,继续停留会看到空列表,正确的做法是删除成功后主动回退一页。
const handleDelete = async (row) => { const confirmed = await ElMessageBox.confirm( `确定删除《${row.bookName}》吗?`, '删除确认', { type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' } ).catch(() => false) if (!confirmed) return await deleteBook(row.id) ElMessage.success('删除成功') if (bookList.value.length === 1 && page.value > 1) { page.value -= 1 } loadList() }这里 .catch(() => false) 是防止 Unhandled Promise Rejection 的常用处理:ElMessageBox.confirm 在用户点取消时会 reject,不接住的话控制台会持续报错。confirmed 为 false 时直接 return,把“取消”和“真正出错”两个路径分开。删除操作只在内存中减少页码还不够,必须等 loadList 重新请求,因为后端分页接口返回的是根据当前 total 计算出的数据。
4. 借书、还书与登录链路:状态管理与路由守卫的配合
借阅是本系统的核心业务流,涉及 Borrow.vue、Borrowbooks.vue、BackBooks.vue 三个组件,分别负责发起借阅、查看借出列表、处理归还。三个组件共享图书列表和借阅记录这两类数据,但这个项目没有为借阅单独建 store,而是通过 network/borrow.js 的接口和组件内响应式状态支撑——因为借阅记录的生命周期只在当前页面内有意义,跨页面共享需求很弱,强行抽 store 属于过度设计。
4.1 借书流程的数据流与 computed 的使用
借书是事务性操作:选定一本书、选定一个读者,提交 readerId、bookId、borrowDate、dueDate 四个字段,后端同时更新图书状态并生成借阅记录。前端要做的关键校验是过滤掉已借出的图书,否则提交后必然被后端拒绝。可借的图书列表一般通过 computed 从全部图书中派生。
const availableBooks = computed(() => allBooks.value.filter(b => b.status === 1) )Vue3 中 computed 是“依赖响应式数据自动生成新数据”的标准表达,比在 method 里手动 filter 再赋回 data 清晰得多。这里也回答了面试里常被问到的 computed 与 watch 的选型:如果结果是另一份派生数据,用 computed;如果要在状态变化后触发接口请求或重置表单这类副作用,才用 watch。UI 上还要再补一道保险,在 el-select 的 option 里通过 :disabled="b.status !== 1" 把已借出的书置灰,即使 computed 出错,用户也选不到无效项。
4.2 还书逻辑与借阅记录状态设计
BackBooks 组件的核心不是“点击还书”这个动作,而是还书前的状态校验。借阅记录需要表达三层状态:借出中、已归还、超期未还。超期与否可以前端比较当前日期和 dueDate,但逾期费用建议以后端计算结果为准,前端只负责展示。下表是借阅记录接口常见的字段设计,注意这里的 status 与图书表中的状态字段是两套枚举,不要混用。
| 字段 | 类型 | 含义 |
|---|---|---|
| status | 0 / 1 / 2 | 0 借出中,1 已归还,2 超期未还 |
| borrowDate | string | 实际借出日期 |
| dueDate | string | 应归还日期 |
| overdueFee | number | 超期费用,未超期时为 0 |
还书确认弹窗里应该把逾期费用显示出来,让管理员在确认前就知道结果。还书成功后,除了刷新借阅列表,还要联动刷新图书列表,否则切回图书管理页时,那本书的状态仍显示“借出”。
const returnBook = async (row) => { const confirmed = await ElMessageBox.confirm( `确认收到《${row.bookName}》的归还?逾期费用:${row.overdueFee ?? 0} 元`, '还书确认', { type: 'info' } ).catch(() => false) if (!confirmed) return await returnBorrow(row.id) ElMessage.success('还书成功') loadBorrowList() emit('refresh-book-list') // 通知图书列表刷新在馆状态 }4.3 路由守卫、登录态与角色权限
登录链路由 Login.vue、userStore.js、network/user.js 组成。登录页提交账号密码,login 接口返回 token 与用户信息,写入 userStore,然后跳转首页。其余页面统一在全局前置守卫里校验登录态。
// src/router/index.js —— 全局前置守卫的典型写法 import { createRouter, createWebHistory } from 'vue-router' import { useUserStore } from '../store/userStore' const router = createRouter({ history: createWebHistory(), routes: [ { path: '/login', component: () => import('../views/Login.vue') }, { path: '/', component: () => import('../views/Home.vue'), meta: { requiresAuth: true } } ] }) router.beforeEach((to, from, next) => { const userStore = useUserStore() if (to.meta.requiresAuth && !userStore.isLoggedIn) { next('/login') } else { next() } })这段示意里 routes 只列了两个,实际系统会在 Home 下继续挂载 Content 等子路由,meta.requiresAuth 可以按路由粒度控制。两个容易踩的坑:第一,createWebHistory 是 HTML5 History 模式,URL 干净,但部署时 nginx 必须配置 try_files 兜底到 index.html,否则刷新子路由返回 404,后面部署章节会再次强调;第二,useUserStore() 只能在 pinia 实例注册后调用,所以放在 beforeEach 内部是安全的,如果把它提到 router 模块顶层去解构,会得到 pinia 未初始化的报错。
提示:如果还有多角色需求,在 userStore 里加 role 字段,路由 meta 里声明 roles 数组,在 beforeEach 中再增加一层角色校验即可。动态注册路由对这个体量的系统收益有限,不建议一开始就上。
5. 安装配置、浏览器兼容与 Nginx 部署实战
5.1 本地运行与 Vue3 环境配置
Node 版本建议 16.14 以上。依赖安装用 npm install,开发启动 npm run serve,生产构建 npm run build。如果安装过程出现 node-sass 或 python 相关报错,优先删除 node_modules 和 package-lock.json 后重新安装,比逐个补环境变量省事。
5.2 .browserslistrc:浏览器兼容边界
Vue3 构建产物依赖较新的 ES 特性。如果线上在旧版 Edge 或老旧 PC 浏览器上表现异常,先看 .browserslistrc。
> 1% last 2 versions not dead这套配置的含义是覆盖市场占有率超过 1% 的浏览器、每个浏览器最近两个版本、排除已停止维护的。遇到兼容问题时把它收紧为 Chrome >= 60、Edge >= 79 再重新构建,很多脚本报错会在编译阶段被降级处理掉。
5.3 Nginx 部署关键配置
这里给的是 Windows 服务器与 Linux 通用配置片段。构建完成后,把 dist 内容放到 nginx 站点目录,核心是两条规则。
server { listen 80; server_name yourdomain.com; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; } }try_files 解决 History 路由刷新 404;/api 反代把前端请求转给后端。proxy_pass 末尾不要追加路径,否则 /api/book/list 会被替换成后端不认识的地址。
5.4 菜单与 Tabs 联动的一个具体做法
把左侧菜单和顶部页签联动,是后台管理里提升效率最明显的小技巧。做法是在 Header.vue 中监听路由变化,自动生成页签。
const tabs = ref([{ title: '首页', path: '/' }]) watch(() => route.path, (newPath) => { const meta = route.meta if (meta.title && !tabs.value.find(t => t.path === newPath)) { tabs.value.push({ title: meta.title, path: newPath }) } })路由 meta 里预先声明 title,watch 到路径变化时判断是否已存在同名页签,重复路由不重复添加。关闭页签时处理 @tab-remove 事件,找到当前索引并跳转到相邻路由,再移除对应项。这样实现下来,Content.vue 里的页签展示逻辑只需要读 tabs 数组渲染,路由与菜单的同步由 watch 自动完成。
本文还有配套的精品资源,点击获取