资讯动态

SSM+Vue教学资源库系统开发实战:从上传审核到在线预览

发布时间:2026/10/21 1:55:35 来源:尧图企业网站定制
1. 起因教学资源乱成一锅粥才决定动手做这个库先交代一下背景。我们这边是搞创客教育的团队课程横跨图形化编程、开源硬件、3D建模几个方向老师和助教加起来十几号人积累的教案、课件、代码示例、电路图、视频素材少说也有几千份。听起来资源很多是好事但乱起来真要命。有人把课件传在群里有人把视频放在网盘有人把教案发在自己电脑里还有人把学生作品截个图随手扔在共享目录。临到上课前十分钟满世界找素材是常态新来的助教想了解某节课的历史资料问了一圈也凑不齐。我最初的想法很简单把散落的文件归拢起来做一个统一入口。但做了一段时间之后发现光有文件管理还不够需要一套真正贴合教学场景的“资源库”老师上传资料时最好能按课程、年级、课时分类搜索要快按关键词几秒钟内能找到某一节课的配套素材资源要有审核流程避免有人直接把未经校对的内容共享给学生学生端最好也能访问部分资源比如课堂演示视频、作业要求。就这样小码创客教育教学资源库这个项目立项了。技术选型上我结合团队现有人员的技术栈和对稳定性的要求最终确定后端用 SSMSpring Spring MVC MyBatis前端用 Vue。项目本身不复杂但麻雀虽小五脏俱全从资源上传、在线预览、标签检索到用户权限、审核流程、课程关联整个过程走下来有很多细节值得展开聊聊。这篇文章就是把这个项目的完整过程做一个复盘包括为什么选这套技术栈、核心功能模块怎么设计、前后端联调时踩过的坑、上线之后遇到的实际问题。如果你是做教育类系统、内容管理类系统或者想拿一个完整的SSMVue项目做练手这篇应该能给你一些参考。2. 技术选型的真实考量为什么是SSMVue而不是其他组合2.1 后端框架选型SSM在这个场景下反而合适先说明一点这个项目立项的时候Spring Boot 已经非常成熟了。如果从零开始完全不受约束我大概率会选 Spring Boot。但真实情况是团队里几位核心开发对 Spring MVC XML 配置的方式非常熟悉手头还有几个维护了很多年的老项目都是这套体系新系统上线之后也需要这些同事来接。与其让他们重新学一套注解体系和自动配置逻辑不如保持技术栈一致性。另外资源库这个项目的复杂度并没有想象中高。系统主要做的就是对教学资源的增删改查、文件上传下载、用户鉴权、审核流转这些操作 SSM 完全可以胜任而且 MyBatis 在手写复杂 SQL 时优势很明显。举个例子按课程名、资源类型、上传时间、标签多条件组合查询这种逻辑我用 MyBatis 的动态 SQL 写起来非常顺手用 JPA 反而要花不少时间调方法命名。选型时也考虑过微服务。我的结论是一个内部教学资源库用户量撑死了几百号人搞一套注册中心、网关、配置中心纯属给自己找麻烦。单体应用 合理的模块分包部署维护成本低得多。2.2 前端框架选型Vue Element UI 的开发效率确实高前端选 Vue 的原因也很直接生态成熟、上手快组件库丰富。尤其 Element UI 这类组件库表格、表单、上传、弹窗都是现成的做后台管理系统效率极高。资源库这类系统80% 的界面都是“左侧菜单 顶部操作栏 中间表格/卡片列表”用 Vue Element UI 基本上就是搭积木。有一个点值得说说就是前端我用的是 Vue CLI 手搭的项目而不是直接拿现成的后台管理模板。原因是我希望项目的依赖尽量精简模板里夹杂了一堆用不上的图表库、地图组件、mock 数据体积大不说出问题都不好定位。自己搭的话核心依赖就这几样依赖用途版本vueMVVM 框架2.6.xvue-router前端路由3.xvuex全局状态管理3.xaxiosHTTP 请求库0.21.xelement-uiUI 组件库2.15.xmavon-editorMarkdown 编辑器2.9.x实际开发下来这套组合非常顺手。可能有人觉得 Vue 2 已经过时了但这个项目是团队内部使用的系统功能稳定、浏览器兼容性好是第一位的Vue 2 完全可以满足要求没必要为了追新而追新。2.3 文件存储与数据库设计的前置考虑资源库的核心资产就是各类教学文件。我做的第一个决策是文件本体用本地磁盘存储数据库只存文件元数据。原因很简单团队规模决定了我们不可能专门维护一套对象存储服务而本地磁盘挂载一个独立的目录操作起来最简单直接。这里也考虑过用 FastDFS или MinIO但如果单机部署这些组件的运维成本完全没必要投入。我在服务器上单独分了一个/data/resources目录按日期和资源类型做二级目录存储后面我会讲这个目录规则的具体设计。数据库方面MySQL 5.7之所以没用 MySQL 8.0是因为服务器上已经装好了 5.7而且生产环境稳定运行过很长时间没必要为这个项目冒险升级。表结构设计在后文会详细拆解。3. 核心功能模块拆解一个教学资源库具体要做什么3.1 功能地图六大核心模块结合一线教学的实际使用场景我把资源库划分成了六个核心模块模块核心功能使用角色资源管理资源上传、编辑、删除、移动分类教师、管理员资源检索按关键词、课程、类型、标签多条件搜索全部用户审核管理资源提交后由管理员审核审核通过才可被检索管理员课程关联资源与课程、课次、学习目标进行关联教师、管理员用户权限登录认证、角色权限、操作日志全部用户数据统计资源下载量、访问量、上传趋势统计管理员这个模块划分是在实际使用中一步步迭代出来的。最开始我设想的是做一个“资源超市”谁都能上传谁都能下载但发现不行没有审核机制会导致资源质量参差不齐。后来加入了审核环节又发现如果只是素材文件而没有跟课程关联老师在备课时还是不知道这节课该用什么资源于是又加了课程关联。3.2 数据库设计核心表结构与设计思路数据库设计是整个项目的基石我花了不少时间梳理表结构。核心表一共有这么几张用户表user字段类型说明idint自增主键usernamevarchar(50)用户名唯一passwordvarchar(100)BCrypt加密后的密码real_namevarchar(50)真实姓名roleint角色1管理员 2教师 3学生statusint状态1正常 0禁用create_timedatetime创建时间角色这里没有设计成用户-角色-权限三张表因为我们的角色只有三种权限矩阵很固定用一张表加一个角色字段就够了。过度设计反而会增加理解成本。资源表resource字段类型说明idint自增主键titlevarchar(200)资源标题typeint资源类型1课件 2教案 3代码 4视频 5图片category_idint所属分类IDtagsvarchar(500)标签逗号分隔file_pathvarchar(300)文件存储路径cover_pathvarchar(300)封面图路径视频、课件用file_sizebigint文件大小字节descriptiontext资源简介uploader_idint上传者IDcourse_idint关联课程IDstatusint审核状态0待审核 1通过 2拒绝view_countint浏览量download_countint下载量create_timedatetime创建时间分类表category字段类型说明idint自增主键namevarchar(50)分类名称parent_idint父分类ID0表示顶级sortint排序权重课程表course字段类型说明idint自增主键namevarchar(100)课程名称gradevarchar(50)适用年级descriptiontext课程说明这里要重点说一下目录设计。前面提到文件存储在/data/resources下我使用的规则是/data/resources/课程名/资源类型/日期_文件名。比如/data/resources/Scratch进阶/课件/20230508_第一课循环结构.pptx /data/resources/scratch进阶/代码/20230508_追光小车.sb3 /data/resources/Microbit入门/教案/20230506_心跳爱心.md这样做的好处是即使数据库不小心损坏了系统管理员在服务器上按目录结构也能找到对应文件。文件路径里拼接文件名之前一定要过滤掉用户传入文件名中的特殊字符否则路径穿越漏洞很容易被利用。3.3 用户角色与权限边界三种角色对应的权限边界我用人话梳理一下管理员可以审核任何人的资源可以管理所有分类和课程可以查看全站数据统计可以禁用账号可以删除任意资源。教师可以上传资源、编辑自己的资源、申请资源关联课程可以查看所有已经审核通过的资源并下载。学生只能在资源广场中查看已审核通过的资源可以下载部分开放下载的资源比如课堂演示文稿、作业要求不能上传。权限在后端通过 Spring MVC 的拦截器实现。前端只是做菜单展示控制真正要卡的还是后端接口这个细节在后文会展开。4. 后端实现细节SSM 里的核心代码与配置4.1 统一响应体与全局异常处理前后端联调时最容易扯皮的就是数据格式不一致。后端同学今天返回一个{code: 200, data: null}明天返回一个{success: true, msg: ok}前端同学迟早要崩溃。所以我在项目一开始就定了一个统一的响应封装public class ResultT { private Integer code; // 200成功400参数错误401未登录403无权限500系统异常 private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.code 200; result.message success; result.data data; return result; } public static T ResultT error(Integer code, String message) { ResultT result new Result(); result.code code; result.message message; return result; } }配套的全局异常处理做一个ControllerAdvice把业务异常和系统异常统一拦截前端只需要判断code是否为 200 即可进入正常流程。4.2 登录认证与接口鉴权拦截器的正确写法用户登录成功后后端生成一个 UUID 作为 token存到 Redis 并设置过期时间这里 Redis 只做 token 存储不承担业务数据缓存。前端每次请求在Authorization头中携带 token后端拦截器统一校验。public class LoginInterceptor implements HandlerInterceptor { Autowired private RedisTemplateString, String redisTemplate; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException { // 放行登录接口 if (request.getRequestURI().contains(/user/login)) { return true; } String token request.getHeader(Authorization); if (StringUtils.isEmpty(token)) { writeUnauthorized(response); return false; } String username redisTemplate.opsForValue().get(token); if (StringUtils.isEmpty(username)) { writeUnauthorized(response); return false; } // 将用户名存入Request作用域供Controller直接使用 request.setAttribute(username, username); return true; } }这里有一个很容易踩的坑如果写了拦截器但忘了在 Spring MVC 配置中注册那么一切校验都不会生效。我刚开始就是这个坑排查了很久才发现spring-mvc.xml里的mvc:interceptors配置根本没把拦截器加进去。另一个坑是放行规则。前端静态资源比如课件预览时需要用到的 PDF 文件本来不需要登录也能访问如果拦截器拦了所有路径会导致预览组件拿不到文件。我在拦截器里做了两件事放行/api/login、放行/resources/**文件访问路径其他接口全部要 token。对于需要特定角色的接口我没有再写第二个拦截器而是在 Controller 方法上自定义了一个RequireRole(value {admin})注解用一个 AOP 切面去判断当前用户的角色。这样做的好处是权限控制在代码上清晰可见不用翻配置文件。4.3 文件上传的实现细节与防坑指南文件上传用 MultipartFile 接收这块逻辑本身不复杂但有几个细节极其关键。RequestMapping(/upload) public ResultString upload(RequestParam(file) MultipartFile file, RequestParam(courseName) String courseName, RequestParam(resourceType) Integer type) { // 1. 校验文件大小超出限制直接拒绝 if (file.getSize() 200 * 1024 * 1024) { return Result.error(400, 文件大小不能超过200MB); } // 2. 获取原始文件名并清理特殊字符 String originalFilename file.getOriginalFilename(); String safeFilename originalFilename.replaceAll([\\\\/:*?\|], _); // 3. 按日期归档目录 String dateStr new SimpleDateFormat(yyyyMMdd).format(new Date()); String resourceTypeDir getTypeDir(type); String dirPath /data/resources/ courseName / resourceTypeDir / dateStr; File destDir new File(dirPath); if (!destDir.exists()) { destDir.mkdirs(); } // 4. 重命名文件名避免重名覆盖 String ext safeFilename.substring(safeFilename.lastIndexOf(.)); String newFilename UUID.randomUUID().toString().replace(-, ) ext; File dest new File(dirPath / newFilename); file.transferTo(dest); // 5. 数据库记录元数据 // ...省略 return Result.success(dest.getAbsolutePath()); }第一个教训是transferTo 方法的目标目录必须先存在否则会抛 IOException。我之前以为它会自动创建不存在的目录结果并不是这样。第二个教训是不要直接用用户上传的文件名保存到磁盘。一来可能导致文件重名互相覆盖二来文件名里的特殊字符可能带来安全隐患。统一用 UUID 重命名原始文件名作为 metadata 存进数据库展示层从数据库读取。第三个教训是视频类的大文件上传。直接用 MultipartFile 接收Tomcat 默认的单次请求大小限制会拦住。我遇到的报错是The request was rejected because its size exceeds the configured maximum。最后在配置文件里调大限制bean idmultipartResolver classorg.springframework.web.multipart.commons.CommonsMultipartResolver property namemaxUploadSize value209715200/ property namemaxInMemorySize value1048576/ /bean同时也要调 Tomcat 的maxPostSize否则换了配置也白搭。大文件如果超过 500MB单次请求上传的体验非常差断点续传、分片上传这些才是正解。但目前团队的应用场景里视频素材大多控制在 200MB 以内直接上传还扛得住。需要说明的是这个方案适用于内部网络环境如果带宽有限仍然建议做分片上传。5. 业务逻辑中的“硬核”部分审核流转、多条件检索、资源预览5.1 资源审核一个简单却严谨的状态机资源的审核流程是一个典型的状态流转场景我专门设计了状态枚举public enum ResourceStatus { PENDING(0, 待审核), APPROVED(1, 已通过), REJECTED(2, 已拒绝); private final int code; private final String description; ResourceStatus(int code, String description) { this.code code; this.description description; } }状态流转规则很明确待审核才能被审核为通过或拒绝已通过要修改需要重新进入待审核已拒绝的可以修改后重新提交。我没有引入复杂的状态机框架而是在 Service 层用 if 判断规则反而清晰可靠public void review(Integer resourceId, Integer status, String reviewComment) { Resource resource resourceMapper.selectByPrimaryKey(resourceId); if (resource null) { throw new BusinessException(资源不存在); } if (resource.getStatus() ! ResourceStatus.PENDING.getCode()) { throw new BusinessException(只有待审核的资源可以进行审核操作); } // 更新状态和审核意见 resource.setStatus(status); resource.setReviewComment(reviewComment); resource.setReviewTime(new Date()); resourceMapper.updateByPrimaryKeySelective(resource); }这里有个设计决策值得说明拒绝的时候必须填写审核意见通过则可以不填。审核意见会推送给上传资源的老师老师根据意见修改后重新提交。这个闭环流程在真实使用时非常有效比我最初设想的“只给一个通过/不通过按钮”要好得多。5.2 多条件检索MyBatis 动态 SQL 全场最佳资源检索是整个系统中使用频率最高的功能没有之一。老师打开资源库默认看到的是一张资源列表然后通过筛选条件缩小范围。筛选条件组合起来五花八门有的按课程过滤有的按资源类型过滤有的输入一个关键词有的同时选课程加类型加标签。这种多条件组合查询MyBatis 的动态 SQL 处理起来非常顺手select idsearchResources resultTypeResourceVO SELECT r.*, c.name AS category_name, u.real_name AS uploader_name FROM resource r LEFT JOIN category c ON r.category_id c.id LEFT JOIN user u ON r.uploader_id u.id where if teststatus ! null AND r.status #{status} /if if testcourseId ! null and courseId ! 0 AND r.course_id #{courseId} /if if testtype ! null and type ! 0 AND r.type #{type} /if if testkeyword ! null and keyword ! AND (r.title LIKE CONCAT(%, #{keyword}, %) OR r.tags LIKE CONCAT(%, #{keyword}, %) OR r.description LIKE CONCAT(%, #{keyword}, %)) /if /where ORDER BY r.create_time DESC /select注意一个问题学生端只能看到通过的资源所以服务端默认强制加上status 1的条件前端传什么条件都不覆盖服务端设置的默认值。这个校验一定要放在 Service 层而不是依赖前端的控件隐藏否则学生直接改请求参数就能看到未通过审核的内容。关于 LIKE 搜索的性能资源量到了一定规模后全表 LIKE 肯定是慢的。目前 MySQL 索引对前缀模糊查询有效但%关键词%这种中缀匹配是没法走索引的。我的做法是控制匹配字段的范围标题、标签、简介三个字段足够覆盖绝大多数搜索场景同时加上上传时间范围筛选减少查询范围。之前试过用 MySQL 的 FULLTEXTngram 分词来做中文全文索引效果不错但在 MySQL 5.7 上对中文分词的配置和维护比较繁琐后面再考虑。5.3 资源预览几个轻量方案的真实体验资源在线预览有很多种方案我按资源类型做了区分PPT/PDF 类转成图片或者直接用 PDF.js 在网页端预览。如果是课件 PPT我实际采用的是用 LibreOffice 转 PDF 再嵌入浏览器预览的方式。命令行操作很简单libreoffice --headless --convert-to pdf --outdir /data/convert/ /data/resources/xxx.pptx视频类直接用 HTML5 的video标签播放。要注意服务器返回视频响应时需要正确设置Content-Type否则浏览器可能无法识别视频格式。更重要的是HTML5 video 默认是持续请求整个文件的如果文件很大需要支持 HTTP Range 请求才能拖动进度条。Spring 框架返回资源文件时默认支持 Range 请求这个点倒是没有额外处理。代码类用 code-prettify 插件在网页端高亮显示文本内容直接读取文件返回前端即可。图片类最简单直接img标签展示。在线预览最大的问题不是技术而是权限。如果资源文件路径直接暴露知道 URL 的人就能直接访问绕过整个鉴权系统。这里我做了两层保障第一文件的静态资源目录设置了不经过拦截器的放行路径第二放行路径对应的目录只放转换后的预览文件原始文件全部存放在未开放目录/data/private/里需要下载时走带 token 校验的下载接口。这个设计经过实际测试是可靠的但要注意权限控制粒度只能做到“登录用户可见就能预览”做不到“只能预览某一课的资源”这种细粒度。对于内部资源库这个粒度已经足够了。6. 前端实现思路与前后端联调心得6.1 权限路由与菜单控制前端路由按角色做动态配置。用户登录后后端返回当前用户角色前端根据角色决定哪些菜单可见、哪些路由可访问。路由配置里我用了一个简单方案在 router 的beforeEach钩子里做判断router.beforeEach((to, from, next) { const token localStorage.getItem(token); if (!token to.path ! /login) { return next(/login); } if (token to.path /login) { return next(/); } const role store.getters.role; if (to.meta.roles to.meta.roles.indexOf(role) -1) { return next(/403); } next(); });当然这只是控制页面入口真正的数据安全还是靠后端接口的鉴权。前端控制路由只是为了让普通用户看不到“审核管理”这个入口减少无效请求。6.2 axios 封装与 token 统一处理我用 axios 实例统一封装了 HTTP 请求import axios from axios; const service axios.create({ baseURL: /api, timeout: 15000 }); service.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers[Authorization] token; } return config; }); service.interceptors.response.use( response { const res response.data; if (res.code 200) { return res; } if (res.code 401) { localStorage.removeItem(token); window.location.href /login; } return Promise.reject(new Error(res.message)); }, error { ElMessage.error(网络异常请稍后重试); return Promise.reject(error); } );这里有一个细节请求后端的地址配置了/api前缀同时通过前端 devServer 的 proxy 把/api转发到后端端口devServer: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这样前端编码的时候直接用相对路径不用关心后端部署的 IP 和端口后续上线到服务器只要改 nginx 的代理配置就行前端代码不用动。6.3 文件上传组件与进度条Element UI 自带的el-upload组件已经封装了上传能力我选择自定义上传方式方便控制上传时的额外参数el-upload action# :http-requesthandleUpload :before-uploadbeforeUpload :file-listfileList el-button选择文件/el-button /el-upload对应的 JS 部分handleUpload(options) { const formData new FormData(); formData.append(file, options.file); formData.append(courseName, this.courseName); formData.append(resourceType, this.resourceType); service.post(/resource/upload, formData, { onUploadProgress: (e) { if (e.total) { const percent Math.round(e.loaded / e.total * 100); options.onProgress({ percent }); } } }).then(res { if (res.code 200) { ElMessage.success(上传成功); options.onSuccess(res); } else { ElMessage.error(res.message); options.onError(res); } }).catch(() { options.onError(new Error(上传失败)); }); }上传时还有两个注意点一个是上传过程要锁定提交按钮防止用户重复点击导致同一文件重复上传另一个是上传之后立刻刷新路径回显让用户直观感受到文件已经传好并进入了待审核状态。7. 从部署到实际使用性能调优与踩坑记录7.1 部署架构最终部署在一台 4核8G 的云服务器上单机跑 Nginx Tomcat MySQL Redis。Nginx 负责前端静态文件服务和反向代理server { listen 80; server_name resource.example.com; location / { root /opt/frontend/dist; 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; } }前端用 Nginx 托管后端接口用反向代理转发避免跨域问题。这个方案在我实际部署中非常稳定比之前直接用 Tomcat 托管前端静态文件要省事很多。7.2 上线初期最严重的两个问题第一个问题是视频预览时的内存飙升。有老师反馈上传了一个近 150MB 的 mp4播放的时候访问速度很慢服务器内存还飚了起来。排查之后发现Nginx 代理转发视频文件时默认读取到内存再转发大文件会导致内存快速膨胀。修复方式是直接不代理视频文件的访问路径视频存放在单独的文件服务器路径下独立走一个静态目录location /static/video/ { alias /data/resources/; directio 4m; output_buffers 1 128k; }加上directio让 Nginx 直接绕过内存缓冲读取磁盘文件性能立刻改观。第二个问题是历史文件迁移时目录规则不统一。最初我只按资源类型归档后来加了课程维度之后老文件就挂在根目录下新文件按课程分类放。结果检索时数据都有去磁盘上找文件时却要两边翻。后来干脆写了一个迁移脚本把所有老文件按新规则复制到对应课程目录再删除旧文件彻底统一了目录。7.3 数据库层面的几个小坑大字段的description在资源列表面临性能问题。列表接口一开始连 description 也查出来返回页面渲染时数据量大接口响应慢了不少。后来把列表接口的 SQL 改为不查 description只有点击资源详情时才查全字段列表接口的速度明显提升。下载量统计我用了一个简单办法排行榜接口和列表接口都查download_count排序。随着数据量增大这个字段可以考虑做成一个单独的日统计表但目前的访问频率完全够用。一个经典的 MySQL 坑datetime字段查询时用了字符串比较导致索引失效。后来全部改用 JDBC 参数传递Date对象避免隐式类型转换。8. 复盘与扩展这个资库库未来还能做什么说实话这个项目最大的收获不是代码量有多少而是让我理解了”教学资源管理”和”普通文件管理”的本质差异。普通文件管理只要解决存取问题教学资源库的核心不是“存”而是“用”——让正确的人、在正确的场景、快速找到正确的资源并且能沉淀出使用数据反哺教学。目前系统里一个明显的短板是推荐功能。现在只是基于标签和分类的规则匹配还没有针对资源下载、浏览行为做个性化推荐的学习。如果后续要扩展有两个方向一是结合学生的学习数据做智能推荐。比如学生在某个课程节点频繁下载某类资源系统可以推送相似的补充材料。这个方向技术上不难做核心是行为埋点数据要积累够。二是资源版权与版本管理。教学资源更新迭代非常快同一个课件可能一学期改好几版。目前系统是直接覆盖旧文件没有版本记录。下一步可以考虑引入版本管理逻辑保留历史版本这样即使新版有问题也可以快速回退。另外对上传文件的格式校验还可以加强。现在只做了扩展名白名单校验但某些文件重命名后扩展名可能被伪造下一步要针对文件头做魔数校验确保上传的文件格式是真实的而非改个后缀名冒充的。最后说几句真实的体会整个项目从最开始的信息整理到上线稳定运行前后大概花了两个月的时间其中大部分时间都花在了业务细节打磨上。做这种内部系统最大的心得就是一定要贴近实际使用场景——你觉得自己设计了一个完美的审核流程真实使用时老师们可能更关心的是“能不能一键关联到我的课程”你觉得资源分类越细越好实际用户更喜欢直接用搜索而不是去翻分类。上线之后多跟使用者聊聊你才能知道系统哪里真的好用、哪里纯粹是工程师思维的一厢情愿。如果这个项目能给正在做类似系统的同学一个参考我建议你把精力优先放在检索速度和上传体验上这是资源库最基本的体验底线。审核、权限这些功能可以后面迭代但上传卡顿和搜索慢一次就能让用户失去信任。

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

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

免费获取报价 →
↑