# 文章阅读时长自动跟踪 实施计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 去除文章详情页的"阅读完成"按钮,改为自动跟踪阅读时长,用户离开页面时上报 **Architecture:** 后端新增累计式API(按article_id+child_id+DATE(read_at) upsert),前端利用onShow/onHide生命周期+App级前后台切换实现可见性感知计时,每30秒自动同步,离开时最终上报 **Tech Stack:** Spring Boot 2.7 + MyBatis-Plus / uni-app Vue 2 小程序 **设计文档:** `docs/superpowers/specs/2026-07-28-article-reading-time-tracking-design.md` ## Global Constraints - 后端接口统一使用 `@PostMapping` - 小程序禁止可选链 `?.`(用 `&&` 替代)、禁止 CSS Grid(用 flexbox) - Vue 2 Options API,禁止 Composition API - 所有迁移必须幂等(try-catch 忽略已存在的列/表) --- ### Task 1: 数据库迁移 + 实体更新 **Files:** - Modify: `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` - Modify: `cfc-backend/src/main/java/com/etotem/cfc/entity/ArticleReadingRecord.java` - Modify: `cfc-backend/src/main/resources/schema.sql` **Interfaces:** - Consumes: 现有 `article_reading_records` 表定义(DatabaseInitializer 第 3946 行) - Produces: `ArticleReadingRecord.articleId` 字段, `article_reading_records.article_id` 列, `article_reading_records.updated_at` 列 - [ ] **Step 1: DatabaseInitializer 添加迁移** 在 DatabaseInitializer 末尾(搜索 `// 迁移` 找最新编号),添加: ```java // 迁移N: article_reading_records表添加article_id和updated_at列 try { jdbcTemplate.execute("ALTER TABLE article_reading_records ADD COLUMN article_id BIGINT DEFAULT NULL COMMENT '文章ID' AFTER id"); log.info("已添加article_id列到article_reading_records表"); } catch (Exception e) { // 列已存在,忽略 } try { jdbcTemplate.execute("ALTER TABLE article_reading_records ADD COLUMN updated_at DATETIME DEFAULT NULL COMMENT '更新时间' AFTER read_at"); log.info("已添加updated_at列到article_reading_records表"); } catch (Exception e) { // 列已存在,忽略 } try { jdbcTemplate.execute("ALTER TABLE article_reading_records ADD INDEX idx_article_child_date (article_id, child_id, read_at)"); log.info("已添加idx_article_child_date索引"); } catch (Exception e) { // 索引已存在,忽略 } ``` - [ ] **Step 2: ArticleReadingRecord 实体添加字段** ```java // 在 content 字段后添加 private Long articleId; // 在 readAt 字段后添加 private Date updatedAt; ``` - [ ] **Step 3: 同步 schema.sql** 在 schema.sql 的 `article_reading_records` CREATE TABLE 语句中添加 `article_id` 和 `updated_at` 列: ```sql CREATE TABLE IF NOT EXISTS article_reading_records ( id BIGINT AUTO_INCREMENT PRIMARY KEY, article_id BIGINT DEFAULT NULL COMMENT '文章ID', user_id BIGINT COMMENT '用户ID', child_id BIGINT NOT NULL COMMENT '孩子ID', content VARCHAR(500) COMMENT '阅读内容摘要', duration_seconds INT DEFAULT 0 COMMENT '阅读时长(秒)', read_at DATETIME COMMENT '阅读时间', updated_at DATETIME DEFAULT NULL COMMENT '更新时间', created_at DATETIME, INDEX idx_child_id (child_id), INDEX idx_read_at (read_at), INDEX idx_article_child_date (article_id, child_id, read_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章阅读记录表'; ``` - [ ] **Step 4: 编译验证** ```bash cd cfc-backend && mvn clean compile ``` --- ### Task 2: 后端累计式阅读时长上报 API **Files:** - Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleController.java` - Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java` **Interfaces:** - Consumes: `ArticleReadingRecord` entity, `ArticleReadingRecordMapper` - Produces: `POST /api/articles/report-reading-time` 端点 - [ ] **Step 1: ArticleService 添加 reportReadingTime 方法** ```java public void reportReadingTime(Long articleId, Long userId, Long childId, int durationSeconds) { if (childId == null) return; // 查找当日已有记录(同文章+同孩子) LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(ArticleReadingRecord::getArticleId, articleId) .eq(ArticleReadingRecord::getChildId, childId) .apply("DATE(read_at) = CURDATE()") .last("LIMIT 1"); ArticleReadingRecord existing = articleReadingRecordMapper.selectOne(wrapper); if (existing != null) { // 累加时长 existing.setDurationSeconds(existing.getDurationSeconds() + durationSeconds); existing.setUpdatedAt(new Date()); articleReadingRecordMapper.updateById(existing); } else { // 新建记录 ArticleReadingRecord record = new ArticleReadingRecord(); record.setArticleId(articleId); record.setUserId(userId); record.setChildId(childId); record.setDurationSeconds(durationSeconds); record.setReadAt(new Date()); record.setCreatedAt(new Date()); articleReadingRecordMapper.insert(record); } } ``` - [ ] **Step 2: ArticleController 添加端点** ```java @PostMapping("/report-reading-time") public Result reportReadingTime(@RequestBody Map body, @RequestAttribute(value = "userId", required = false) Long userId) { Long articleId = Long.valueOf(body.get("articleId").toString()); int durationSeconds = body.get("durationSeconds") != null ? Integer.parseInt(body.get("durationSeconds").toString()) : 0; Long childId = body.get("childId") != null ? Long.valueOf(body.get("childId").toString()) : null; articleService.reportReadingTime(articleId, userId, childId, durationSeconds); return Result.success(null); } ``` - [ ] **Step 3: 编译验证** ```bash cd cfc-backend && mvn clean compile ``` --- ### Task 3: 前端 — 删除按钮 + 答题 + 旧记录逻辑 **Files:** - Modify: `cfc-frontend/pages/article-center/article-detail.vue` **Interfaces:** - Consumes: 现有 article-detail.vue 完整组件 - Produces: 干净的文章详情页(无"阅读完成"按钮、无答题界面、无 quiz/result 状态) - [ ] **Step 1: 删除模板中的按钮和答题界面** 从 template 中移除: - `read-btn` 按钮(第 58-61 行) - 整个 `quiz-container` 区块(第 65-98 行) - 整个 `result-container` 区块(第 100-109 行) - 保留 `share-btn` 和底部栏 底部栏变为(只保留分享): ```html ``` 注意同时删除外层 `v-if="!showQuiz && !showResult"` 条件(因为不再有 quiz/result 状态),改为直接显示。 - [ ] **Step 2: 删除 script 中的相关数据和方法** 从 data 中删除: - `readSeconds`, `readTimer`(将在 Task 4 重新以不同方式添加) - `showQuiz`, `quizLoading`, `quizError`, `quizErrorMsg`, `quizQuestions`, `quizRecordId` - `selectedAnswers`, `showResult`, `resultScore`, `resultTotal`, `resultPoints` 从 computed 中删除:`canSubmit` 从 methods 中删除: - `startTimer()`, `stopTimer()`(将在 Task 4 重新实现) - `onReadComplete()` - `loadQuiz()`, `onSubmitQuiz()` - `selectAnswer()`, `getOptionLetter()`, `getOptionText()` - `goBack()` 从 `onLoad` 中删除 `this.startTimer()` 调用(Task 4 重新实现) 从 `onUnload` 中删除 `this.stopTimer()` 调用(Task 4 重新实现) - [ ] **Step 3: 删除 import** 删除 `getAiQuestions`, `submitAnswers`, `recordArticleRead` 的导入(只保留 `getArticleDetail`) 删除 `shareMixin` 导入和 mixins 注册(如果分享还在的话保留,确认 share-mixin 是否在其他地方使用... 保留分享功能) 实际上分享按钮还在,所以保留 `shareMixin` 和 `onShareTap`。 --- ### Task 4: 前端 — 添加自动阅读时长跟踪 **Files:** - Modify: `cfc-frontend/pages/article-center/article-detail.vue` **Interfaces:** - Consumes: `POST /api/articles/report-reading-time` - Produces: 右侧阅读时长显示 + 可见性感知计时 + 自动上报 - [ ] **Step 1: 在 data 中添加计时相关状态** ```javascript data() { return { // ... 保留 articleId, article, loading, error, errorMsg readingSeconds: 0, lastReportedSeconds: 0, isTimerRunning: false, timerHandle: null, syncHandle: null } } ``` - [ ] **Step 2: 添加计时器方法** ```javascript methods: { // ... 保留现有方法 startReadingTimer: function() { if (this.isTimerRunning) return this.isTimerRunning = true var self = this // 每秒累加 this.timerHandle = setInterval(function() { self.readingSeconds++ }, 1000) // 每30秒自动上报 this.syncHandle = setInterval(function() { self.reportReadingTime() }, 30000) }, pauseReadingTimer: function() { if (!this.isTimerRunning) return this.isTimerRunning = false if (this.timerHandle) { clearInterval(this.timerHandle) this.timerHandle = null } if (this.syncHandle) { clearInterval(this.syncHandle) this.syncHandle = null } this.reportReadingTime() }, reportReadingTime: function() { var delta = this.readingSeconds - this.lastReportedSeconds if (delta <= 0) return this.lastReportedSeconds = this.readingSeconds var childId = uni.getStorageSync('currentChildId') if (!childId || !this.article) return // 调用新 API try { uni.request({ url: getApp().globalData.baseUrl + '/api/articles/report-reading-time', method: 'POST', data: { articleId: this.article.id, childId: parseInt(childId), durationSeconds: delta }, header: { 'Authorization': 'Bearer ' + (uni.getStorageSync('token') || ''), 'Content-Type': 'application/json' }, fail: function() { // silent fail } }) } catch (e) { // silent fail } }, formatReadingTime: function(seconds) { if (seconds < 60) { return '已读 ' + seconds + '秒' } var min = Math.floor(seconds / 60) var sec = seconds % 60 return '已读 ' + min + '分' + (sec > 0 ? sec + '秒' : '') } } ``` 关于 API 调用方式——需要检查项目中统一请求封装。看一下 `utils/api.js` 中的 `request` 函数签名,用统一方式调用更好。 ```javascript import { reportReadingTime } from '@/utils/api.js' ``` 在 `utils/api.js` 添加: ```javascript export const reportReadingTime = (data) => request('/api/articles/report-reading-time', 'POST', data) ``` 然后在组件中: ```javascript reportReadingTime: function() { var delta = this.readingSeconds - this.lastReportedSeconds if (delta <= 0) return this.lastReportedSeconds = this.readingSeconds var childId = uni.getStorageSync('currentChildId') if (!childId || !this.article) return reportReadingTime({ articleId: this.article.id, childId: parseInt(childId), durationSeconds: delta }) } ``` - [ ] **Step 3: 生命周期钩子** ```javascript onLoad(options) { if (options && options.id) { this.articleId = options.id this.loadDetail(options.id) // 不再在 onLoad 启动计时器,改为 onShow } else { this.error = true this.errorMsg = '参数错误' this.loading = false } }, onShow: function() { // 页面显示时开始/恢复计时 if (this.article && !this.error) { this.startReadingTimer() } }, onHide: function() { // 页面隐藏时暂停+上报 this.pauseReadingTimer() }, onUnload: function() { // 页面卸载时最终上报 this.pauseReadingTimer() } ``` - [ ] **Step 4: 模板中添加时长显示** 在 meta 信息区旁边或正文区右上角添加阅读时长显示: ```html {{ article.author || '浠艾福' }} | {{ formatDate(article.publishedAt) }} | {{ article.readTime || 3 }}分钟阅读 {{ formatReadingTime(readingSeconds) }} ``` CSS: ```css .reading-time-badge { margin-left: auto; font-size: 20rpx; color: #5B9BD5; background: rgba(91,155,213,0.08); padding: 4rpx 12rpx; border-radius: 20rpx; } ``` - [ ] **Step 5: 添加 App 级前后台切换监听** 在 `App.vue` 中,当前页面通过 `onShow`/`onHide` 已能处理大部分场景。对于小程序切后台,`App.vue` 的 `onHide`/`onShow` 会触发全局事件,所有页面的 `onShow`/`onHide` 也会被触发,所以不需要额外处理。 但为了保险,在 `article-detail.vue` 中: 在 `onShow` 中需要加入:当 App 从后台切回时,恢复计时。 在 `onHide` 中:当 App 切后台时,暂停计时+上报。 这些已经通过 `onShow`/`onHide` 实现。不需要额外代码。 - [ ] **Step 6: 验证模板和逻辑一致性** 确保: - 模板中没有引用已删除的 `showQuiz`、`showResult`、`canSubmit` 等变量 - 没有引用已删除的方法 - `share-btn` 的 `open-type="share"` 正常工作 - 阅读时长显示正确绑定 `readingSeconds` --- ### Task 5: 编译验证 + 最终检查 **Files:** - Run: 后端 `mvn clean compile` - [ ] **Step 1: 后端编译** ```bash cd cfc-backend && mvn clean compile ``` - [ ] **Step 2: 前端编译** ```bash cd cfc-frontend && npm run build:mp-weixin ``` - [ ] **Step 3: 最终检查列表** - [ ] `article_reading_records` 表有 `article_id` 和 `updated_at` 列 - [ ] `ArticleReadingRecord` 实体有 `articleId` 和 `updatedAt` 字段 - [ ] `POST /api/articles/report-reading-time` 端点存在且可调用 - [ ] `recordArticleRead` 旧 API 保留(其他页面可能使用) - [ ] article-detail.vue 无"阅读完成"按钮 - [ ] article-detail.vue 无答题/结果界面 - [ ] article-detail.vue 右上角显示阅读时长 - [ ] 切后台时暂停计时,返回时恢复 - [ ] 离开页面时上报累计时长