去年我接手一个内部图书管理系统的重构任务,网上翻了一圈全栈教程,铺天盖地全是 Java 和 Node 的内容,.NET 的声音少得可怜。可实际在企业里,尤其是 Windows 服务器生态占主导、技术团队又不大的一线环境,.NET 才是最有性价比的选择。这套基于 .NET 10 + Vue3 + Element Plus + MySQL 的全栈图书管理系统,就是我在这个背景下重新梳理出来的完整方案。它能覆盖什么范围?图书录入、检索、借阅、归还、用户管理、权限控制,最常见的图书馆和内部资料室场景都能落进去;适合谁参考?刚学完 C# 和前端基础、想做一个能写进简历的完整全栈项目的人,或者在团队里需要快速交付一个管理系统、不想被繁琐框架折腾的人。这篇文章我会按一条真实项目的推进顺序,从技术选型、数据库设计、后端 API、前端页面到最终部署,把每一步的关键细节和踩坑记录展开讲清楚。
1. 技术选型:为什么是 .NET 10 而不是 Spring Boot 或 Node
先亮结论:这套组合的核心优势是后端强类型、编译期检查多、开发效率高,前端借着 Vue3 的生态能快速搭出中后台管理界面,MySQL 又是普及度最高的数据库。三者组合在一起,正好避开我遇到过的几个痛点:教程碎片化、前后端联调无法无天、数据库字段改一个连带着改三天代码。
1.1 我对比过的几套全栈方案,为何最终定下这一套
在决定用 .NET 之前,我实际对比过三套全栈组合,分别拿图书管理这类中后台业务跑过 demo。结果如下表:
| 对比维度 | .NET 10 + Vue3 | Spring Boot + Vue3 | Node.js + Vue3 |
|---|---|---|---|
| 后端语言 | C# | Java | JavaScript/TypeScript |
| 学习曲线 | 中等 | 偏高 | 较低 |
| 类型安全 | 强类型,编译期检查 | 强类型 | 弱类型(选 TS 才可选) |
| 国内资料 | 官方文档完善 | 最多 | 前端生态丰富 |
| 部署环境 | Windows/Linux 都轻松 | 依赖 JVM 调优 | Docker 化容易 |
| 数据库驱动 | Pomelo EF Core 成熟 | MyBatis/JPA 成熟 | Sequelize/Prisma 略折腾 |
Spring Boot 不是不好,而是对一个图书管理系统来说,Java 环境的配置成本、Maven 依赖的解析速度、JVM 参数的调优,对企业内部技术团队其实是负担。Node.js 的上手速度确实快,但业务代码一旦超过十几个接口,弱类型带来的字段名拼写错误、参数类型不确定这些小坑会消耗大量联调时间。C# 这边,Visual Studio 社区版免费就能开发,dotnet 命令一行就能建项目,EF Core 操作 MySQL 的链路也非常顺,整体体验最接近“把注意力放在业务上”。
1.2 .NET 10 对业务开发最直观的几个变化
很多教程一提到 .NET 就只讲框架本身,其实对业务开发影响最大的是语言和生态的迭代。.NET 10 这个版本,有几个地方在实际写代码时会明显感受到:
- C# 13 的集合表达式,类似
List<int> nums = [1, 2, 3],比原来new List<int> { 1, 2, 3 }简洁不少,写初始化逻辑时很顺手。 - Web API 模板默认集成了 OpenAPI 文档,
dotnet new webapi出来就能看到接口文档页面,省去了单独配 Swagger 的步骤。 - 运行时和 GC 做了持续优化,对图书管理系统这种中小并发场景绰绰有余。
- EF Core 在查询翻译上有不少改进,复杂
where和连表在 MySQL 上生成的 SQL 更精准。
需要提醒一句:.NET 版本有 LTS 与 STS 的策略区分,如果所在团队对版本生命周期有严格要求,正式立项前务必确认好选型版本。个人项目和内部系统用 .NET 10 完全没问题,但如果是交付给外部客户,选择上一个 LTS 版本也许更稳妥。
1.3 Vue3 + Element Plus 的组合为什么比 Vue2 更划算
前端部分我直接选择了 Vue3,原因不复杂。Vue2 的 Options API 在小页面里很直观,但一旦图书管理这种页面多了,data、methods、watch分散在文件各处,做逻辑复用只能靠 mixin,项目一大就容易“找不到代码在哪”。Vue3 的组合式 API 把同一业务的代码聚在一起,用onMounted、ref、watch这些 API 组织页面逻辑,配合script setup写法,写起来清爽得多。
Element Plus 是 Element UI 面向 Vue3 的重写版本,表格、表单、弹窗、分页、消息提示这些中后台组件都齐,表单校验和表格列渲染的扩展性也足够。再配合官方提供的暗黑模式支持,做个主题切换功能都花不了十分钟,这一点后面部署部分我会演示。
至于 MySQL,选择 8.0 以上的稳定版本即可,社区资料最多、问题最容易检索到。字符集统一utf8mb4,中文和特殊符号都能正确处理,这是必选项。
2. 数据库设计骨架:图书、借阅与用户的三表联动
数据库是这一类系统最容易想当然的环节。很多人上来就建一堆表,然后发现业务根本用不上;或者反过来,只建两张表,结果借还书记录没地方存。我的建议是先别写任何代码,把完整的业务流走一遍,再回头建模。
2.1 先想清楚借阅流程再设计表
图书管理系统的核心业务可以拆成一条闭环:
- 用户注册并登录。
- 管理员录入图书信息。
- 用户按书名、分类或作者检索图书。
- 用户发起借阅,系统检查可借库存并扣减。
- 用户在应还日期前归还,系统加回库存。
这条闭环里的关键角色只有三个:用户、图书、借阅记录。所以核心表结构就是三张,不必额外建一堆分类表、出版社表。分类和出版社直接作为图书表的字段即可,对中小系统更实用。“先闭环、后加表”是我踩过几次坑后总结出来的原则。
2.2 三张核心表的建表 SQL 与字段说明
以 MySQL 8.0 为例,完整建表脚本如下。
用户表:
CREATE TABLE `users` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', `username` VARCHAR(50) NOT NULL COMMENT '登录名', `password_hash` VARCHAR(100) NOT NULL COMMENT '密码哈希值', `real_name` VARCHAR(50) DEFAULT NULL COMMENT '真实姓名', `email` VARCHAR(100) DEFAULT NULL COMMENT '邮箱', `role` TINYINT NOT NULL DEFAULT 0 COMMENT '0-普通用户,1-管理员', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1-正常,0-禁用', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';图书表:
CREATE TABLE `books` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', `isbn` VARCHAR(20) DEFAULT NULL COMMENT '国际标准书号', `title` VARCHAR(200) NOT NULL COMMENT '书名', `author` VARCHAR(100) DEFAULT NULL COMMENT '作者', `publisher` VARCHAR(100) DEFAULT NULL COMMENT '出版社', `category` VARCHAR(50) DEFAULT NULL COMMENT '分类', `cover_url` VARCHAR(255) DEFAULT NULL COMMENT '封面图地址', `total_stock` INT NOT NULL DEFAULT 0 COMMENT '总库存', `available_stock` INT NOT NULL DEFAULT 0 COMMENT '可借库存', `description` TEXT COMMENT '图书简介', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), KEY `idx_title` (`title`), KEY `idx_category` (`category`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='图书表';借阅记录表:
CREATE TABLE `borrow_records` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID', `book_id` BIGINT UNSIGNED NOT NULL COMMENT '图书ID', `borrow_date` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '借出时间', `due_date` DATETIME NOT NULL COMMENT '应还时间', `return_date` DATETIME DEFAULT NULL COMMENT '实际归还时间', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0-借出中,1-已归还,2-逾期', PRIMARY KEY (`id`), KEY `idx_user` (`user_id`), KEY `idx_book` (`book_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='借阅记录表';几个字段设计上的关键决策:
- 主键用
BIGINT UNSIGNED,不要用INT。图书管理项目短时间内看起来几十条数据无所谓,但借阅记录会随时间持续增长,INT上限约 21 亿,索引和关联查询的性能余量也不如BIGINT。 - 密码字段存
password_hash,绝对不存明文。哪怕内部系统也不该犯这种错误。 role用TINYINT而不是字符串,判断逻辑简单,扩展新角色时加一个枚举值即可。books表里total_stock和available_stock分开存。归还图书时只改available_stock,总数保留用于统计损耗和借出率,不用临时计算。borrow_records.status初始为 0,归还时改为 1。逾期状态可以不做定时任务,查询时根据due_date < NOW()和return_date IS NULL动态判断,在接口层算出来,省去后台任务。
2.3 索引、外键与 MySQL 8.0 环境要点
索引策略上遵循一个原则:给查询的 where 条件和排序字段建索引,不要给每个字段都加索引。books表的title和category是检索高频字段,加普通索引;borrow_records的user_id、book_id、status分别加索引,支撑“某用户当前借了哪些书”“某本书现在是否可借”这类查询。
外键我建议只在特殊场景下用。图书借阅集中在高并发写入,物理外键会影响每次插入的校验成本;而且如果后期分表分库,物理外键反而成负担。项目里直接用逻辑外键,在程序层保证user_id和book_id的有效性,代码里多做一步存在性查询。
再说一个很多人卡住的环境问题。MySQL 在 Windows 上安装时,如果 MySQL Installer 界面里没有“Developer Default”选项,不必纠结,直接选Server only,装完再单独装一个 MySQL Workbench 图形客户端就够了。安装路径不要带中文和空格,否则启动服务时很容易报路径错误。习惯用 Docker 的可以直接跑docker run -p 3306:3306 --name mysql8 -e MYSQL_ROOT_PASSWORD=你的密码 -d mysql:8.0,注意镜像标签不要用latest,指定mysql:8.0更可控,必要时把数据目录挂载到宿主机,避免容器重建后数据丢失。
3. 后端 API:.NET 10 项目中容易被忽略的落地细节
数据库设计完成,后端是第一个动手写的部分。很多新手喜欢上来就写 Controller,我认为正确顺序是先搭骨架,再填充业务。骨架对了,后面的每个接口都只是机械操作。
3.1 项目结构与依赖注入,一次搭好不分心
我用的解决方案是四个项目:BookManage.Api(Web API 入口)、BookManage.Application(业务服务层)、BookManage.Domain(实体与接口)、BookManage.Infrastructure(EF Core 与仓储实现)。图书管理系统这个规模,严格来说两层也够,但四层的好处是后续加缓存、加消息队列时有明确的位置放,不会把 Api 项目堆成一团浆糊。
dotnet new sln -n BookManage dotnet new webapi -n BookManage.Api dotnet new classlib -n BookManage.Domain dotnet new classlib -n BookManage.Application dotnet new classlib -n BookManage.Infrastructure dotnet sln add BookManage.Api BookManage.Application BookManage.Domain BookManage.Infrastructure依赖注入的核心代码在Program.cs:
var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddDbContext<BookDbContext>(options => options.UseMySql(builder.Configuration.GetConnectionString("Default"), new MySqlServerVersion(new Version(8, 0, 36)))); builder.Services.AddScoped<IBookService, BookService>(); builder.Services.AddScoped<IBorrowService, BorrowService>(); builder.Services.AddScoped<IUserService, UserService>(); var app = builder.Build(); app.MapControllers(); app.Run();构造函数的依赖注入是 ASP.NET Core 的默认行为,控制器里声明接口参数,框架会在请求时自动实例化服务,不需要手动 new。这一点在 .NET 里非常省心,写完服务注册后,Controller 里直接用即可。
3.2 EF Core 连接 MySQL 的配置与迁移过程
EF Core 操作 MySQL 需要安装Pomelo.EntityFrameworkCore.MySql包,这是目前社区使用最广泛的驱动库,对 MySQL 的方言支持完整。连接字符串配置在appsettings.json:
{ "ConnectionStrings": { "Default": "Server=localhost;Port=3306;Database=book_manage;User=root;Password=你的密码;" } }这里有个很容易被忽略的点:Database=book_manage这个库要先手动创建,EF Core 的迁移不会自动建库,只会在库已存在的前提下建表。如果数据库不存在,第一次database update会直接报错。
迁移工具链如下:
dotnet tool install --global dotnet-ef dotnet add BookManage.Infrastructure package Pomelo.EntityFrameworkCore.MySql dotnet add BookManage.Infrastructure package Microsoft.EntityFrameworkCore.Design dotnet ef migrations add Init -p BookManage.Infrastructure dotnet ef database update -p BookManage.Infrastructure用迁移而不是直接同步数据库,核心价值在于保留变更历史。后面改了表结构,migrations add会生成增量脚本,代码评审能看见数据库的变化,回滚也有据可依。
3.3 统一响应模型 Result 与全局异常处理
之前做一个前后端项目时,后端接口有的返回{ code, message, data },有的直接返回数据本身,前端每个页面都要单独判断,联调效率极低。从那以后我所有项目统一用响应模型。
public class ApiResult<T> { public bool Success { get; set; } public string Message { get; set; } = string.Empty; public T? Data { get; set; } public static ApiResult<T> Ok(T data, string message = "操作成功") { return new ApiResult<T> { Success = true, Message = message, Data = data }; } public static ApiResult<T> Fail(string message) { return new ApiResult<T> { Success = false, Message = message }; } }控制器里不需要关心统一包装,服务层直接返回业务实体,控制器统一包一层:
[HttpGet("{id}")] public async Task<IActionResult> GetBook(long id) { var book = await _bookService.GetByIdAsync(id); if (book == null) return NotFound(ApiResult<BookDto>.Fail("图书不存在")); return Ok(ApiResult<BookDto>.Ok(book)); }配合全局异常处理中间件,遇到的未捕获异常统一返回500 + 错误信息,不用在每个方法里写 try-catch。中间件的核心逻辑是捕获Exception,记录日志后返回统一结构,前端 axios 拦截器只需要写一次错误处理。
3.4 JWT 认证,前后端分离下最稳妥的身份方案
图书管理系统必然涉及权限区分:普通用户能借书还书,管理员还能管理图书和维护用户。前后端分离架构下,JWT 是最稳妥的方案,服务端不保存会话状态,登录成功返回一个带签名的 Token,前端后续请求在请求头带Authorization: Bearer <token>即可。
Program.cs里配置认证:
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = jwtConfig.Issuer, ValidateAudience = true, ValidAudience = jwtConfig.Audience, ValidateLifetime = true, ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey( Encoding.UTF8.GetBytes(jwtConfig.Key)) }; });生成 Token 时,把用户 ID 和角色放进声明(Claim)里:
var claims = new List<Claim> { new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), new Claim(ClaimTypes.Role, user.Role == 1 ? "Admin" : "User") }; var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var token = new JwtSecurityToken( issuer: jwtConfig.Issuer, audience: jwtConfig.Audience, claims: claims, expires: DateTime.UtcNow.AddHours(2), signingCredentials: credentials); return new JwtSecurityTokenHandler().WriteToken(token);需要管理员权限的接口直接加特性标注:
[HttpPost] [Authorize(Roles = "Admin")] public async Task<IActionResult> CreateBook(BookCreateDto dto)这里有一个比较常见的坑:JWT 的密钥太短会导致运行时抛加密算法异常。建议用不少于 32 个字符的随机字符串,放在环境变量或配置文件里。其次,前端退出登录的直接做法是清掉本地 Token,因为 JWT 服务端天然无状态,不需要调用登出接口。
4. 前端工程:Vue3 组合式 API 与 Element Plus 的配合方式
前端的核心任务是把后端接口可靠地展示出来,并且给用户一个顺手的操作界面。Element Plus 的组件能省掉大量 UI 工作量,真正要花心思的是接口封装和状态管理。
4.1 用 Vite 初始化项目,换掉过时的 Vue CLI
Vue CLI 虽然还能用,但 Vite 已经是 Vue 生态默认构建工具,启动速度和热更新体验完全不是一个量级。创建项目的命令:
npm create vite@latest book-manage-web -- --template vue-ts cd book-manage-web npm install npm install element-plus @element-plus/icons-vue npm install pinia vue-router axios--template vue-ts会生成带 TypeScript 的模板,对中后台项目非常合适,接口返回的数据结构可以用类型约束,避免字段名拼错才发现问题。
工程目录我习惯这样组织:
src/ ├── api/ # 接口层,按模块拆分 ├── components/ # 通用组件 ├── layouts/ # 布局组件 ├── router/ # 路由配置 ├── stores/ # Pinia 状态 ├── views/ # 页面组件 └── utils/ # 工具函数api层和页面逻辑分离是我比较坚持的一点。所有后端接口调用集中在src/api目录,页面只关心数据展示,后面接口路径变了只改一个文件。
4.2 组合式 API 的图书列表页:从列表到表单的一次串通
图书管理页是这套系统里最有代表性的页面,包含筛选区、数据表格、分页和新增编辑弹窗。用组合式 API 组织逻辑,代码集中而且扩展方便:
<script setup lang="ts"> import { ref, onMounted, reactive } from 'vue' import { getBookList, createBook, updateBook, deleteBook } from '@/api/book' import { ElMessage, ElMessageBox } from 'element-plus' const loading = ref(false) const list = ref<BookItem[]>([]) const total = ref(0) const query = reactive({ page: 1, pageSize: 10, keyword: '' }) const loadData = async () => { loading.value = true try { const res = await getBookList(query) list.value = res.data.records total.value = res.data.total } finally { loading.value = false } } const handleSearch = () => { query.page = 1 loadData() } const handleDelete = async (id: number) => { await ElMessageBox.confirm('确定删除这本书吗?', '提示', { type: 'warning' }) await deleteBook(id) ElMessage.success('删除成功') loadData() } onMounted(loadData) </script>模板部分用el-table展示数据,el-dialog承载新增编辑表单,el-pagination做分页。这里有一个值得注意的细节:el-table的data绑定的是当前页的记录列表,total是后端返回的总条数,分页组件用total来控制总页数,千万不要把当前页的list.length当成总数传给分页,否则切到第二页时会发现分页条数少一截。
表单校验方面,el-form的model和rules字段名必须一一对应,如果不一致,校验规则不会触发。这是 Element Plus 使用中最常见的隐形问题。
4.3 一套开箱即用的 axios 封装、状态管理与路由守卫
axios 封装的核心目的是统一处理 Token 注入、响应异常和 401 跳转。下面是实际项目里直接可用的request.ts:
import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const service = axios.create({ baseURL: '/api', timeout: 15000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) service.interceptors.response.use( response => { const res = response.data if (!res.success) { ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } return res }, error => { if (error.response?.status === 401) { localStorage.removeItem('token') router.push('/login') } ElMessage.error(error.response?.data?.message || '请求失败') return Promise.reject(error) } ) export default service路由守卫用来做登录控制和权限判定:
router.beforeEach((to, from) => { const token = localStorage.getItem('token') if (!token && to.path !== '/login') { return '/login' } if (token && to.path === '/login') { return '/' } return true })管理员权限用路由的meta字段标记,比如meta: { requiresAdmin: true },在守卫里判断用户角色后放行。这套逻辑对图书管理系统的权限模型足够了。
5. 联调与部署:跨域、认证、发布一步都不能省
前后端都写完,真正的考验才开始。联调阶段的跨域问题、生产部署的路径问题、上线前的体验细节,每一项都能让项目从“写完”变成“可用”。
5.1 开发环境跨域:Vite 代理与 CORS 配合使用
开发时前端跑在http://localhost:5173,后端跑在http://localhost:7000,浏览器同源策略会拦截请求。两种处理方式我建议同时配置。
后端开启 CORS:
builder.Services.AddCors(options => { options.AddPolicy("AllowVite", policy => policy.WithOrigins("http://localhost:5173") .AllowAnyHeader() .AllowAnyMethod()); }); app.UseCors("AllowVite");前端用 Vite 代理把/api转发到后端地址,vite.config.ts:
export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:7000', changeOrigin: true } } } })这样前端请求/api/books/list时代理会转发到http://localhost:7000/api/books/list,开发环境下的请求路径和后端路由完全一致,不需要在代码里维护一长串绝对地址。
需要注意一点:默认代理转发的changeOrigin: true会把请求头里的Host改成目标地址。后端什么都没有配置的情况下也能通,但如果后端加了基于 Host 的重定向逻辑,仅靠代理可能不够,所以 CORS 也要开。
5.2 生产部署:Kestrel 与 Nginx 的分工
生产环境我采用最常见的方案:后端用 Kestrel 直接监听内部端口,前端静态文件交给 Nginx 托管,Nginx 再把/api请求反向代理到后端。
后端发布:
dotnet publish -c Release -o ./publish在服务器上运行发布后的 dll 即可:
dotnet BookManage.Api.dll --urls http://localhost:5000前端构建:
npm run build构建产物在dist目录,把它复制到服务器如/var/www/book-web。Nginx 配置如下:
server { listen 80; server_name book.example.com; location / { root /var/www/book-web; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这里的try_files $uri $uri/ /index.html是必须的。Vue 路由用的createWebHistory模式,前端路由/books/1在刷新时如果 Nginx 找不到对应文件,会直接 404,加上try_files后所有路径都会回退到index.html,由前端路由接管。如果不想处理这个细节,也可以在路由中改用createWebHashHistory,但 URL 会带#,观感稍差。
5.3 上线前值得做的三个细节:主题切换、动态标题、批量操作
基础功能跑通后,有几个小功能能显著提升完成度。实测下来,这三个性价比最高。
第一是暗黑模式主题切换。Element Plus 官方已经内置了暗黑模式的 CSS 变量,只需要在html根节点上加darkclass:
import 'element-plus/theme-chalk/dark/css-vars.css' const isDark = ref(false) watch(isDark, (val) => { document.documentElement.classList.toggle('dark', val) localStorage.setItem('theme', val ? 'dark' : 'light') })进入页面时从localStorage读取主题偏好,页面刷新后也能保持。登录页和内容页都配上,项目观感会立刻提一个档次。
第二是动态多标签页导航。管理系统经常需要在多个页面来回操作,单页签体验很割裂。实现思路不复杂:在 Pinia 里维护一个tabs数组,路由切换时把路径和标题推入,关闭标签时从数组移除并跳转到最后一个页面。这个功能在全栈项目展示时非常加分,对应热搜里“vue3 修改 tabs 标签页样式”的需求,本质就是维护一个可渲染数组。
第三是图书批量导入导出。如果后台上手工一条条录入图书信息,几十本就够烦躁。可以借助exceljs在前端直接生成 Excel 文件,也可以后端用 NPOI 或 MiniExcel 生成结构化文件。后端导出的优点是可以带上所有字段,前端导出的优点是减少了网络传输。我的选择是批量导入走后端解析 CSV 或 Excel,批量导出走前端exceljs生成,两者分工明确。
最后再分享一点个人体会
把整套项目从无到有跑通之后,我最大的感触是:全栈项目真正的难点不在任何一个单一技术点上,而在三层之间的衔接。数据库字段名改一下,后端所有查询就得跟着变;后端返回结构不统一,前端每个页面都要单独适配;接口路径没有提前约定,联调阶段就会被一次又一次“我这边没问题啊”卡住。所以如果你准备自己动手做这套项目,我强烈建议把顺序反过来:先把数据库建模讨论清楚,再定义ApiResult<T>这类公共规范,最后才开始写业务页面。顺序对了,剩下的工作都是体力活。这套项目做完以后,你对全栈的理解会比单看教程深得多,至少我是这么成长过来的。