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
@PostMapping?.(用 && 替代)、禁止 CSS Grid(用 flexbox)Files:
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.javacfc-backend/src/main/java/com/etotem/cfc/entity/ArticleReadingRecord.javacfc-backend/src/main/resources/schema.sqlInterfaces:
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_id 和 updated_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
Files:
cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleController.javacfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.javaInterfaces:
ArticleReadingRecord entity, ArticleReadingRecordMapperProduces: 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
Files:
cfc-frontend/pages/article-center/article-detail.vueInterfaces:
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 状态),改为直接显示。
从 data 中删除:
readSeconds, readTimer(将在 Task 4 重新以不同方式添加)showQuiz, quizLoading, quizError, quizErrorMsg, quizQuestions, quizRecordIdselectedAnswers, 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 重新实现)
删除 getAiQuestions, submitAnswers, recordArticleRead 的导入(只保留 getArticleDetail)
删除 shareMixin 导入和 mixins 注册(如果分享还在的话保留,确认 share-mixin 是否在其他地方使用... 保留分享功能)
实际上分享按钮还在,所以保留 shareMixin 和 onShareTap。
Files:
cfc-frontend/pages/article-center/article-detail.vueInterfaces:
POST /api/articles/report-reading-timeProduces: 右侧阅读时长显示 + 可见性感知计时 + 自动上报
[ ] 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;
}
在 App.vue 中,当前页面通过 onShow/onHide 已能处理大部分场景。对于小程序切后台,App.vue 的 onHide/onShow 会触发全局事件,所有页面的 onShow/onHide 也会被触发,所以不需要额外处理。
但为了保险,在 article-detail.vue 中:
在 onShow 中需要加入:当 App 从后台切回时,恢复计时。
在 onHide 中:当 App 切后台时,暂停计时+上报。
这些已经通过 onShow/onHide 实现。不需要额外代码。
确保:
showQuiz、showResult、canSubmit 等变量share-btn 的 open-type="share" 正常工作readingSecondsFiles:
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_id 和 updated_at 列
[ ] ArticleReadingRecord 实体有 articleId 和 updatedAt 字段
[ ] POST /api/articles/report-reading-time 端点存在且可调用
[ ] recordArticleRead 旧 API 保留(其他页面可能使用)
[ ] article-detail.vue 无"阅读完成"按钮
[ ] article-detail.vue 无答题/结果界面
[ ] article-detail.vue 右上角显示阅读时长
[ ] 切后台时暂停计时,返回时恢复
[ ] 离开页面时上报累计时长