SpringBoot+Vue+uniapp开发急救常识学习小程序全流程实践
简介基于小程序与SpringBootVue的急救常识学习系统是一套面向毕业设计、课程设计或前后端分离初学者的完整工程。项目以SpringBoot提供后端接口Vue与uniapp构建管理端及小程序端MySQL存储急救知识数据整体采用前后端分离架构可通过SQL脚本完成数据库初始化并部署运行。压缩包共1334个文件约11.95MB主要涉及Java源码、Vue组件、小程序wxml/wxss页面、JS逻辑脚本、JSON配置、图片素材及数据库SQL文件目录按前端、后端、小程序等模块划分可对照查看接口调用与数据流。目前已有75人学习下载适合需要快速搭建急救常识学习系统并做二次开发的读者。资源内提供可运行源码、SQL脚本和配套文档可支撑从环境配置、接口调试到页面联调的完整实践便于学习SpringBoot与uniapp的结合方式及项目改造思路对理解前后端分离项目结构和跨端开发流程有直接参考价值。1. 基于小程序做急救常识学习SpringBoot Vue uniapp 这套组合到底怎么分工急救常识这类内容天然适合做成学习系统知识点短、形式固定图文、视频、题库又需要记录用户学了什么、练得怎么样。但很多初接触的开发者容易把它想成“做一个文章展示小程序”于是把题库、记录、统计全塞进前端后端只留一个用户登录。这样做 demo 可以真到上线就会发现权限、数据回溯、多端复用得全部重来。这个标题里出现的 SpringBoot、Vue、uniapp 三件套指向的是一个更常见的生产结构SpringBoot 提供业务接口与数据持久化Vue 负责后台管理页面uniapp 负责面向用户的小程序端。三者的边界如果切对后续加功能、换端口都只是增量工作。这套技术选型适合谁适合已经能独立写接口和页面的后端或全栈工程师想用一套代码同时覆盖微信小程序、H5 甚至 App 端又不想给学习类业务引入过重的微服务架构。文章会顺着“数据模型 → 后端接口 → 小程序端实现 → 部署验证”这条完整链路走一遍把参数设计和排错点都放在可执行的位置上。你不需要依赖任何付费组件纯开源工具就能把它跑起来。2. SpringBoot 端先立数据模型急救常识的内容结构不是一张表能搞定的学习系统的核心不是“展示”而是“学习闭环”用户看内容、做练习、留下记录。所以后端设计的第一步是认清业务对象而不是急着写 Controller。2.1 急救常识学习系统的实体划分与表结构设计常见的误区是建一张article表字段放标题、正文、分类然后就没有然后了。实际至少要拆成四类实体知识点急救常识条目题库选择题绑定知识点用户学习记录看过哪个知识点、最后一次学习时间练习记录答对答错、得分知识点和题库都要有“分类”字段。急救常识按场景分心肺复苏、止血包扎、烧伤烫伤、异物卡喉、中暑溺水等。分类不建议单独建表用category_code字符串即可理由是这个分类层级浅、数量少建表反而多一次 JOIN。一张核心表参考结构如下CREATE TABLE firstaid_knowledge ( id bigint(20) NOT NULL AUTO_INCREMENT, title varchar(200) NOT NULL COMMENT 标题, category_code varchar(50) NOT NULL COMMENT 分类编码如 cpr、bleeding, content text NOT NULL COMMENT 正文支持富文本或 markdown, video_url varchar(500) DEFAULT NULL COMMENT 视频链接存相对路径或第三方播放地址, cover_url varchar(500) DEFAULT NULL COMMENT 封面图, difficulty tinyint(4) DEFAULT 1 COMMENT 难度1入门 2进阶 3专业, view_count int(11) DEFAULT 0 COMMENT 浏览量, status tinyint(4) DEFAULT 1 COMMENT 1上架 0下架, sort_order int(11) DEFAULT 0 COMMENT 排序, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_category (category_code), KEY idx_status_sort (status, sort_order) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT急救常识知识点表;题库表的差异点是必须有knowledge_id外键和analysis字段。analysis是答案解析很多系统把解析直接放前端这会导致用户抓包后能看到所有题目的答案。后端返回题目时只返回题干和选项不返回答案提交后再判断并返回解析这个细节决定了练习功能是否合规。用户学习记录表建议用联合主键(user_id, knowledge_id)记录最后学习位置。如果之后要做“继续学习”功能还需要冗余一个last_position视频播放秒数或文章滚动比例。这两个字段在初期容易漏后续补字段虽然不难但要处理存量数据成本比一开始加上高得多。2.2 用 SpringBoot 把领域服务搭起来的基建选择项目骨架用 Spring Initializr 生成即可依赖选spring-boot-starter-web、mybatis-plus、mysql-connector-java、lombok。这里核心选择是 MyBatis-Plus 而不是 JPA——原因有两条学习系统里单表 CRUD 占绝大多数知识点增删改查、题库维护MyBatis-Plus 的BaseMapper几乎不用写 SQL。项目交接和招聘时国内团队对 MyBatis 系更熟悉自定义 SQL 排查更直观。实体类用TableName注解映射表名字段上加TableId(type IdType.AUTO)。注意content这种大字段在 MyBatis-Plus 查询时默认会查出来列表页不需要正文可以用LambdaQueryWrapper.select()排除大字段减少网络传输和内存占用。这是一个容易忽略的点但是列表接口响应变慢的常见原因。项目分包按controller / service / mapper / entity / common展开。common里放统一响应体RT、异常处理、分页参数。统一响应体建议直接给固定格式public class RT { private Integer code; private String msg; private T data; public static T RT ok(T data) { ... } public static T RT fail(String msg) { ... } }这个小类算是个“傻瓜式”设计但实用价值极高。后端任何接口都返回这个结构前端 axios 拦截器统一判断code字段省掉大量重复的异常分支。为什么不直接用 HTTP 状态码因为小程序端的网络请求库对非 2xx 的处理会自动走到 fail 回调但很多系统会把业务错误参数不对、内容下架也映射成 4xx这会导致前端无法区分“请求失败”和“业务失败”而用业务 code 能把两者彻底分开。2.3 小程序端和后端接口的字段对齐别用驼峰直接连数据库SpringBoot 后端默认返回驼峰命名如viewCountuniapp 端 JS 也习惯用驼峰这段天然一致。但有两处必须做转换数据库下划线字段 → 实体类驼峰属性MyBatis-Plus 默认开启map-underscore-to-camel-case不用额外配。实体类布尔字段不要用is开头命名。比如isHot会被框架识别为hot造成局部 bug 且肉眼极难发现。建议改成hotFlag或status 1来标记。写接口时另一个常见坑是时间格式。MySQLdatetime返回给 Jackson 序列化后默认是一长串时间戳小程序端拿到后要自己格式化。推荐直接加配置spring.jackson.date-formatyyyy-MM-dd HH:mm:ss spring.jackson.time-zoneGMT8这样前端直接用字符串渲染即可。如果后续有“距离上次学习已过 N 天”这类计算前端拿到字符串后用new Date()解析即可无兼容问题。3. 后端接口实现知识点列表、详情、练习提交三条核心链路的写法接口设计不追求多追求链路完整。学习系统最核心的接口就三个列表含分类筛选与分页、详情计数 记录学习行为、提交练习判分 返回解析。把这三个写稳其余功能都是增补。3.1 知识点列表接口分类筛选、分页与浏览计数的实现Controller 层用RestControllerRequestMapping(/api/knowledge)。列表接口接收四个参数pageNum、pageSize、categoryCode、keyword。keyword支持标题模糊搜索是刚需用户可能记不清完整标题只记得“心”“压”等关键词。GetMapping(/list) public RPageKnowledgeVO list( RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 10) Integer pageSize, RequestParam(required false) String categoryCode, RequestParam(required false) String keyword) { LambdaQueryWrapperKnowledge wrapper new LambdaQueryWrapper(); wrapper.eq(StringUtils.isNotBlank(categoryCode), Knowledge::getCategoryCode, categoryCode) .like(StringUtils.isNotBlank(keyword), Knowledge::getTitle, keyword) .eq(Knowledge::getStatus, 1) .orderByAsc(Knowledge::getSortOrder) .orderByDesc(Knowledge::getCreateTime); wrapper.select(Knowledge.class, i - !content.equals(i.getColumn()) !videoUrl.equals(i.getColumn())); PageKnowledge page knowledgeService.page(new Page(pageNum, pageSize), wrapper); return R.ok(page); }这段代码的要点在wrapper.select那行列表页只需要标题、封面、分类、难度、浏览量不需要正文和视频地址。排除大字段后接口响应体从几 KB 降到了几百字节列表滑动加载明显变快。eq和like方法的第一个参数是布尔条件条件为true才拼 SQL这是 MyBatis-Plus 条件构造器的核心用法。orderByAsc(Knowledge::getSortOrder)保证运营手动排序生效orderByDesc(Knowledge::getCreateTime)作为次级排序。请求参数那行RequestParam(defaultValue 1)是必需的前端如果漏传参数后端不至于报 500。在接口文档中要写明 pageNum 从 1 开始很多前端同学会下意识以为是 0 开始调通之前会浪费一次沟通成本。3.2 详情与学习记录的写入时机详情接口返回完整内容同时需要做两件事浏览量加一、写入/更新学习记录。有些后台系统会用 Redis 做计数器异步落库这里不过度设计直接同步完成即可单机每秒几百次点击完全扛得住。GetMapping(/detail/{id}) public RKnowledgeVO detail(PathVariable Long id, RequestParam Long userId) { Knowledge knowledge knowledgeService.getById(id); if (knowledge null || knowledge.getStatus() ! 1) { return R.fail(内容不存在或已下架); } // 浏览量 1并发下使用 setSql 原子更新 LambdaUpdateWrapperKnowledge updateWrapper new LambdaUpdateWrapper(); updateWrapper.eq(Knowledge::getId, id) .setSql(view_count view_count 1); knowledgeService.update(updateWrapper); // 记录学习行为 LearningRecord record new LearningRecord(); record.setUserId(userId); record.setKnowledgeId(id); record.setLastTime(new Date()); record.setUserId(userId); learningRecordService.saveOrUpdate(record, new LambdaQueryWrapperLearningRecord() .eq(LearningRecord::getUserId, userId) .eq(LearningRecord::getKnowledgeId, id)); return R.ok(convertToVO(knowledge)); }setSql(view_count view_count 1)是原子自增的关键。如果先查出来getViewCount() 1再 update两个并发请求会互相覆盖浏览量会偏小。saveOrUpdate的第一个参数是实体对象第二个参数是查询条件满足条件就更新否则插入恰好匹配“学习记录存在则更新时间不存在则新增”的场景。注意这里userId直接从参数传进来的做法在真实项目中应该从 JWT Token 获取。本文不展开权限设计但至少要明白小程序端传什么userId都信是生产事故的温床。可以在HandlerInterceptor里统一从请求头token解析用户Controller方法参数里只留RequestParam的knowledgeId。3.3 练习提交接口前端不拿答案后端批量判分练习题目的核心矛盾是提交时要一次性提交一组答案而不是一题一题提交。一次十题用户做完后点“交卷”前端把[{questionId, answer}]数组 POST 给后端。后端循环判分统计得分率保存练习记录返回结果列表题干、用户答案、正确答案、解析。PostMapping(/practice/submit) public RPracticeResultVO submit(RequestBody PracticeSubmitDTO dto) { ListQuestion questions questionService.listByIds( dto.getAnswers().stream().map(PracticeAnswerDTO::getQuestionId).collect(Collectors.toList())); int correctCount 0; ListQuestionResultVO results new ArrayList(); for (Question q : questions) { String userAnswer dto.getAnswers().stream() .filter(a - a.getQuestionId().equals(q.getId())) .findFirst() .map(PracticeAnswerDTO::getAnswer) .orElse(); boolean correct q.getCorrectAnswer().equals(userAnswer); if (correct) correctCount; QuestionResultVO vo new QuestionResultVO(); vo.setQuestionId(q.getId()); vo.setStem(q.getStem()); vo.setOptions(q.getOptions()); vo.setUserAnswer(userAnswer); vo.setCorrectAnswer(q.getCorrectAnswer()); vo.setAnalysis(q.getAnalysis()); vo.setCorrect(correct); results.add(vo); } // 保存练习记录总分、正确率、耗时 PracticeRecord record new PracticeRecord(); record.setUserId(dto.getUserId()); record.setTotalCount(questions.size()); record.setCorrectCount(correctCount); record.setScore(correctCount * 100 / questions.size()); record.setDurationSeconds(dto.getDurationSeconds()); practiceRecordService.save(record); PracticeResultVO result new PracticeResultVO(); result.setScore(correctCount * 100 / questions.size()); result.setResults(results); return R.ok(result); }correctCount * 100 / questions.size()这里先乘后除避免整数除法直接把小数抹掉。例如 3 题对 2 题2 * 100 / 3 66而100 / 3 * 2 0。这是后端开发最常见的低级但隐蔽的 bug加一段注释防未来接手的人改错。PracticeSubmitDTO里必须有NotNull校验Validated注解加在接口参数上内容为空时直接返回 400。练习记录只需要存储分数和题目数量不要逐个存用户答案否则数据量会爆炸。想看某次练习具体错了哪题可以在返回结果里让前端单次展示历史记录只存分数。4. uniapp 小程序端从页面结构到请求封装、学习进度条与视频兼容小程序端是用户直接接触的部分开发体验和微信平台限制是绕不开的话题。uniapp 的价值在于一份代码编译到微信小程序、H5、App。但“一套代码跑三端”是有代价的最典型的就是视频组件差异和下钻场景的条件编译。4.1 项目初始化和请求拦截器的最小可运行配置用 HBuilderX 创建 uni-app 项目选择 Vue 3 版本。模板选默认空模板不要选带 UI 库的模板原因有二后续可能用 uview-plus 或 nut-ui预置模板反而有冗余部分模板自带的地图、登录等示例会让新人误以为平台有内置完整逻辑。请求封装用uni.request包一层 Promise。小程序端没有 axios但可以封装出类似体验// utils/request.js const BASE_URL https://api.example.com export function request(options) { return new Promise((resolve, reject) { uni.request({ url: BASE_URL options.url, method: options.method || GET, data: options.data || {}, header: { Content-Type: application/json }, success: (res) { if (res.data.code 200) { resolve(res.data.data) } else { uni.showToast({ title: res.data.msg, icon: none }) reject(res.data) } }, fail: (err) { uni.showToast({ title: 网络异常请稍后重试, icon: none }) reject(err) } }) }) }BASE_URL一定要用 HTTPS 且在微信公众平台配置合法域名开发调试时可以临时勾选“不校验合法域名”但上线前必须配好。把BASE_URL抽成单独常量文件而不是写死在每个页面因为测试环境和生产环境要切换改动一处即可生效。uni.showToast是全局统一错误提示统一在拦截器里做页面层不再重复写 loading 和错误弹窗代码量会大幅度降低。4.2 首页分类导航与知识点列表页的生命周期细节小程序首页布局通常是顶部搜索框、分类横向滚动、下方知识点瀑布流或列表。分类切换和数据加载要处理“重复请求”与“下拉刷新”两个场景。分类数据结构不用频繁请求接口可以在onLoad时一次性拉取并缓存到globalData之后切 tab 直接本地过滤避免每次切换都转圈。列表数据要分页onReachBottom里判断pageNum * pageSize total才能继续请求下一页否则提示“没有更多了”。这个判断防不了高并发但对单用户操作足够。列表项的关键数据是view_count它构成用户的选择偏好——“看的人多”会被理解为“内容靠谱”。列表项 UI 右下角显示阅读量格式化函数是常见小工具export function formatCount(count) { if (count 10000) { return (count / 10000).toFixed(1) 万 } return String(count) }4.3 学习记录与进度显示的落地做法“学习进度”是这个系统区别于普通文章站的功能。学习进度有两种实现粒度粗粒度用户点开详情页就记录“已学习”适合纯图文内容。细粒度记录阅读位置或视频播放位置适合长文和视频。急救常识正文一般 800 字以内用粗粒度即可。但用户需要在小程序首页看到一个“继续学习”入口数据来源于学习记录表里last_time最新的记录。列表接口GetMapping(/learning/recent) public RListLearningRecordVO recent(RequestParam Long userId) { ListLearningRecord records learningRecordService.list( new LambdaQueryWrapperLearningRecord() .eq(LearningRecord::getUserId, userId) .orderByDesc(LearningRecord::getLastTime) .last(limit 5)); // 联表查知识点标题、封面 ... }这里last(limit 5)是 MyBatis-Plus 直接拼接 SQL 尾部的方法只传常量不传用户输入没有注入风险。联表建议手动写一个LearningRecordMapper.xml里的selectRecentWithKnowledge方法涉及多表查询时不要用QueryWrapper拼 JOIN可读性差而且不好维护。“继续学习”这个入口是提高留存的小功能成本低但用户感知强。它相当于把被动浏览变成了主动延续用户不用翻记录去找上次看的条目。4.4 小程序端视频播放的兼容问题急救常识经常会配教学视频比如心肺复苏的正确手势。视频格式建议用 MP4H.264 编码微信小程序原生video组件直接支持。如果视频源是 m3u8HLS微信小程序并不直接支持播放需要额外处理这是最常见的“开发时好好的、真机一测黑的”场景。推荐的做法后端转码后提供 MP4 地址或使用支持 HLS 的第三方播放器插件。小型系统选第一条路更省事。但要注意视频文件不要放服务器本地磁盘应该传到对象存储阿里云 OSS、腾讯云 COS否则带宽会成为瓶颈。视频地址要加签名防盗链否则会被外部站点直接引用。对象存储一般支持 URL 签名有效期设置 30 分钟即可。小程序端video组件的src属性用:src动态绑定页面onLoad时拿到的地址才是最终地址不能写在模板字符串里硬编码。video v-ifdetail.videoUrl :srcvideoSrc controls errorhandleVideoError stylewidth: 100%; height: 200px; /videoexport default { data() { return { detail: {}, videoSrc: } }, onLoad(options) { this.loadDetail(options.id) }, methods: { async loadDetail(id) { const data await request({ url: /knowledge/detail/${id}, data: { userId: this.userId } }) this.detail data this.videoSrc data.videoUrl }, handleVideoError() { uni.showToast({ title: 视频加载失败, icon: none }) } } }v-ifdetail.videoUrl保证没有视频时组件不渲染否则会出现一个黑框占位。有些开发会忘记清理videoSrc导致切换知识点时旧视频一闪而过。在onLoad里赋值就没有这个问题因为每次进入页面都是新实例。5. Vue 管理后台与 SpringBoot 项目的目录协同管理后台用 Vue 3 Element Plus通过 Vite 构建。它解决的是素材维护问题运营人员录入急救常识内容、上传图片和视频、管理题库。这个后台不直接面对 C 端用户所以界面可以尽量朴素但功能一个不能少。5.1 管理后台“内容录入”页面的关键交互急救常识的后台管理页面文章编辑器是最复杂的部分。运营录入的正文要支持图文混排建议直接用v-html渲染富文本。富文本编辑器选 wangEditor 5 或 quill两者都是纯前端开源方案无授权问题。上传图片用el-upload组件的自定义上传方法走后端/api/upload接口返回图片 URL 后插入编辑区。el-upload :actionuploadUrl :headers{ token: getToken() } :on-successhandleUploadSuccess namefile el-button sizesmall上传图片/el-button /el-upload后端upload接口的核心逻辑是保存文件并返回访问路径。关键参数是文件大小上限、允许的扩展名集合。建议限制为 jpg/png/gif/webp单张不超过 5MB否则存储压力大且前端加载慢。getToken()方法从localStorage里取管理员登录后的 token。前后端分离下文件上传接口也需要鉴权不能裸奔。handleUploadSuccess回调里拿到后端返回的 URL插入富文本编辑器的当前位置。这一步如果做不好运营每次都要先传图再手动拼链接录入效率会降低一半。5.2 管理端路由权限与 Vue 动态添加路由的实现管理端有“管理员”和“运营编辑”两种角色管理员能删数据、配置系统参数运营只能维护内容。路由权限用 Vue Router 的beforeEach全局守卫 动态addRoute实现。router.beforeEach((to, from, next) { const token localStorage.getItem(admin_token) if (!token to.path ! /login) { next(/login) } else { next() } })动态权限在登录后拉取用户角色根据角色过滤路由表。新手容易跳过这块直接写死全部路由开发很爽但上线会出安全问题运营直接改 URL 就能进入系统管理页。虽然按钮不显示但路由注册了就能访问这种漏洞属于“开发模式惯性”留下的隐患。最小可行做法是写入路由表的meta.roles字段守卫里比对不匹配就next(/403)。5.3 SpringBoot 后端与 Vue 前端在跨域与代理上的历史遗留问题本地开发时 Vue 跑http://localhost:5173后端跑http://localhost:8080直接请求必然跨域。常见做法是 Vite 配置开发代理// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })开发环境用代理后前端请求/api/list会被转发到8080/api/list浏览器的地址栏还是5173不存在跨域问题。生产环境 Nginx 也要做同样代理前后端部署在同一个域下。这样后端就不用配CrossOrigin了CrossOrigin是临时的配了它意味着线上会暴露允许跨域的来源安全审计时会被扣分。6. 从压缩包到上线的最后三件事编译产物、环境配置与验证清单项目的最终交付形态是.zip压缩包里面应该是三个子项目SpringBoot 后端、Vue 管理后台、uniapp 前端。拿到项目后不急着改代码先按顺序做三件事检查配置、初始化数据库、跑最小验证。6.1 数据库初始化与 three 端配置核对数据库脚本文件一般放在压缩包的sql/目录下。用 Navicat 或命令行导入后先检查三张核心表是否有数据。很多时候压缩包带的是完整 SQL导入后就有初始题库但视频和图片 URL 是作者本机路径如localhost:8080/files必须全局排查替换成你自己的服务器地址。用编辑器全局搜索localhost把这个值全量替换比在数据库里逐条改快得多也不容易漏。后端配置集中在application.yml以下三个配置是上线的硬门槛spring: datasource: url: jdbc:mysql://localhost:3306/first_aid?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password servlet: multipart: max-file-size: 10MB max-request-size: 20MBserverTimezoneAsia/Shanghai不配MySQL 8 驱动会报时区错误。max-file-size和max-request-size跟随你的上传限制如果后台限制图片 5MB那这里至少 10MB因为 base64 上传会导致体积膨胀虽然这里用的是 multipart 表单但请求总大小仍要预留余量。6.2 uniapp 小程序打包与发布检查uniapp 怎么打包是这个项目被搜索最多的问题具体操作路径是HBuilderX 菜单栏“发行”→“小程序-微信”输入微信小程序的 AppID点击发行。产物目录在dist/build/mp-weixin然后打开微信开发者工具导入该目录即可预览。打包前要检查三个配置点任何一处遗漏都会导致真机异常manifest.json里的微信小程序 AppID 必须填真实值测试号能看但无法上传。接口地址必须是 HTTPS 且已配置在微信公众平台的“服务器域名”里。不校验合法域名只在开发者工具里勾选真机预览需要真实可访问的 HTTPS 接口。上线审核时急救类内容不涉及医疗建议违规但注意不要让系统出现“诊断”“治疗”“用药指导”等词避免被平台分类为医疗健康类目。急救常识属于知识科普文案上保持一致就好。6.3 小程序端验证清单与常见失败排查上线前跑一遍下方清单能拦截大多数问题。用表格列出来方便逐项核对验证项预期结果常见失败原因用户首次授权登录弹出授权弹窗登录后显示头像昵称manifest里隐私协议未配置知识点列表加载首页能看到分类和内容列表后端接口 404检查 Nginx 代理是否存在详情页视频播放视频能加载进度条可拖拽视频域名未在微信后台配置为业务域名提交练习显示得分和每道题的解析请求体结构不匹配检查JSON.stringify是否序列化了数组后台添加内容小程序端下拉能刷出新增内容小程序端缓存了旧数据手动静默缓存导致“手动静默缓存”是一个容易忽略的实现问题有些开发者会用uni.setStorageSync缓存列表数据上线后发布新内容用户看到的还是旧数据。要么加“下拉刷新时强制重新请求并覆盖缓存”的逻辑要么干脆不缓存列表。学习类内容本身不重每次请求实时拉取即可省心且不会出同步问题。另外建议在详情页埋点记录页面停留时长上报到后端learning_record的duration_seconds字段。之后统计“哪个知识点完读率高、哪个分类被跳过最多”这些数据可以指导内容调整。这个埋点用onHide和onUnload生命周期计算时间差即可不需要引入统计 SDK是编辑后续运营方向的低成本方案。本文还有配套的精品资源点击获取