2026-07-28-article-reading-time-tracking-plan.md 14 KB

文章阅读时长自动跟踪 实施计划

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 末尾(搜索 // 迁移 找最新编号),添加:

// 迁移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 实体添加字段

    // 在 content 字段后添加
    private Long articleId;
    // 在 readAt 字段后添加
    private Date updatedAt;
    
  • [ ] Step 3: 同步 schema.sql

在 schema.sql 的 article_reading_records CREATE TABLE 语句中添加 article_idupdated_at 列:

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: 编译验证

    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 方法

    public void reportReadingTime(Long articleId, Long userId, Long childId, int durationSeconds) {
    if (childId == null) return;
        
    // 查找当日已有记录(同文章+同孩子)
    LambdaQueryWrapper<ArticleReadingRecord> wrapper = new LambdaQueryWrapper<ArticleReadingRecord>()
            .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 添加端点

    @PostMapping("/report-reading-time")
    public Result<Void> reportReadingTime(@RequestBody Map<String, Object> 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: 编译验证

    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 和底部栏

底部栏变为(只保留分享):

<!-- 底部 -->
<view v-if="!showQuiz && !showResult" class="detail-footer">
  <button class="share-btn" open-type="share" @click="onShareTap">
    <text class="share-btn-icon">📤</text>
    <text class="share-btn-text">分享</text>
  </button>
</view>

注意同时删除外层 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 是否在其他地方使用... 保留分享功能)

实际上分享按钮还在,所以保留 shareMixinonShareTap


Task 4: 前端 — 添加自动阅读时长跟踪

Files:

  • Modify: cfc-frontend/pages/article-center/article-detail.vue

Interfaces:

  • Consumes: POST /api/articles/report-reading-time
  • Produces: 右侧阅读时长显示 + 可见性感知计时 + 自动上报

  • [ ] Step 1: 在 data 中添加计时相关状态

    data() {
    return {
    // ... 保留 articleId, article, loading, error, errorMsg
    readingSeconds: 0,
    lastReportedSeconds: 0,
    isTimerRunning: false,
    timerHandle: null,
    syncHandle: null
    }
    }
    
  • [ ] Step 2: 添加计时器方法

    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 函数签名,用统一方式调用更好。

import { reportReadingTime } from '@/utils/api.js'

utils/api.js 添加:

export const reportReadingTime = (data) => request('/api/articles/report-reading-time', 'POST', data)

然后在组件中:

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: 生命周期钩子

    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 信息区旁边或正文区右上角添加阅读时长显示:

<!-- 在元信息行旁边 -->
<view class="detail-meta">
  <text class="meta-author">{{ article.author || '浠艾福' }}</text>
  <text class="meta-sep">|</text>
  <text class="meta-date">{{ formatDate(article.publishedAt) }}</text>
  <text class="meta-sep">|</text>
  <text class="meta-readtime">{{ article.readTime || 3 }}分钟阅读</text>
  <!-- 阅读时长追踪显示 -->
  <text class="reading-time-badge">{{ formatReadingTime(readingSeconds) }}</text>
</view>

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.vueonHide/onShow 会触发全局事件,所有页面的 onShow/onHide 也会被触发,所以不需要额外处理。

但为了保险,在 article-detail.vue 中:

onShow 中需要加入:当 App 从后台切回时,恢复计时。 在 onHide 中:当 App 切后台时,暂停计时+上报。

这些已经通过 onShow/onHide 实现。不需要额外代码。

  • Step 6: 验证模板和逻辑一致性

确保:

  • 模板中没有引用已删除的 showQuizshowResultcanSubmit 等变量
  • 没有引用已删除的方法
  • share-btnopen-type="share" 正常工作
  • 阅读时长显示正确绑定 readingSeconds

Task 5: 编译验证 + 最终检查

Files:

  • Run: 后端 mvn clean compile

  • [ ] Step 1: 后端编译

    cd cfc-backend && mvn clean compile
    
  • [ ] Step 2: 前端编译

    cd cfc-frontend && npm run build:mp-weixin
    
  • [ ] Step 3: 最终检查列表

  • [ ] article_reading_records 表有 article_idupdated_at

  • [ ] ArticleReadingRecord 实体有 articleIdupdatedAt 字段

  • [ ] POST /api/articles/report-reading-time 端点存在且可调用

  • [ ] recordArticleRead 旧 API 保留(其他页面可能使用)

  • [ ] article-detail.vue 无"阅读完成"按钮

  • [ ] article-detail.vue 无答题/结果界面

  • [ ] article-detail.vue 右上角显示阅读时长

  • [ ] 切后台时暂停计时,返回时恢复

  • [ ] 离开页面时上报累计时长