资讯动态

基于SpringBoot与Vue的旅游管理系统毕业设计实战

发布时间:2026/9/16 16:04:55 来源:尧图企业网站定制
简介一套基于Spring Boot与Vue的旅游管理系统毕业设计资源面向计算机相关专业正在做毕设的学生以及需要项目实战的Java学习者。系统覆盖管理员与用户双端功能管理员可进行用户管理、景区分类与信息管理、景区商城、商品分类、用户分享、投诉建议、系统管理及订单管理用户端支持个人中心、用户分享、投诉建议、我的收藏与订单管理功能完整页面简洁易用。资源包共1893个文件以Java、Vue、HTML、JavaScript、CSS等前后端源码为主同时包含SQL数据库脚本、Maven配置、项目文档及部署说明压缩包整体约139MB。已有1358人学习下载。项目已通过导师指导并完成调试附带数据库脚本与部署视频可直接用于课程设计或毕业设计参考也适合用于Spring Boot与Vue整合开发的项目实战练习。1. 旅游管理系统这个毕设题SpringBoot Vue 前后端分离是对的打开方式本科毕业设计的旅游管理系统最常见的毛病是把题做小了只写景点表的增删改查答辩时被问一句“订单状态怎么流转”就没了下文。这个题目的正确打开方式是围绕用户、景点、线路、酒店、订单、评论六个实体用 SpringBoot Vue 前后端分离跑通完整闭环。这套方案的价值在于每个环节都是能被追问的技术点JWT 登录鉴权、MyBatis-Plus 分页、下单事务、vue-router 路由守卫。吃透这几个点既能避免“只做了一个 CRUD 系统”的评价也能当 springbootvue 前后端分离的练手项目。适合两类人Java 基础一般、需要稳妥过审的本科生以及想快速搭管理后台 demo 的开发者。下面按“设计 → 后端 → 前端 → 联调”展开每个模块都能直接照做。2. 数据库设计与 SpringBoot 工程骨架先把旅游系统的数据模型立住2.1 旅游系统的核心实体六张表的关系不用画太复杂先想清楚一个旅游管理系统里有哪些“名词”。用户、景点、线路、酒店、订单、评论这六个就够。很多网上的表结构会拆出七八张关联表对毕设来说反而增加维护负担。关系上只需要明确三点一个用户下多张订单一张订单只属于一个用户订单可以同时承载“门票、酒店、线路”三类商品用 item_type 区分。评论挂在景点和用户之间一对多不需要额外建中间表。表名核心字段设计说明userid, username, password, phone, role, create_timerole 用 tinyint0 普通用户1 管理员scenicid, name, city, ticket_price, open_time, description, imageimage 存相对路径或文件名不存完整 URLrouteid, title, days, price, spots, descriptionspots 用逗号分隔的景点 id简单但好实现hotelid, name, city, price, star, imagecity 字段用于和景点做同城推荐ordersid, order_no, user_id, item_type, item_id, item_name, quantity, total_price, statusitem_type 1 门票2 酒店3 线路commentid, user_id, scenic_id, content, score, create_timescore 控制在 1-5前端做星级展示2.2 订单表为什么设计成“通用订单”很多毕设会把订单拆成景点订单和酒店订单两张表理由是“类型不同字段不同”。但实际写起来会发现两张表意味着两个 Controller、两套 CRUD、两套列表页而字段差异其实只有 item_id 指向的目标不同。通用订单的做法是用 item_type item_id 组合指向任意商品再冗余一个 item_name 字段这样订单列表页不需要 join 就能显示商品名称。这个设计在答辩时是加分点。导师问“为什么订单表只有一张”时回答是“所有订单状态的流转逻辑是一致的拆表会导致重复代码后期加线路订单只需要新增一个 item_type 值”。这个回答同时展示了数据建模能力和对代码复用的理解。2.3 用 SQL 脚本把用户表和订单表建出来数据库用 MySQL 8.0字符集统一 utf8mb4。下面给出最核心的两张表景点、酒店、线路、评论表结构一致不单独展开。CREATE TABLE user ( id bigint NOT NULL AUTO_INCREMENT COMMENT 主键, username varchar(50) NOT NULL COMMENT 登录名, password varchar(100) NOT NULL COMMENT BCrypt加密后的密码, nickname varchar(50) DEFAULT NULL COMMENT 昵称, phone varchar(20) DEFAULT NULL COMMENT 手机号, role tinyint NOT NULL DEFAULT 0 COMMENT 0普通用户 1管理员, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 注册时间, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; CREATE TABLE orders ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单号业务上唯一, user_id bigint NOT NULL COMMENT 下单用户ID, item_type tinyint NOT NULL COMMENT 1门票 2酒店 3线路, item_id bigint NOT NULL COMMENT 对应商品的ID, item_name varchar(100) DEFAULT NULL COMMENT 商品名称冗余避免join, quantity int NOT NULL DEFAULT 1 COMMENT 数量, total_price decimal(10,2) NOT NULL COMMENT 总金额, status tinyint NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已取消, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表;密码字段长度给到 varchar(100)是因为 BCrypt 加密后的字符串是 60 位长度 50 会截断。order_no 建唯一索引有两个原因订单号作为对外展示的业务编号不应该让用户看出自增主键下单接口被重复提交时唯一索引能把并发重复插入挡在数据库层。这里刻意不加外键约束MyBatis-Plus 做逻辑删除和批量插入时外键会引入不必要的锁开销外键关系由 Service 层保证。答辩时可以说“外键由应用层维护避免高并发下单时触发级联锁”。注意MySQL 8.0 的驱动类名是 com.mysql.cj.jdbc.Driver老教程里的 com.mysql.jdbc.Driver 在 8.0 下会直接启动失败。2.4 SpringBoot 工程骨架用 2.7.x JDK 8 还是 3.x JDK 17建工程遇到的第一道坎是 SpringBoot 版本。SpringBoot 3.x 强制要求 JDK 17不少毕设环境还是 JDK 8环境变量配来配去启动就报 UnsupportedClassVersionError。建议直接用 SpringBoot 2.7.18它是 2.x 的最后一个版本JDK 8 和 JDK 17 都能跑MyBatis-Plus 3.5.x 也完全兼容省掉一堆版本适配问题。java 环境变量配置只需要确认 JAVA_HOME 指向 JDK 安装目录、PATH 里有 %JAVA_HOME%\bin。依赖用 Maven 管理pom.xml 里最核心的依赖如下。jjwt 需要同时引入 api、impl、jackson 三个包只引第一个会在运行时抛 ClassNotFound。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependenciesMyBatis-Plus 承担单表 CRUD 和分页BaseMapper 自带的 selectById、selectPage 能省掉手写 XML 的时间也避免“mapper.xml 路径写错导致启动报 Invalid bound statement”这类低级问题。实体类用 TableName(orders) 显式指定表名防止 Order 这种类名和 SQL 关键字冲突。工程目录按 controller / service / mapper / entity / config / common 分包common 里放统一返回和异常处理。这个结构不花哨但答辩时能顺口说出每层职责Controller 只做参数接收和路由转发Service 放业务逻辑Mapper 只碰 SQL。2.5 application.yml 配置数据源、驼峰映射和日志server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/travel_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: banner: falseurl 里的三个参数是必写项useUnicodetrue 打开 Unicode 开关characterEncodingutf8 保证中文不乱码serverTimezoneAsia/Shanghai 避免驱动和数据库时区不一致导致日期差 8 小时。map-underscore-to-camel-case 让数据库的 create_time 自动映射到实体字段 createTime否则查出来的字段全是 null。log-impl 配成 StdOutImpl 后控制台会打印每条 SQL 和参数联调时定位问题比看接口日志直接得多。3. SpringBoot 后端接口JWT 登录、统一返回与景点分页查询3.1 统一返回结果 Result让前端拦截器只认一个 code前后端分离项目里接口返回格式不统一是联调阶段最浪费时间的坑。有的接口返回 {code:200, data:...}有的直接返回裸数组前端每个页面都得单独判断。封装 Result 是第一步所有 Controller 都返回它。Data public class ResultT { private Integer code; // 200 成功401 未登录其余为业务失败 private String message; private T data; public static T ResultT ok(T data) { ResultT r new Result(); r.setCode(200); r.setMessage(success); r.setData(data); return r; } public static T ResultT fail(String message) { ResultT r new Result(); r.setCode(500); r.setMessage(message); return r; } public static T ResultT error(Integer code, String message) { ResultT r new Result(); r.setCode(code); r.setMessage(message); return r; } }code 的含义要和第四章的 axios 响应拦截器对齐200 直接取 data401 统一跳登录页其他 code 弹 message 提示。不要把业务状态和 HTTP 状态码混用“库存不足”应该返回 200 code 500而不是 HTTP 500否则 nginx 访问日志里全是误报的 5xx答辩演示时很难看。3.2 JWT 登录鉴权拦截器里解析 token别用 session管理员接口必须鉴权。用 JWT 而不是 Session原因有两个接口是前后端分离的前端可能部署在另一个端口SessionId 跨域传递要额外配置后续如果接小程序端同一套 token 机制能直接复用。登录接口校验完用户名密码后生成 token 返回前端前端存 localStorage以后每次请求带在 Authorization 头里。public class JwtUtil { private static final String SECRET travel-demo-change-me-2024; private static final long EXPIRE_MILLIS 24L * 60 * 60 * 1000; public static String generateToken(Long userId, Integer role) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(role, role) .setExpiration(new Date(System.currentTimeMillis() EXPIRE_MILLIS)) .signWith(Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8)), SignatureAlgorithm.HS256) .compact(); } public static Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8))) .build() .parseClaimsJws(token) .getBody(); } }写法对比jjwt 0.9.1jjwt 0.11.5构建解析器Jwts.parser().setSigningKey(secret字符串)Jwts.parserBuilder().setSigningKey(Keys.hmacShaKeyFor(...)).build()签名密钥字符串直接传必须用 Keys.hmacShaKeyFor 生成 SecretKey依赖单个 jjwt 包jjwt-api / jjwt-impl / jjwt-jackson 三件套这里是最常见的版本坑jjwt 0.9.1 的写法在 0.11.5 下编译直接报错。另外 SECRET 字符串必须超过 32 字节否则 HS256 初始化会抛弱密钥异常。密钥硬编码在代码里属于毕设阶段的妥协更合理的做法是放进 application.yml用 Value 注入。拦截器负责在请求进 Controller 之前完成校验。preHandle 里取 Authorization 头去掉 Bearer 前缀后解析 claims把 userId 和 role 放进 ThreadLocal 供 Service 使用接口方法里就不用每层都传 userId。请求结束后在 afterCompletion 清理 ThreadLocal防止线程池复用导致用户数据串号。3.3 景点分页查询MyBatis-Plus 的 Page 直接返回给前端景点列表是管理系统最典型的接口关键字模糊搜索、城市筛选、分页。Controller 接收四个参数Service 层用 LambdaQueryWrapper 组装条件避免字符串拼接 SQL 的注入风险。RestController RequestMapping(/api/scenic) public class ScenicController { Autowired private ScenicService scenicService; GetMapping(/page) public ResultPageScenic page( RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 10) Integer pageSize, RequestParam(required false) String keyword, RequestParam(required false) String city) { return Result.ok(scenicService.pageQuery(pageNum, pageSize, keyword, city)); } }Service 实现public PageScenic pageQuery(Integer pageNum, Integer pageSize, String keyword, String city) { PageScenic page new Page(pageNum, pageSize); LambdaQueryWrapperScenic wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.hasText(keyword), Scenic::getName, keyword) .eq(StringUtils.hasText(city), Scenic::getCity, city) .orderByDesc(Scenic::getTicketPrice); return scenicMapper.selectPage(page, wrapper); }LambdaQueryWrapper 的 like 和 eq 第一个参数是 boolean为 false 时该条件不拼进 SQL。用 StringUtils.hasText 判断参数为空的情况可以做到一个接口同时支持全量列表和条件筛选。Page 对象序列化到前端后包含 records 和 totalrecords 是当前页数据total 是总条数正好对应 Element Plus 的分页组件。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这个配置注册的是 MyBatis-Plus 分页插件它会把 Page 参数转换成带 LIMIT 的 SQL 再执行同时自动生成 count 查询。不配这个拦截器selectPage 返回的是全表数据的内存假分页数据量一大就卡这是分页查询最容易踩的坑。3.4 下单接口Transactional 和订单号生成下单涉及插入订单、更新商品余量两个写操作必须放在同一个事务里。Spring 的 Transactional 默认只在抛出 RuntimeException 时回滚如果方法里 catch 住异常再返回 Result.fail事务不会回滚会出现余量扣了但订单没生成的情况。所以下单方法要么不 catch要么在 catch 里手动调用 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。订单号用“日期 4 位随机数”生成比拿自增 id 当订单号安全。日期前缀还有一个演示价值答辩时连续下两单屏幕上能看到两个不同的订单号比 1、2、3 有说服力。订单表上的 order_no 唯一索引是最后一道防线即使并发重复提交数据库也会拒绝第二次插入并抛 DuplicateKeyException这个异常被全局异常处理器转成“订单已存在”提示。4. Vue 前端路由守卫、axios 封装与景点管理页面4.1 用 Vite 创建前端工程并完成 vue 安装依赖前端用 Vue3 Vite比 Vue2 的 webpack 模板启动快得多npm install 和 npm run dev 就能跑起来。开始前先确认 Node 版本vue 安装及环境配置这一步最容易出问题的是 Node 版本太老导致 Vite 5 无法启动建议 Node 16 以上。node -v npm create vitelatest travel-web -- --template vue cd travel-web npm install npm install element-plus vue-router4 axios npm run devnpm 安装依赖慢是国内环境的常态装到一半失败通常不是代码问题而是源的速度问题。执行 npm config set registry https://registry.npmmirror.com 换源后重新 install多数报错能解决。这里刻意不装 pinia旅游管理系统里需要跨页面共享的只有 token 和用户信息都放 localStorage 就够。引入 pinia 后反而要回答“为什么需要状态管理”这个追问。4.2 封装 axios请求拦截器带头、响应拦截器统一处理axios 不封装直接用在页面里效果是每个页面都写一遍“取 token 塞 header、判断 res.code、处理 401 跳登录”。封装之后页面里的调用变成 request.get(/scenic/page, { params })返回值直接就是业务数据。import axios from axios import { ElMessage } from element-plus import router from ../router const request axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器把 token 带到 Authorization 头 request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer token } return config }) // 响应拦截器统一处理业务码和登录过期 request.interceptors.response.use( response { const res response.data if (res.code 200) { return res.data } ElMessage.error(res.message || 请求失败) return Promise.reject(res) }, error { if (error.response error.response.status 401) { localStorage.removeItem(token) router.push(/login) } ElMessage.error(网络异常请检查后端服务) return Promise.reject(error) } ) export default requestbaseURL 写成 /api 而不是 http://localhost:8080/api是为了避免硬编码。开发时后端跑在 8080可以在 vite.config.js 里把 /api 转发到后端端口部署时由 nginx 把同一个域名的 /api 指向后端前端代码一行不用改。响应拦截器直接 return res.data调用方拿到的就是业务数据页面里不需要 res.data.data 这种双重取值。4.3 vue-router 路由配置与登录守卫未登录一律回登录页路由是前端权限体系的入口。页面分成三类公开页面首页、景点详情、登录页、需要管理员权限的后台页面。后台页面的路由配置里挂 meta.requiresAuth 和 meta.role全局 beforeEach 守卫根据标记做判断。import { createRouter, createWebHistory } from vue-router const routes [ { path: /login, component: () import(../views/Login.vue) }, { path: /admin, component: () import(../layout/AdminLayout.vue), redirect: /admin/scenic, meta: { requiresAuth: true, role: 1 }, children: [ { path: scenic, component: () import(../views/admin/ScenicManage.vue) }, { path: orders, component: () import(../views/admin/OrderManage.vue) } ] }, { path: /scenic/:id, component: () import(../views/ScenicDetail.vue) } ] const router createRouter({ history: createWebHistory(), routes }) router.beforeEach((to, from, next) { const token localStorage.getItem(token) const role localStorage.getItem(role) if (to.meta.requiresAuth !token) { next(/login) return } if (to.meta.role to.meta.role ! role) { next(/) return } next() }) export default routermeta 字段类型作用requiresAuthboolean需要登录未登录跳 /loginrolestring需要的角色与 localStorage 里的 role 比对这里用到两种 vue 路由参数写法列表页跳详情页推荐 path query即 router.push({ path: /scenic/detail, query: { id: row.id } })刷新后参数还在地址栏里复制链接也能定位只在组件内部流转的临时数据才用 params。路由守卫只判断 token 是否存在不解析是否过期过期判断交给 axios 的 401 响应处理职责更清晰。登录后要把 role 也写进 localStorage否则角色守卫永远放行。4.4 Element Plus 景点管理页表格、分页和查询条件的组合后台管理页面的核心是表格 分页 搜索条件几乎每个模块都是这个模式写熟一个模块线路管理、酒店管理就是复制改字段。下面是景点管理页的最小可运行版本。template div el-input v-modelkeyword placeholder景点名称 clearable stylewidth: 200px / el-button typeprimary clickhandleSearch查询/el-button el-table :datalist border stripe stylemargin-top: 12px el-table-column propname label景点名称 / el-table-column propcity label城市 width120 / el-table-column propticketPrice label门票(元) width120 / el-table-column label操作 width160 template #default{ row } el-button sizesmall typeprimary clickrouter.push({ path: /scenic/detail, query: { id: row.id } }) 详情 /el-button /template /el-table-column /el-table el-pagination v-model:current-pagepageNum v-model:page-sizepageSize :totaltotal layouttotal, prev, pager, next stylemargin-top: 12px current-changeloadData / /div /template script setup import { ref, onMounted } from vue import { useRouter } from vue-router import request from ../../api/request const router useRouter() const list ref([]) const keyword ref() const pageNum ref(1) const pageSize ref(10) const total ref(0) async function loadData() { const data await request.get(/scenic/page, { params: { pageNum: pageNum.value, pageSize: pageSize.value, keyword: keyword.value || undefined } }) list.value data.records total.value data.total } function handleSearch() { pageNum.value 1 // 搜索时回到第一页避免停在空页 loadData() } onMounted(loadData) /scriptkeyword.value || undefined 这行值得展开输入框被清空后 keyword 是空字符串axios 序列化时会把 ?keyword 发出去后端 StringUtils.hasText 能兜住但 URL 里挂着空参数不干净传 undefined 则 axios 直接忽略该参数。el-pagination 的 current-change 只在页码变化时触发所以查询按钮里要手动把 pageNum 重置为 1否则用户在第三页搜索时会带着旧页码请求出现“明明有数据却搜不到”的假象。这个细节在答辩演示时很容易暴露提前处理掉。5. 联调、打包部署与答辩验证SpringBoot Vue 收尾的细节5.1 开发联调阶段的跨域配置前端 dev server 跑在 5173后端在 8080浏览器会拦截跨域请求。最省事的做法是在后端加全局 CORS 配置前端不需要额外转发配置dev 和 prod 行为一致。Configuration public class WebConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowCredentials(true) .maxAge(3600); } }注意是 allowedOriginPatterns 而不是 allowedOrigins前者可以和 allowCredentials(true) 共存后者配 * 时携带 cookie 的请求会被浏览器拒绝。OPTIONS 预检请求必须放行因为 axios 带了 Authorization 自定义头浏览器会先发一次 OPTIONS 探路。部署到同端口后这段配置保留也不影响。5.2 打包前端 dist 交给后端java -jar 一键启动前端 npm run build 生成 dist 目录后端 mvn clean package -DskipTests 生成 jar。答辩推荐把 dist 复制到后端 resources/static 下重新打包一个 jar 里既有页面又有接口在任何机器上都能 java -jar 跑起来现场演示不依赖 Node。npm run build cp -r dist ../travel-server/src/main/resources/static/ cd ../travel-server mvn clean package -DskipTests java -jar target/travel-0.0.1-SNAPSHOT.jar用 nginx 托管 dist、把 /api 请求转发到 8080 是另一种方式但答辩现场多一个进程就多一个故障点。前端使用 history 模式路由时刷新 /admin/scenic 会 404因为静态服务器没有这个物理文件这是 hash 模式不存在的坑演示前务必确认刷新页面能正常回显。5.3 答辩前按这个清单验证再处理时间格式这个隐藏坑主线流程全部走一遍管理员登录 → 新增景点 → 前台注册用户 → 搜索景点 → 下单 → 查看订单 → 修改订单状态。每条都要实际点一遍不能只在接口工具里调。补两条异常路径不登录直接访问 /admin看是否被守卫弹回登录页用普通用户账号访问管理接口看是否被 role 校验拦下。验证项预期结果常见失败原因登录成功生成 tokenlocalStorage 出现 tokenBCrypt 密码校验失败景点分页查询records 少于等于 pageSize 条分页拦截器没配置不登录访问后台路由跳转 /login守卫里 meta 没写对下单后订单列表可见item_name 正确显示item_type 映射错误最后一个容易翻车的点在时间字段。数据库的 datetime 被 Jackson 序列化后带毫秒和时区偏移页面显示类似 2024-01-01T08:00:00.00000:00 的格式。在实体时间字段上加 JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8)列表和详情的时间显示就统一了。如果改用全局配置 spring.jackson.date-format 和 time-zone 要注意它对 java.util.Date 生效对 LocalDateTime 不一定生效LocalDateTime 需要额外引入 jackson-datatype-jsr310 并单独配置。把 timezone 写成 GMT8是演示时最不容易翻车的一行配置。本文还有配套的精品资源点击获取

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

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

免费获取报价