
前阵子朋友盘下一家自习室晚上十二点还在群里跟兼职管理员核对当天预约记录Excel表格里红色绿色标了一堆。我看了眼后台既没有自动计时也没有实时座位状态退座还得靠管理员去现场看。索性用Java后端加Vue3给他做了一套付费自习室预约管理系统。现在这套系统已经稳定跑了小半年高峰期每天处理上千条预约座位实时状态、自动计费、支付回调全部自动完成。这篇就把完整的实现思路、表结构设计、并发控制、前端状态管理以及部署踩坑都写出来给准备做类似预约类系统的同学参考。如果你正在用Spring Boot做后端、Vue3做管理端或者刚接触付费预约类项目这篇文章可以直接当作一套可落地的方案。座位状态机怎么设计计费规则怎么做到可配置预约并发怎么防超卖Vue3的响应式数据在真实项目里有哪些坑都会讲到。1. 先想清楚自习室预约到底要管哪几件事1.1 从手工登记表推导出的核心需求那种拿A4纸打印的座位表本质上就是一个状态看板。每个格子代表一个座位有人坐就画圈暂时离开放支笔预约了还没到就贴便签。把这张纸搬到系统里需求就很清晰了用户能看见所有座位的当前状态哪些被预约、哪些空着、哪些是暂离。用户选一个时间段发起预约系统必须保证同一时间内同一个座位只被一个人占用。预约成功后开始计时用户签到、暂离、退座都需要有时间记录。费用根据预约时长、时段、套餐自动计算支付完成后座位才正式锁定。管理员能设置座位数量、座位类型、价格规则查看每个座位的当日使用流水。这些听着简单但一旦并发上来就复杂了。比如一个热门靠窗座位十个人同时抢同一天下午两点到六点的时段数据库层面如果没有约束超卖就是必然的。所以这套系统的核心不是CRUD而是座位状态流转和并发控制。1.2 用户端、商家端、管理端三端模型付费自习室通常有三类使用者普通用户、商家运营者、超级管理员。我在设计时没有做成一个巨大的后台而是按角色拆成三个入口端使用者核心页面技术形态用户端自习的人座位地图、预约选座、支付、订单列表、个人中心Vue3 H5/公众号网页商家端自习室店长、前台座位管理、价格规则、预约审核、收入统计、用户管理Vue3 PC后台管理端平台运营多门店管理、基础数据配置、异常订单处理Vue3 PC后台这里要特别说一下很多初学者会把三端做成三个完全独立的前端工程维护成本很高。我这套系统里商家端和管理端共用了一个Vue3项目只通过路由表权限和登录接口返回的role来判断能访问哪些页面用户端单独一个H5项目。共用工程的好处是组件可以复用比如座位表格组件、订单列表组件两边的交互逻辑基本一致只是按钮和字段不同。1.3 我把付费与计时规则拆成了可配置项自习室的计费方式五花八门按小时计费、按天卡计费、工作日和周末价格不同、晚间时段有优惠、充值会员打折、迟到超过十五分钟要额外收费。这些规则如果直接写死在Java代码里每次调价都要重新发版显然不行。我的做法是建一张price_rule规则表核心字段包括rule_type计费类型1按小时、2按次卡、3按时段套餐time_slot适用时间段例如09:00-22:00week_day适用星期例如1,2,3,4,5代表周一至周五base_price基础价格按小时内为例就是每小时多少钱min_unit最小计费单位比如30分钟max_duration单次预约最大时长discount_rate会员折扣率每个座位可以关联多个规则下单时根据当前时间和预约时长匹配最合适的那条规则计算预付款。这样商家在后台改价前端用户立刻就能看到新价格全程不需要改代码。后面讲到计费引擎时再展开。2. 技术选型为什么是Java Vue3而不是其他2.1 后端选型Spring Boot 3 MyBatis-Plus MySQL Redis后端我选了Java生态具体版本是Spring Boot 3.2、JDK 17、MyBatis-Plus 3.5、MySQL 8.0、Redis 6.x。理由很直接Spring Boot的事务管理非常成熟预约、支付、结算这类涉及多张表更新的场景一个Transactional就能保证原子性省去很多手工补偿逻辑。MyBatis-Plus提供了强大的CRUD封装像分页查询、逻辑删除、乐观锁插件都是现成的适合这种业务相对集中的管理系统。Redis在这里不是简单的缓存而是承担了分布式锁和座位状态预扣两个关键任务。后面讲并发控制时你会看到它的价值。Java生态招人容易即使这个项目后来交给别人维护也不会因为技术栈太偏而找不到人。如果你用的是JDK 8直接降级到Spring Boot 2.x也能跑通但JDK 17的switch模式匹配、文本块这些特性确实写起来舒服很多建议有条件就用新版本。2.2 前端选型Vue3 Vite Pinia Element Plus前端我没有用jQuery时代的模板渲染方案直接上了Vue3全家桶。具体是Vue 3.4、Vite 5、Pinia 2、TypeScript 5、Element Plus 2.6。Vue3最吸引我的其实不是性能提升而是组合式API对复杂业务逻辑的整理能力。传统Options API把一个功能的代码分散在data、methods、watch里写一个预约弹窗可能要在文件里上下翻好几次。组合式API可以把同一个业务域的响应式数据、方法、生命周期像拼乐高一样组合在一起代码可读性提升非常明显。这个后面第五章会细说。Vite作为构建工具比Webpack快太多了尤其在大型后台项目里热更新几乎是秒开。TypeScript在这个项目里主要用来约束后端返回的接口类型比如座位状态、订单状态这些枚举值写错了一个TS直接报红比运行时才发现错误要舒服得多。2.3 为什么没直接用若依这类脚手架以及什么时候该选热搜里很多人在搜“若依vue3”说明这个脚手架确实火。我也用它做过几个内部管理项目但付费自习室预约系统我选择从零搭建。若依这类脚手架最大的优势是内置了用户管理、角色权限、操作日志、代码生成器开发后台管理系统确实能省两三天时间。但它的代价是前端塞了大量通用组件和约定项目启动后你很难知道哪些代码能删、哪些代码之间有隐式依赖。尤其当业务涉及座位状态机、支付回调、WebSocket实时推送这些非标准CRUD场景时脚手架的通用抽象反而变成阻力。我的建议是如果项目就是传统的增删改查后台比如内容管理、配置管理直接用若依没问题。如果业务有复杂状态流转、强实时交互、特殊并发要求比如我这个预约系统建议基于官方脚手架搭个干净模板再把通用能力登录、权限、路由逐步加进去反而更可控。当然这也不是绝对如果你的团队已经很熟悉若依的内核在里面魔改也完全可以只是要充分评估学习成本和维护成本。3. 数据库设计与座位状态机的核心逻辑3.1 五张核心表别把计费规则写死在代码里这可能是全篇最重要的部分。预约系统表结构如果一开始没设计好后面改起来真的是牵一发而动全身。我最终沉淀下来的核心表有五张seat、user、reservation_order、price_rule、seat_status_log。seat表记录座位基础信息id、store_id、seat_no、area_type座位编号和区域比如沉浸区、讨论区seat_type单人位、双人位、包间is_enabled是否启用管理员可以在后台临时关闭某个座位version乐观锁版本号后面更新座位状态时会用到reservation_order表是核心中的核心order_no业务订单号不是自增ID而是用日期加随机数生成的唯一号user_id、seat_idbook_date、start_time、end_time预约的日期和时间段status订单状态10待支付、20已预约、21已签到、22已退座、30已完成、40已取消total_amount、pay_amount、pay_status金额和支付状态version同样是乐观锁字段处理用户端和管理端同时操作一条订单price_rule表前面已经提到注意不要只存价格还要存适用范围比如星期几、哪个时段、哪个座位类型。seat_status_log表是流水表业务上叫“座位行为轨迹”。用户签到、暂离、回座、退座每一次状态变化都插入一条记录包含seat_id、status_before、status_after、operate_time、operator_id。这张表主要用于对账和纠纷处理。举个例子用户说“我下午两点就来了只是忘记签到”管理员可以通过日志看到这个座位在两点到四点的状态到底是空闲还是占用有据可查。3.2 座位状态的流转空闲/锁定/占用/离座座位状态别用布尔字段一个is_booked完全不够用。真实场景里一个座位从空到有人坐要经历好几个中间态。我最终定义了四种基础状态IDLE空闲谁都可以预约。LOCKED锁定已生成订单但用户还没支付系统临时占用防止他人抢座。BOOKED已预约支付完成等待用户签到。OCCUPIED占用中用户已签到正在学习。AWAY暂离用户暂时离开座位保留一段时间。状态流转规则我用一张表固定了下来当前状态触发动作下一状态校验要求IDLE生成订单LOCKED当前时段无冲突LOCKED支付成功BOOKED与支付回调匹配LOCKED超时未支付IDLE超时自动释放BOOKED用户签到OCCUPIED在预约开始时间前后30分钟内OCCUPIED用户点击暂离AWAY无AWAY暂离超时IDLE超时自动释放并通知用户AWAY用户返回OCCUPIED无OCCUPIED用户退座IDLE计算费用并结算这张状态机表我在设计时贴在了显示器旁边写每个状态转换的接口前都要对照一遍确认不会出现A状态跳成不存在的D状态的情况。3.3 预约冲突怎么避免唯一索引与Redis分布式锁结合现在说最核心的问题防止两个用户同时预约同一个座位的同一个时间段。最简单粗暴的思路是Java代码里用synchronized锁住预约方法像这样public synchronized Order createOrder(CreateOrderRequest request) { // 校验座位是否空闲 // 生成订单 }但这个方案只对单实例部署有效一旦后端部署了多台机器A机器和B机器各有一个synchronized锁两个用户分别打到两台机器照样同时通过校验。所以分布式环境下必须用分布式锁或数据库约束。我的方案是Redis分布式锁加数据库唯一索引双重保险。先在Redis里争抢锁拿到锁之后再查数据库、插入订单最后释放锁。String lockKey seat:lock: seatId : bookDate : startTime; boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, Duration.ofSeconds(5)); if (!locked) { throw new BusinessException(该座位当前被其他人抢占了请刷新重试); } try { // 数据库校验并插入预占订单 } finally { redisTemplate.delete(lockKey); }Redis锁解决的是接口层面的并发竞争数据库唯一索引解决的是极端情况下穿透到数据库的并发插入。我给reservation_order表加了一个唯一索引ALTER TABLE reservation_order ADD UNIQUE INDEX uk_seat_time (seat_id, book_date, start_time, status);注意这里没有把end_time放进唯一索引因为只要一个座位在某一天的同一start_time被占用了后面同一时段的预约就无法插入这也就基本避免了重叠。但更严谨的做法还需要判断插入的end_time不能和已有订单的start_time交叉这部分我在业务层再校验一次防止出现预约10点到12点和已有11点到13点订单重叠的情况。4. 核心接口的并发控制与计时计费实现4.1 预约接口数据库层面的防并发很多人以为加了Redis锁就万事大吉其实还有几个细节要处理。比如锁的粒度seat:lock:这个key里如果只放seatId而不放日期和时段那用户预约明天上午和今天下午就会互相阻塞拉低整体并发。所以锁粒度要精确到“某个座位在某个日期的某个开始时间”。锁的过期时间也要结合业务设置。创建一个订单通常只需要几百毫秒如果业务操作时间太长5秒锁到期后其他请求就能进来前一个请求还没提交事务这就不安全。我的做法是锁时间设短然后结合数据库唯一索引作为兜底。一旦唯一索引报DuplicateKeyException就说明有人抢到了直接返回友好提示。还有一个细节是事务范围。锁的获取必须在事务方法之外也就是说先拿锁再开启事务执行校验和插入最后提交事务并释放锁。如果锁释放放在事务提交之前其他线程可能在当前事务还没提交时就进入了校验逻辑看到的数据还是旧的导致误判。4.2 计费规则引擎按时段、按套餐、按分钟阶梯计费这块我不建议在Service里堆if else。我用策略模式设计了计费引擎接口就一个方法public interface PriceCalculator { BigDecimal calculate(PriceRule rule, LocalDateTime start, LocalDateTime end); boolean supports(PriceRule rule); }每种计费方式一个实现类HourlyPriceCalculator按小时计费不足最小计费单位按最小单位计算。PackagePriceCalculator按时段套餐比如“三小时套餐”、“夜间五小时套餐”超时部分单独按小时补差价。MemberDiscountPriceCalculator会员折扣内部先调用其他计算器再套折扣率。计算时先根据预约的日期和星期查出一批适用的规则再用责任链方式逐条判断适用条件选最优惠的一条给用户展示。预约界面显示的“预估价格”和最终支付金额可能有差异因为预估时没有考虑迟到超时费这个差异要在支付前弹窗二次确认。有个小坑必须要提醒Java里金额计算绝对不能用double一定用BigDecimal。自习室虽然单价不高但订单量大哪怕一个座位差一毛钱月底对账都是灾难。4.3 支付回调与座位解锁的幂等处理接支付回调比如微信支付、支付宝时最容易踩的坑就是重复通知。支付平台为了保证送达会以一定的频率重复回调如果回调处理逻辑没有幂等用户支付成功后可能座位被重复锁定或者订单金额被重复入账。我的做法是在订单表里增加pay_status字段回调处理流程是这样的校验签名确认请求来自支付平台。根据order_no查出订单先判断pay_status。如果已经是PAID直接返回成功不再重复处理。如果仍是UNPAID使用CAS更新支付状态也就是UPDATE ... SET pay_statusPAID WHERE order_no? AND pay_statusUNPAID更新行数为0就说明有其他回调已经处理过了直接返回成功。更新订单状态为BOOKED座位状态从LOCKED改为BOOKED同时写入seat_status_log。这里的关键是步骤4里的AND pay_statusUNPAID条件它在数据库层面保证了幂等。哪怕两个回调同时到达也只有一条SQL能更新成功。4.4 用CompletableFuture处理签到后的异步任务用户扫码签到时系统要做的事不止是改一个状态要通知前台、要给用户推送服务提醒、可能要检查用户是否有未完成的订单让他确认。这些操作如果串行执行签到接口的响应时间会明显变长用户扫码后看到转圈体验很差。我用CompletableFuture把不需要立即返回结果的任务丢到异步线程池执行CompletableFutureVoid future CompletableFuture.runAsync(() - { wsService.notifyAdmin(seatId, userId); }, taskExecutor); future.thenRunAsync(() - { pushService.sendCheckInMessage(userId); }, taskExecutor);这里要注意如果多个异步任务之间有依赖关系可以用CompletableFuture.allOf(f1, f2).get()等待所有任务完成。比如订单结算时需要同时计算用户本单费用、更新座位状态、刷新当日流水三个任务都完成后再给前端返回“退座成功”这时候用allOf是合适的。但签到场景不需要等直接异步发出去。不是所有地方都要异步异步的前提是不影响主流程一致性判断否则就给自己挖坑。5. Vue3前端落地从登录到预约页的状态管理5.1 组合式API的合理拆分别把页面变成巨型组件刚开始用Vue3的时候我也犯过一个毛病喜欢把整个预约页面所有逻辑塞进一个script setup里结果一个文件两千行比Vue2时代还难读。后来我学会了把业务逻辑拆成可组合函数useXxx。以预约选座为例我拆了三个组合函数useSeatMap负责座位图的加载、渲染状态枚举、座位筛选逻辑暴露seatList、selectedSeat、loadSeats。useOrder负责预约表单、价格计算、下单、支付轮询暴露createOrder、payOrder。useAuth负责登录状态、用户信息、Token维护。页面组件里只做组装script setup import { useSeatMap } from /composables/useSeatMap import { useOrder } from /composables/useOrder import { useAuth } from /composables/useAuth const { seatList, selectedSeat, loadSeats } useSeatMap() const { createOrder } useOrder() const { userInfo } useAuth() /script这样页面template部分看下来就是纯粹的结构逻辑在哪都能快速定位。测试也变得容易直接对组合函数做单测不需要挂载组件。5.2 Pinia管理用户身份与座位筛选条件状态管理我选了Pinia而不是Vuex。Pinia对TypeScript的支持更自然store定义方式也更简洁而且没有mutations直接在actions里改状态写起来少一层样板代码。用户端有这样一个场景用户在座位地图页设置筛选条件比如只看“靠窗位”或“可充电”然后切到其他页面再切回来希望筛选条件还在。这个状态放到Pinia的seatFilterStore里就很合适因为它是跨页面共享的。但如果只是单个页面内部的临时筛选条件直接ref就行不要习惯性把所有数据都塞store。座位状态的实时更新我会通过WebSocket推送Pinia里的seatList由服务端推送覆盖。要注意的是WebSocket推送频率很高如果每次都做全量替换座位地图的DOM更新压力很大。我在前端做了一层差异更新只有状态变化或新增座位的节点才更新Vue3的响应式系统能自动处理这部分但前提是你不要用reactive给整个大数组重新赋值而是更新具体某一项。5.3 Axios二次封装Token刷新与请求重试管理端和用户端都离不开Axios但直接用原生Axios写请求会有几个问题每个请求都要带Token、401时得统一跳登录、接口报错要有统一的提示。我封装在request.js里逻辑如下请求拦截器从Pinia里取token加到Authorization请求头。响应拦截器判断HTTP状态码如果是401说明Token过期调用刷新Token接口拿新Token然后重放请求。为了避免多个请求同时返回401导致重复刷新Token我用一个isRefreshing标识加上待重试请求队列。第一个401触发刷新后续401进入队列等待刷新完成后统一重放。核心代码片段let isRefreshing false let pendingQueue [] service.interceptors.response.use( (response) { return response.data }, async (error) { const { config, response } error if (response.status 401 !config._retry) { if (isRefreshing) { return new Promise((resolve) { pendingQueue.push({ config, resolve }) }) } config._retry true isRefreshing true const newToken await refreshToken() error.config.headers.Authorization Bearer ${newToken} isRefreshing false pendingQueue.forEach(({ config, resolve }) { config.headers.Authorization Bearer ${newToken} resolve(service(config)) }) pendingQueue [] return service(error.config) } return Promise.reject(error) } )这个封装帮我少处理了大量重复的登录过期弹窗强烈建议每个Vue3项目都配一套。5.4 实时座位地图用WebSocket推状态而不是轮询座位状态如果靠前端每隔5秒轮询一次接口高峰期全校几百个用户盯着页面看后端接口压力太大。而且轮询的实时性始终有延迟用户明明已经退座了其他人还要等下一个5秒才能看到空位体验很差。所以选座页我用了WebSocket做服务端推送。后端在用户签到、退座、暂离时会向Redis订阅对应主题广播消息每个门店的WebSocket客户端只接收自己门店的消息。前端在.onmessage里判断消息类型更新Pinia中的seatList。连接管理上要注意两点断线重连必须做。我用了一个简单的重连策略断线后延迟2秒重连失败后延迟时间翻倍最多延迟30秒。连接建立后要及时发送心跳包。我每隔30秒发一次ping服务端收到后返回pong连着两次没收到pong就主动重连。6. 构建与部署Nginx部署Vue3与后端的连接问题6.1 Vite打包配置API代理、路由history模式、base路径Vue3项目默认是BrowserRouter的history路由模式URL里没有#看着清爽但Nginx必须配置try_files否则刷新页面就会404。还有本地开发时的API代理要和后端联调就必须把Vite的server.proxy配好。我本地的Vite配置大概长这样// vite.config.ts export default defineConfig({ base: ./, server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, }, /ws: { target: ws://localhost:8080, ws: true, changeOrigin: true, }, }, }, build: { outDir: dist, chunkSizeWarningLimit: 1500, }, })base: ./很有用如果你要把前端静态文件放在后端同域下的子目录或者部署到对象存储相对路径比绝对路径省心得多。6.2 Nginx前端配置与后端反向代理前端打包成dist目录后我直接配Nginx托管。Nginx配置里最关键的三个点静态资源缓存、history路由fallback、API反向代理。server { listen 80; server_name yourdomain.com; root /var/www/selfstudy-web/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /ws/ { proxy_pass http://127.0.0.1:8080/ws/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; } location ~* \.(js|css|png|jpg|jpeg|gif|svg|woff2?)$ { expires 7d; add_header Cache-Control public, immutable; } }这里有个很容易踩的坑proxy_pass后面的路径是否带/。如果写成proxy_pass http://127.0.0.1:8080/Nginx会把/api也传给后端可能导致后端路由变成/api/api/xxx。我通常会把后端的Context Path统一设置为/api然后Nginx里也保留/api前缀这样两边路径一致减少混淆。6.3 HTTPS与WebSocket的WSS代理现在小程序和公众号要求HTTPSWebSocket也必须升级成WSS。如果直接让后端暴露ws://浏览器会拦截。我的做法是在Nginx配置SSL证书并为WebSocket单独配置代理server { listen 443 ssl http2; server_name yourdomain.com; ssl_certificate /etc/nginx/cert/fullchain.pem; ssl_certificate_key /etc/nginx/cert/privkey.pem; location /ws/ { proxy_pass http://127.0.0.1:8080/ws/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_read_timeout 3600s; } }前端连接地址要从ws://改成wss://并且注意端口。如果前端页面和WebSocket是同域名直接写wss://yourdomain.com/ws就行不需要额外开端口。proxy_read_timeout要设大一点我设了3600秒防止连接空闲被Nginx断开。6.4 环境变量与多环境构建我用Vite的环境变量文件区分开发、测试、生产环境.env.developmentVITE_API_BASE/api本地代理.env.productionVITE_API_BASEhttps://yourdomain.com/api在代码里统一通过import.meta.env.VITE_API_BASE取后端地址不要把IP和端口硬编码在业务代码里。我见过同事把所有请求地址写死在request.js里后来换域名只能全局搜索替换非常痛苦。生产环境我一般用GitHub Actions或Jenkins做自动化构建推到服务器后执行一行脚本先npm run build再把dist目录同步到Nginx的root路径。因为环境变量已经区分构建完成后只需要重启Nginx即可后端采用java -jar或systemd守护进程部署。7. 我踩过的几个坑直接给你结论7.1 Vue3的reactive数组响应性问题Vue3的reactive是基于Proxy实现的看起来很美好但很多人还是会碰到“数据变了页面不更新”的情况。最常见的原因是对数组项直接赋值const state reactive({ seats: [] }) // 错误写法 state.seats[0] newSeat // 正确写法 state.seats.splice(0, 1, newSeat) // 或者用 ref const seats ref([]) seats.value[0] newSeat用reactive访问深层嵌套的对象在解构时也会丢失响应性。比如在setup里直接const { seats } state;后面修改seats[0]页面不更新。解决办法是不要解构或者用toRefs转换。我的习惯是数组和嵌套不深但多字段的对象一律用ref只有强调整体对象结构时才用reactive少踩很多坑。7.2 echarts在v-for循环里的尺寸计算不生效后台统计页通常要在同一个页面放好几个图表我一开始用v-for遍历optionList生成多个div然后echarts.init(chartDom)。结果发现有的图表宽度变成100px有的压根不渲染。原因很简单v-for渲染的DOM元素在onMounted里可能还没完成布局或者父容器使用了display: none导致getBoundingClientRect()返回0。网上有人说是pxtorem对echarts内联样式没生效其实是混淆了。pxtorem会转换你写的CSS和DOM元素的行内样式但echarts是通过canvas绘制的不受CSS单位影响它只认初始化时容器的高度和宽度。真正的问题是容器在初始化时没有真实尺寸。解决办法是在nextTick之后初始化再用ResizeObserver监听容器变化import * as echarts from echarts onMounted(async () { await nextTick() chart echarts.init(chartDom.value) chart.setOption(option) const observer new ResizeObserver(() chart.resize()) observer.observe(chartDom.value) })用v-for时图表容器的id不能写死要用ref函数动态获取每个实例。同时给每个图表div设定一个min-height避免初始化时高度为0。7.3 Vue3 diff算法对表格性能的影响以及key的写法很多人知道v-for要写key但不知道为什么要写。Vue3的diff算法会对比新旧虚拟DOM的key来判断节点是否可复用。如果给key写数组下标index当列表中间插入一项时后面的所有节点都被当成不同的节点全部销毁重建表格数据一旦上百条页面就会明显卡顿。正确写法是用业务唯一ID作为key比如seat.id、order_no。渲染长表格时还需要配合虚拟滚动只渲染可视区域内的行。我用的el-table在Element Plus里可以配合el-table-v2做虚拟表格数据量超过300行时效果非常明显。7.4 Java后端的时间精度问题LocalDateTime与Jackson序列化Java 8的LocalDateTime配合Jackson序列化时如果不加处理前端收到的可能是一长串时间数组比如[2025,6,1,14,30]而不是字符串。前端拿这个做时间显示非常头疼。我配置了Jackson统一序列化格式spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8同时在需要返回时间字符串的DTO字段上加JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8)。还有MySQL连接串里要加serverTimezoneAsia/Shanghai否则从数据库查出来的TIMESTAMP类型可能比北京时间早8小时。这个坑非常隐蔽因为它不会报错只会让所有时间看起来怪怪的。自习室管理系统上线之后最明显的改变是朋友不用再每晚核对Excel了。每天营业结束系统自动汇总当日订单、座位使用率、收入流水他只要看一眼后台首页的统计卡片就行。后续如果要把系统扩展到多门店我建议把store_id维度再强化一下或者干脆引入分库分表方案。不过对大多数单体预约系统来说上面这套设计已经完全够用。