资讯动态

会议室预约系统状态机与并发控制实战设计

发布时间:2026/10/21 2:44:39 来源:尧图企业网站定制
简介这是一套面向Java Web初学者与企业信息化开发者的会议室资源管理实战项目聚焦会议场景下的多角色协同预约需求解决传统人工排会易冲突、效率低、信息不同步等痛点。资源包共744个文件5.01MB涵盖20个JSP核心页面实现用户交互与业务跳转、212个JS脚本含表单验证与动态日历控件、74个HTML静态页公告展示与说明、26个CSS样式文件响应式界面布局及31个PNG/GIF图标资源辅以JavaBean类、Servlet配置、SQL建表语句与Tomcat部署说明构成完整MVC分层结构。已有1659人学习下载读者可直接部署运行掌握基于JSPServletMySQL的Web应用开发全流程包括部门/员工/会议室三级数据建模、Session权限控制、预约冲突检测逻辑、前后端交互规范及常见安全加固实践。1. 为什么一个“会议室预约”系统反而最容易暴露毕设/课设的硬伤你手上的这个「JAVA JSP会议室预约系统」表面看是教务管理里的小模块但实际是检验工程能力的试金石——它不考炫技算法却卡在真实业务闭环的每个毛细血管里谁在什么时间、用什么身份、预约哪间带投影仪的3号会议室预约后能不能被行政老师一键驳回驳回理由要不要留痕已确认的预约临时加人怎么同步通知这些不是“功能点”而是权限流、状态机、并发控制、日志追溯四重关卡。我带过三届学生做这类系统80%的翻车现场都出在“以为JSPServlet能跑通登录就等于系统做完”——结果答辩时导师一问“两个老师同时点下‘确认使用’按钮系统怎么保证只生成一条占用记录”当场哑火。这套方案专为踩过坑的人设计从真实会议场景倒推架构用可验证的状态迁移图定义流程给出带事务注解的DAO层关键代码附带5条高发并发/权限/数据一致性黑盒问题排查清单。适合正在赶毕设/课设、需要一份能跑、能讲、能答的完整交付物的同学也适合作为小型OA中会议模块的技术参考原型。2. 需求建模与核心状态机设计把“开会”这件事拆解成可编码的规则会议室预约不是简单的CRUD而是一套受角色、时间、设备、审批链多重约束的状态流转过程。我们先放弃“用户填表→存数据库”的直觉用状态机思维锚定业务主干。2.1 从真实会议场景提炼6类核心角色与权限边界系统中不存在“万能管理员”。必须明确划分以下角色及其操作边界直接决定后续Spring Security配置粒度普通员工仅可创建/取消自己发起的待审核预约查看本人所有预约含历史不可修改他人预约部门秘书可代本部门员工提交预约可查看本部门全部预约含未审核可撤回本部门待审预约会议室管理员可审核/驳回所有预约可强制释放超时未使用的预约可维护会议室基础信息名称、容量、设备列表超级管理员仅用于初始化系统如创建初始管理员账号日常不启用该权限访客账号预留扩展仅可查看公开会议室空闲时段不可预约为后续对接门禁系统留接口系统服务账号用于定时任务如每日凌晨清理7天前的历史记录无Web登录入口提示角色权限表role_permission必须采用“角色-资源-操作”三元组设计而非简单字段标记。例如“会议室管理员”对/meeting/approve资源拥有POST操作权限而非在用户表加is_admin布尔字段——后者会导致权限变更需改代码且无法支持未来“某管理员仅管3楼会议室”的精细化需求。2.2 会议预约全生命周期状态机4个主状态7种合法迁移预约单meeting_booking的核心字段status不能用字符串随意赋值必须严格遵循下图状态迁移规则实际开发中用枚举类BookingStatus封装当前状态可迁移到状态触发动作约束条件DRAFT草稿PENDING_APPROVAL提交预约必须选择会议室、起止时间、参会人数时间不能与该会议室已确认预约冲突PENDING_APPROVAL待审核APPROVED/REJECTED/CANCELLED_BY_USER审核通过/驳回/用户主动取消驳回时reject_reason字段必填用户取消仅限审核前APPROVED已批准OCCUPIED/CANCELLED_BY_ADMIN/EXPIRED开始使用/管理员强制取消/超时未使用OCCUPIED需校验当前时间≥预约开始时间EXPIRED由定时任务触发超时30分钟未点击“开始使用”OCCUPIED使用中COMPLETED/CANCELLED_BY_ADMIN结束会议/管理员强制终止COMPLETED需记录实际结束时间用于统计会议室利用率// BookingStatus.java 枚举定义关键部分 public enum BookingStatus { DRAFT(草稿), PENDING_APPROVAL(待审核), APPROVED(已批准), OCCUPIED(使用中), COMPLETED(已完成), REJECTED(已驳回), CANCELLED_BY_USER(用户取消), CANCELLED_BY_ADMIN(管理员取消), EXPIRED(已过期); private final String desc; // 构造、getter省略... // 核心方法校验状态迁移是否合法 public boolean canTransitionTo(BookingStatus target) { switch (this) { case DRAFT: return target PENDING_APPROVAL; case PENDING_APPROVAL: return target APPROVED || target REJECTED || target CANCELLED_BY_USER; case APPROVED: return target OCCUPIED || target CANCELLED_BY_ADMIN || target EXPIRED; case OCCUPIED: return target COMPLETED || target CANCELLED_BY_ADMIN; default: return false; // 其他状态禁止迁移 } } }这段代码的价值在于所有状态变更必须调用canTransitionTo()校验否则抛出IllegalStateException。它把业务规则固化在代码层避免前端JS或Controller里写一堆if-else判断极大降低逻辑错乱风险。我见过太多项目在“已批准”状态下还允许用户点击“取消”结果数据库状态变成非法值后续统计报表全崩。2.3 关键约束的落地实现时间冲突检测与设备匹配算法会议室预约最常被忽略的硬性约束是时间重叠和设备缺失。不能只查“该会议室在[14:00,15:00]是否有预约”而要精确到分钟级的区间覆盖检测。-- MySQL查询检查会议室room_id5在start_time2024-06-15 14:00:00到end_time2024-06-15 15:00:00之间是否存在冲突预约 SELECT COUNT(*) FROM meeting_booking WHERE room_id 5 AND status IN (PENDING_APPROVAL, APPROVED, OCCUPIED) AND NOT ( end_time 2024-06-15 14:00:00 OR -- 新预约在旧预约结束后开始 start_time 2024-06-15 15:00:00 -- 新预约在旧预约开始前结束 );设备匹配逻辑放在Service层更合理先查出所有满足时间要求的会议室再过滤出包含所需设备如投影仪、视频会议系统的列表。SQL中用FIND_IN_SET(投影仪, equipment_list)虽快但违反第一范式长期维护成本高。推荐在room表增加equipment_ids字段JSON数组存储设备ID关联equipment字典表通过JOIN实现精准匹配。3. 总体架构设计三层结构与四个模块的职责划分本系统采用经典Java Web分层架构但针对预约场景做了关键强化将状态机引擎独立为Service模块DAO层强制事务控制JSP页面仅负责渲染不掺杂业务逻辑。避免初学者常见的“JSP里写Java代码判断状态”反模式。3.1 分层结构图与各层核心职责┌─────────────────┐ HTTP/HTTPS ┌──────────────────┐ │ Browser │◄─────────────────►│ Tomcat 9 │ └────────┬────────┘ └────────┬─────────┘ │ │ ▼ ▼ ┌─────────────────┐ ┌──────────────────┐ │ JSP Pages │ │ Servlets │ │ (View Layer) │ │ (Controller) │ │ - booking_form.jsp │◄───────┐ │ - BookingServlet │ │ - my_bookings.jsp │ │ │ - AdminServlet │ │ - room_calendar.jsp │ └────────►│ - ReportServlet │ └────────┬────────┘ └────────┬─────────┘ │ │ ▼ ▼ ┌───────────────────────────────────────────────────────────────┐ │ Service Layer (Core Logic) │ │ - BookingService: 状态迁移、冲突检测、通知触发 │ │ - RoomService: 设备匹配、容量校验、空闲时段计算 │ │ - UserService: 角色权限校验、部门树查询 │ │ - NotificationService: 邮件/站内信模板渲染与发送 │ └───────────────────────────────────────────────────────────────┘ │ ▼ ┌───────────────────────────────────────────────────────────────┐ │ DAO Layer (Data Access) │ │ - BookingDAO: 所有booking操作Transactional标注 │ │ - RoomDAO: 会议室增删改查含设备关联查询 │ │ - UserDAO: 用户信息及角色关系查询 │ │ - LogDAO: 操作日志记录关键用于审计 │ └───────────────────────────────────────────────────────────────┘ │ ▼ ┌───────────────────────────────────────────────────────────────┐ │ Database (MySQL 5.7) │ │ - meeting_booking: id, room_id, user_id, start_time, end_time, │ │ status, created_at, updated_at, reject_reason, actual_end...│ │ - meeting_room: id, name, capacity, equipment_list(JSON), ... │ │ - sys_user: id, username, password, dept_id, role_code, ... │ │ - sys_log: id, operator_id, operation_type, target_id, ... │ └───────────────────────────────────────────────────────────────┘3.2 四大核心模块详细设计与接口契约模块一预约管理模块Booking Module核心接口BookingService.createBooking(BookingRequest request)关键设计点BookingRequest对象必须包含roomId,startTime,endTime,userId,requiredEquipmentIds(List )禁止传入原始字符串参数创建前调用RoomService.checkAvailabilityAndEquipment(roomId, startTime, endTime, requiredEquipmentIds)该方法内部执行SQL时间冲突检测设备匹配状态初始化为DRAFT仅当用户点击“提交审核”才调用BookingService.submitForApproval(bookingId)此时状态变更为PENDING_APPROVAL所有状态变更操作approve/reject/cancel均在BookingService中完成Controller绝不直接更新status字段模块二会议室资源模块Room Module核心接口RoomService.getAvailableRooms(LocalDateTime start, LocalDateTime end, ListLong equipmentIds)关键设计点返回ListRoomWithAvailability其中RoomWithAvailability包含会议室基本信息该时段是否可用true/false冲突预约数用于前端提示“该时段已被预约X次”空闲时段计算不依赖复杂SQL而是用Java Stream处理查出该会议室所有APPROVED/OCCUPIED状态的预约生成LocalDateTime[]数组表示占用区间再与请求时段做差集运算设备匹配采用IN子查询SELECT r.* FROM meeting_room r JOIN room_equipment re ON r.idre.room_id WHERE re.equipment_id IN (1,3,5) GROUP BY r.id HAVING COUNT(DISTINCT re.equipment_id)3模块三权限与角色模块Auth Module技术选型Spring Security 5.7 自定义UserDetailsService关键设计点权限表达式使用PreAuthorize(hasRole(ROOM_ADMIN) or #booking.userId principal.id)控制方法级访问角色数据从数据库加载不硬编码。SysUser实体中roleCode字段对应sys_role表的code如ROOM_ADMIN登录成功后SecurityContext中存入CustomUserDetails对象包含username,deptName,roleCode,permissions(List )JSP页面用sec:authorize accesshasRole(ROOM_ADMIN)控制按钮显示禁止用EL表达式${user.role admin}模块四通知与日志模块Notification Audit Module核心设计解耦通知渠道日志强制记录关键设计点NotificationService.send(NotifyRequest request)接收统一请求对象内部根据request.channelEMAIL/SYSTEM调用不同实现站内信模板存于notify_template表template_codeBOOKING_APPROVED对应审批通过模板支持{userName},{roomName}等占位符所有状态变更、关键操作如管理员强制取消必须调用LogDAO.insertLog(...)记录字段包括operatorId,operationType(BOOKING_APPROVE),targetId(预约ID),details(JSON格式含原状态/新状态/操作人IP)4. 关键模块实现与避坑指南5条血泪经验总结这部分是真正让你避开答辩雷区的干货。下面列出我在多个项目中反复验证过的高频翻车点每条都按“现象→原因→解决”结构给出可立即执行的方案。4.1 并发预约冲突两个用户同时预约同一时段数据库存了两条记录现象测试时两人几乎同时点击“提交审核”后台日志显示两条INSERT INTO meeting_booking成功但会议室实际只能容纳一场会议。原因时间冲突检测checkAvailabilityAndEquipment和插入预约insertBooking是两个独立数据库操作中间存在时间窗口。即使加了TransactionalMySQL默认隔离级别REPEATABLE READ也无法阻止幻读。解决在BookingDAO.insertBooking()方法上添加数据库唯一索引强制约束-- 在meeting_booking表上创建复合唯一索引防止同一会议室在同一时段出现多条非取消状态记录 CREATE UNIQUE INDEX uk_room_time_status ON meeting_booking (room_id, DATE(start_time), HOUR(start_time), status) WHERE status IN (PENDING_APPROVAL, APPROVED, OCCUPIED);注意MySQL 8.0才支持函数索引和WHERE条件索引。若用低版本改用start_dateDATE类型和time_slotTINYINT如14表示14:00-15:00两个字段组合唯一索引。4.2 状态机失控用户在“已批准”状态仍能点击“取消预约”按钮现象JSP页面未根据booking.status动态控制按钮可见性导致用户看到“取消”按钮并点击后端未校验状态直接执行取消逻辑造成状态非法如APPROVED→CANCELLED_BY_USER。原因前端控制JSP EL表达式和后端校验Service方法脱节。常见错误是Controller里只校验userId不校验status。解决双重校验。JSP中c:if test${booking.status DRAFT || booking.status PENDING_APPROVAL} a hrefbooking?opcancelid${booking.id}取消预约/a /c:ifService中cancelBooking(Long bookingId, Long userId)方法开头必须加Booking booking bookingDAO.findById(bookingId); if (!booking.getStatus().canTransitionTo(BookingStatus.CANCELLED_BY_USER)) { throw new BusinessException(当前状态不允许取消 booking.getStatus().getDesc()); }4.3 时间显示时区混乱用户在北京提交14:00预约数据库存成06:00现象前端JSP用fmt:formatDate value${booking.startTime} patternyyyy-MM-dd HH:mm/显示正常但数据库里start_time字段值比预期早8小时。原因MySQL服务器时区为UTC而Java应用未指定时区SimpleDateFormat或JDBC驱动默认用系统时区可能为CST导致时间解析错乱。解决三处统一设置为Asia/ShanghaiMySQL启动参数default-time-zone08:00JDBC连接URLjdbc:mysql://localhost:3306/meeting?serverTimezoneAsia/ShanghaiuseUnicodetruecharacterEncodingUTF-8Java代码中所有日期操作用LocalDateTime无时区ZoneId.of(Asia/Shanghai)转换禁用Date和Calendar4.4 权限绕过未登录用户直接访问/admin/approve.jsp页面现象在浏览器地址栏输入http://localhost:8080/admin/approve.jsp页面直接打开无任何拦截。原因Spring Security配置中只保护了/admin/**路径下的Servlet如/admin/approve但未保护.jsp后缀的静态资源。解决在WebSecurityConfigurerAdapter中显式配置Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers(/admin/**).hasRole(ROOM_ADMIN) .antMatchers(/**/*.jsp).denyAll() // 拒绝所有JSP直接访问 .antMatchers(/css/**, /js/**, /images/**).permitAll() .anyRequest().authenticated(); // ...其他配置 }同时确保web.xml中servlet-mapping不将.jsp映射给Spring DispatcherServlet保持JSP由容器原生处理。4.5 日志缺失管理员驳回预约后找不到谁在何时驳回及理由现象用户投诉“我的预约被驳回了但没收到通知”管理员坚称“我没操作”无证据追溯。原因驳回操作只更新了meeting_booking.status和reject_reason未记录操作人、时间、IP等审计信息。解决在BookingService.rejectBooking(Long bookingId, Long adminId, String reason)方法中必须调用日志服务// 记录驳回操作日志 Log log new Log(); log.setOperatorId(adminId); log.setOperationType(BOOKING_REJECT); log.setTargetId(bookingId); log.setDetails(JsonUtil.toJsonMap(Map.of( oldStatus, booking.getStatus().name(), newStatus, BookingStatus.REJECTED.name(), reason, reason, clientIp, getClientIp(request) // 从HttpServletRequest获取 ))); logDAO.insertLog(log); // 再执行驳回业务逻辑 booking.setStatus(BookingStatus.REJECTED); booking.setRejectReason(reason); bookingDAO.update(booking);5. 前端交互与用户体验优化让系统不只是能用还要好答很多同学花90%精力在后端却在答辩时被导师一句“这个界面怎么没有预约成功提示”问懵。前端不是点缀而是业务闭环的关键一环。这里聚焦三个最影响答辩观感的点实时状态反馈、空闲时段可视化、移动端适配。5.1 预约流程的渐进式状态反馈告别“白屏等待”JSP页面不能只靠response.sendRedirect()跳转必须给用户明确的进度感知。以提交预约为例!-- booking_form.jsp 中的提交按钮 -- button typebutton onclicksubmitBooking() idsubmitBtn span idbtnText提交审核/span span idloadingIcon classd-none⏳/span /button script function submitBooking() { const btn document.getElementById(submitBtn); const text document.getElementById(btnText); const icon document.getElementById(loadingIcon); // 1. 按钮置灰显示加载中 btn.disabled true; text.classList.add(d-none); icon.classList.remove(d-none); // 2. 发送AJAX请求 fetch(booking?opsubmit, { method: POST, headers: {Content-Type: application/x-www-form-urlencoded}, body: new URLSearchParams({ roomId: document.getElementById(roomId).value, startTime: document.getElementById(startTime).value, endTime: document.getElementById(endTime).value }) }) .then(response response.json()) .then(data { if (data.success) { // 3. 成功显示Toast并跳转 showToast(预约已提交等待审核, success); setTimeout(() window.location.href my_bookings.jsp, 1500); } else { // 4. 失败恢复按钮显示错误 btn.disabled false; text.classList.remove(d-none); icon.classList.add(d-none); showToast(提交失败 data.message, error); } }) .catch(err { btn.disabled false; text.classList.remove(d-none); icon.classList.add(d-none); showToast(网络错误请重试, error); }); } /script这段代码的价值在于用户永远知道系统在做什么。答辩时导师点击按钮看到按钮变灰、出现⏳图标、1.5秒后跳转会直观感受到“流程严谨”。而如果只是form actionbooking?opsubmit提交后白屏2秒体验感直接扣分。5.2 会议室空闲时段日历视图用纯CSS实现响应式网格避免引入jQuery UI或FullCalendar等重型库。用Bootstrap Grid 简单CSS即可实现清晰的日程表!-- room_calendar.jsp 核心HTML -- div classrow div classcol-2 font-weight-bold时间/div c:forEach items${rooms} varroom div classcol-2 font-weight-bold${room.name}/div /c:forEach /div c:forEach begin8 end18 step1 varhour div classrow border-top div classcol-2 py-1${hour}:00/div c:forEach items${rooms} varroom div classcol-2 py-1 c:choose c:when test${room.availability[hour] AVAILABLE} button classbtn btn-sm btn-success w-100 onclickbookRoom(${room.id}, ${hour}:00, ${hour1}:00) ✅ 空闲 /button /c:when c:otherwise span classtext-muted small❌ 占用/span /c:otherwise /c:choose /div /c:forEach /div /c:forEach/* room_calendar.css */ .btn-success { background-color: #28a745 !important; border-color: #218838 !important; } .btn-success:hover { background-color: #218838 !important; }这个设计的优势是零JS依赖纯服务端渲染。room.availability是MapInteger, Stringkey为小时8-18value为AVAILABLE/OCCUPIED。数据在Controller中已计算好JSP只做展示。答辩演示时导师拖动浏览器宽度网格自动从4列变为2列会立刻感受到“移动端友好”。5.3 移动端适配关键点表单与列表的触控优化JSP页面默认在手机上体验极差。只需三处修改就能让导师在手机上流畅操作强制视口缩放在head中加入meta nameviewport contentwidthdevice-width, initial-scale1, maximum-scale1, user-scalableno表单控件增大点击区域所有input、select、button添加CSSinput, select, button { min-height: 44px; /* iOS最小触控区域 */ padding: 10px; font-size: 16px; }列表项增加分割线与间距my_bookings.jsp中c:forEach items${bookings} varb div classcard mb-3 shadow-sm div classcard-body h6 classcard-title${b.roomName} · ${b.startTimeStr} - ${b.endTimeStr}/h6 p classcard-text span classbadge badge-${b.statusBadge}${b.statusDesc}/span br参会人${b.participantCount}人 /p /div /div /c:forEachbadge-${b.statusBadge}对应CSS类.badge-success已批准、.badge-warning待审核等颜色区分一目了然。答辩时导师用手机点开你的系统看到卡片式布局、醒目的状态标签、足够大的按钮会认为“这学生考虑到了真实使用场景”。6. 答辩验收与交付检查用这7个问题自测系统完整性答辩不是背诵代码而是证明你构建了一个可运行、可验证、可维护的系统。我每次指导学生前都会让他们对着这份清单逐项打钩。只要7项全过答辩通过率接近100%。这不是玄学是把抽象的“系统完成度”转化成可执行的检查项。6.1 功能性验收7个必答问题清单序号问题检查方式通过标准我的血泪经验1能否用普通员工账号预约一间会议室并在1分钟内被会议室管理员审核通过实际操作A账号预约→B账号登录审核→刷新A账号页面A账号“我的预约”中状态变为“已批准”且B账号操作日志有记录曾有学生忘记在BookingService.approve()中调用NotificationService.send()导致用户收不到通知答辩时导师故意等2分钟说“怎么没提示”当场扣分2两个用户同时预约同一会议室同一时段是否只有一人成功另一人是否收到明确提示并发测试用Chrome和Firefox同时提交相同预约数据库meeting_booking表只新增1条记录失败方页面显示“该时段已被预约请选择其他时间”不做并发测试是最大盲区。用Postman发两个POST请求比手动点更快10秒搞定3管理员驳回预约时是否强制填写驳回理由理由是否存入数据库并显示在用户端操作管理员驳回时不填理由→点击确定页面弹出JS警告“驳回理由不能为空”填入理由后用户端“我的预约”详情页显示该理由很多同学只在数据库加了reject_reason字段但没在JSP表单加required属性也没在Service层校验非空属于典型“半成品”4修改密码后原Session是否立即失效能否用旧密码登录操作A账号登录→修改密码→用旧密码尝试登录旧密码登录失败提示“用户名或密码错误”新密码可登录Session管理常被忽视。必须在UserService.changePassword()中调用SecurityContextHolder.getContext().setAuthentication(null)并清空HttpSession5查看“会议室使用率报表”时是否显示近7天每间会议室的使用小时数数据是否准确操作进入报表页核对数据库meeting_booking中statusCOMPLETED的记录报表数字 SUM(actual_end_time-start_time)单位为小时四舍五入到小数点后1位报表SQL别写COUNT(*)那是预约次数不是使用时长。必须用TIMESTAMPDIFF(HOUR, start_time, actual_end_time)6在手机浏览器访问预约表单能否正常填写按钮是否足够大页面是否横向滚动真机测试用iPhone Safari打开系统表单控件可点击无横向滚动条文字清晰可读切忌用Chrome模拟器真机测试10分钟能发现90%的移动端问题7删除一条已“使用中”的预约系统是否拒绝并提示“会议正在进行中不可删除”操作将预约状态设为OCCUPIED→尝试删除页面提示明确错误数据库记录未删除操作日志记录“DELETE_FAILED”状态机校验必须覆盖所有非法迁移。BookingService.deleteBooking()开头第一行就应是if (booking.getStatus() OCCUPIED) throw ...6.2 文档与代码规范答辩官最易抓的细节除了功能文档质量直接暴露你的工程素养。我要求学生必须提供以下三份文档缺一不可《数据库设计说明书》PDF包含ER图用draw.io画、每张表的字段说明含NOT NULL、INDEX、FOREIGN KEY标注、关键索引解释如uk_room_time_status为何要加WHERE条件。不要截图Navicat要手绘ER图——这证明你理解了关系。《部署手册》TXT精确到每一步命令例如# 1. 导入数据库 mysql -u root -p meeting meeting_schema.sql # 2. 修改Tomcat配置 编辑 conf/server.xml将Connector port8080 改为 port8081 # 3. 启动应用 ./bin/startup.sh不写“配置好数据库连接”这种废话要写出具体改哪行、填什么值。《源码注释覆盖率报告》HTML用JaCoCo生成要求BookingService.java等核心类注释覆盖率≥80%。注释不是写// 设置用户ID而是// 校验状态机仅DRAFT/PENDING_APPROVAL状态允许取消防止非法状态迁移。从那以后我每次带学生做毕设都强制他们在写第一行代码前先用Word画出状态机图、用draw.io画出ER图、用记事本写下部署步骤。不是为了交差而是让大脑先建立完整的系统骨架再往里填肉。骨架歪了代码写得再漂亮也是危房。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑