|
|
@@ -0,0 +1,456 @@
|
|
|
+# 文章阅读时长自动跟踪 实施计划
|
|
|
+
|
|
|
+> **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<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 添加端点**
|
|
|
+
|
|
|
+```java
|
|
|
+@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: 编译验证**
|
|
|
+
|
|
|
+```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
|
|
|
+<!-- 底部 -->
|
|
|
+<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 是否在其他地方使用... 保留分享功能)
|
|
|
+
|
|
|
+实际上分享按钮还在,所以保留 `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
|
|
|
+<!-- 在元信息行旁边 -->
|
|
|
+<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:
|
|
|
+```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 右上角显示阅读时长
|
|
|
+- [ ] 切后台时暂停计时,返回时恢复
|
|
|
+- [ ] 离开页面时上报累计时长
|