2026-08-30-article-reading-tracking-enhancement.md 19 KB

文章阅读追踪增强 实现计划

面向 AI 代理的工作者: 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(- [ ])语法来跟踪进度。

目标: 完善文章阅读追踪——前端列表/详情页显示阅读数量,匿名用户不显示阅读时长;后端新增 admin 接口按文章查询阅读记录(含昵称和时长),供后台界面展示。

架构: 现有 article_reading_records 表已有 article_id 和累加逻辑,本次补充:1) reportReadingTime 首次上报时递增 articles.view_count(覆盖匿名/登录用户);2) 新增 POST /api/admin/articles/reading-records 返回每篇文章的阅读用户明细(含昵称);3) 前端列表/详情页补上阅读数显示;4) 后台弹窗展示阅读记录查询界面。

技术栈: Spring Boot 2.7 + MyBatis-Plus / uni-app Vue 2 小程序 / Vue 2 + Element UI 管理端

设计文档: docs/superpowers/specs/2026-07-28-article-reading-time-tracking-design.md

文件变更清单

文件 变更类型 说明
cfc-backend/.../service/ArticleService.java 修改 reportReadingTime 首次上报时递增 view_count;新增 getArticleReadingRecords
cfc-backend/.../controller/admin/AdminArticleController.java 修改 新增 POST /api/admin/articles/reading-records
cfc-backend/.../mapper/ArticleReadingRecordMapper.java 修改 新增 selectByArticle 查询方法
docs/superpowers/api/API_REFERENCE.md 修改 新增 POST /api/admin/articles/reading-records 接口文档
cfc-web/src/api/article.js 修改 新增 adminArticleReadingRecords 函数
cfc-web/src/views/admin/ArticleManage.vue 修改 每行加"阅读记录"按钮 + 阅读记录弹窗
cfc-frontend/pages/article-center/index.vue 修改 列表卡片底部显示阅读数
cfc-frontend/pages/article-center/article-detail.vue 修改 详情页 meta 显示阅读数;匿名用户不启动计时器

Task 1: 后端 — reportReadingTime 首次上报时递增 view_count

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java (第 318-345 行)

  • [ ] 步骤 1:修改 reportReadingTime 方法,在 insert 路径递增 view_count

在 ArticleService.reportReadingTime 方法中,当创建新记录(else 分支)时,调用 articleMapper 递增 view_count:

public void reportReadingTime(Long articleId, Long userId, Long memberId, int durationSeconds) {
    if (memberId == null) return;

    // 查找当日已有记录(同文章+同孩子)
    LambdaQueryWrapper<ArticleReadingRecord> wrapper = new LambdaQueryWrapper<ArticleReadingRecord>()
            .eq(ArticleReadingRecord::getArticleId, articleId)
            .eq(ArticleReadingRecord::getChildId, memberId)
            .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(memberId);
        record.setDurationSeconds(durationSeconds);
        record.setReadAt(new Date());
        record.setCreatedAt(new Date());
        articleReadingRecordMapper.insert(record);
        // 首次阅读:递增文章 view_count(匿名或登录用户均计)
        articleMapper.update(null, Wrappers.<Article>lambdaUpdate()
                .eq(Article::getId, articleId)
                .setSql("view_count = view_count + 1"));
    }
}

注意:articleMapper 已通过 @Resource 注入,Wrappers 已通过 import com.baomidou.mybatisplus.core.toolkit.Wrappers 引入(ArticleService 已有该 import)。

  • [ ] 步骤 2:编译验证

    cd cfc-backend && mvn clean compile
    

预期输出:BUILD SUCCESS

  • [ ] 步骤 3:Commit

    cd /sc-data/cfc && git add cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java && \
    git commit -m "feat(article): reportReadingTime 首次上报时递增 view_count"
    

Task 2: 后端 — 新增文章阅读记录查询 API + 接口文档

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/mapper/ArticleReadingRecordMapper.java
  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java
  • 修改:cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java
  • 修改:docs/superpowers/api/API_REFERENCE.md

  • [ ] 步骤 1:新增 Mapper 查询方法

在 ArticleReadingRecordMapper.java 末尾新增方法(替换现有内容):

package com.etotem.cfc.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.etotem.cfc.entity.ArticleReadingRecord;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.ResultMap;
import org.apache.ibatis.annotations.Select;
import java.util.List;
import java.util.Map;

@Mapper
public interface ArticleReadingRecordMapper extends BaseMapper<ArticleReadingRecord> {

    @Select("SELECT COALESCE(SUM(duration_seconds), 0) FROM article_reading_records " +
            "WHERE child_id = #{childId} AND read_at >= #{since}")
    Integer selectTotalDurationByChildSince(@Param("childId") Long childId,
                                            @Param("since") java.util.Date since);

    /**
     * 按文章 ID 分页查询阅读记录,JOIN family_members 获取昵称,按最后阅读时间倒序
     */
    @Select("SELECT " +
            "  r.id, " +
            "  r.user_id, " +
            "  r.child_id, " +
            "  r.duration_seconds, " +
            "  r.read_at, " +
            "  r.updated_at, " +
            "  m.nickname, " +
            "  DATE(r.read_at) AS read_date " +
            "FROM article_reading_records r " +
            "LEFT JOIN family_members m ON r.child_id = m.id " +
            "WHERE r.article_id = #{articleId} " +
            "ORDER BY r.updated_at DESC, r.read_at DESC " +
            "LIMIT #{limit} OFFSET #{offset}")
    @ResultMap("readRecordResultMap")
    List<Map<String, Object>> selectByArticle(
            @Param("articleId") Long articleId,
            @Param("offset") int offset,
            @Param("limit") int limit);

    /**
     * 统计某文章总阅读记录数(用于分页)
     */
    @Select("SELECT COUNT(*) FROM article_reading_records WHERE article_id = #{articleId}")
    long countByArticle(@Param("articleId") Long articleId);
}
  • 步骤 2:新增 Service 方法

在 ArticleService.java 中新增方法(放在 reportReadingTime 方法之后):

/**
 * 按文章 ID 分页查询阅读记录,返回用户昵称、时长、最后阅读时间
 */
public Page<Map<String, Object>> getArticleReadingRecords(Long articleId, int page, int size) {
    long total = articleReadingRecordMapper.countByArticle(articleId);
    if (total == 0) {
        Page<Map<String, Object>> empty = new Page<>(page, size);
        empty.setTotal(0);
        empty.setRecords(Collections.emptyList());
        return empty;
    }
    int offset = (page - 1) * size;
    List<Map<String, Object>> records = articleReadingRecordMapper.selectByArticle(articleId, offset, size);
    Page<Map<String, Object>> result = new Page<>(page, size);
    result.setTotal(total);
    result.setRecords(records);
    return result;
}

注意:Page 来自 com.baomidou.mybatisplus.extension.plugins.pagination.Page,Collections 来自 java.util.Collections,均已可复用。

  • 步骤 3:新增 Admin Controller 端点

在 AdminArticleController.java 末尾的 categoryDelete 方法之前插入:

    @PostMapping("/reading-records")
    public Result<Page<Map<String, Object>>> readingRecords(
            @RequestBody Map<String, Object> body) {
        Long articleId = ParamUtils.getLong(body.get("articleId"));
        if (articleId == null) return Result.error("articleId不能为空");
        int page = body.get("page") != null ? Integer.parseInt(body.get("page").toString()) : 1;
        int size = body.get("size") != null ? Integer.parseInt(body.get("size").toString()) : 20;
        return Result.success(articleService.getArticleReadingRecords(articleId, page, size));
    }
  • 步骤 4:更新 API_REFERENCE.md

在 docs/superpowers/api/API_REFERENCE.md 的"📰 文章管理 API"章节(搜索 ## 📰 文章管理 API 或类似标题)末尾追加:

### 文章阅读记录

| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/admin/articles/reading-records` | 按文章查询阅读记录明细 |

**请求体:**

json { "articleId": 123, "page": 1, "size": 20 }


**响应 data 字段(分页):**

json { "total": 5, "records": [

{
  "id": 10,
  "user_id": 42,
  "child_id": 88,
  "nickname": "小明",
  "duration_seconds": 450,
  "read_at": "2026-08-30T10:30:00",
  "updated_at": "2026-08-30T10:30:00",
  "read_date": "2026-08-30"
}

] }

  • [ ] 步骤 5:编译验证

    cd cfc-backend && mvn clean compile
    

预期:BUILD SUCCESS

  • [ ] 步骤 6:Commit

    cd /sc-data/cfc && git add \
    cfc-backend/src/main/java/com/etotem/cfc/mapper/ArticleReadingRecordMapper.java \
    cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java \
    cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java \
    docs/superpowers/api/API_REFERENCE.md && \
    git commit -m "feat(article): 新增文章阅读记录 admin API"
    

Task 3: cfc-web 管理端 — 阅读记录弹窗

文件:

  • 修改:cfc-web/src/api/article.js
  • 修改:cfc-web/src/views/admin/ArticleManage.vue

  • [ ] 步骤 1:新增 API 封装函数

在 cfc-web/src/api/article.js 末尾追加:

// ===== 文章阅读记录 =====
export function adminArticleReadingRecords(data) {
  return request({ url: '/api/admin/articles/reading-records', method: 'post', data })
}
  • 步骤 2:修改 ArticleManage.vue

a) data 中新增状态变量(rejectDialogVisible 之后):

      rejectDialogVisible: false,
      rejectReason: '',
      currentAuditRow: null,
      // 阅读记录弹窗
      readRecordVisible: false,
      readRecordArticleId: null,
      readRecordList: [],
      readRecordTotal: 0,
      readRecordPage: 1,
      readRecordSize: 20,
      readRecordLoading: false

b) template:在"操作"列的操作按钮之后、rejectDialog 之前新增阅读记录弹窗

在 <el-dialog title="驳回原因" ...> 之前插入:

<!-- 阅读记录弹窗 -->
<el-dialog title="文章阅读记录" :visible.sync="readRecordVisible" width="800px" destroy-on-close>
  <div v-loading="readRecordLoading" style="min-height: 200px;">
    <el-table :data="readRecordList" border stripe size="small">
      <el-table-column prop="nickname" label="孩子昵称" width="120" />
      <el-table-column prop="duration_seconds" label="累计时长(秒)" width="120">
        <template slot-scope="{ row }">
          {{ formatDuration(row.duration_seconds) }}
        </template>
      </el-table-column>
      <el-table-column prop="read_date" label="最后阅读日期" width="130">
        <template slot-scope="{ row }">
          {{ row.read_date || '-' }}
        </template>
      </el-table-column>
      <el-table-column prop="read_at" label="最后阅读时间" width="170">
        <template slot-scope="{ row }">
          {{ formatTime(row.read_at) }}
        </template>
      </el-table-column>
    </el-table>
    <el-pagination
      v-if="readRecordTotal > 0"
      layout="total, prev, pager, next"
      :current-page="readRecordPage"
      :page-size="readRecordSize"
      :total="readRecordTotal"
      @current-change="onReadRecordPageChange"
      style="margin-top: 16px; text-align: right;"
    />
  </div>
  <span slot="footer">
    <el-button @click="readRecordVisible = false">关闭</el-button>
  </span>
</el-dialog>

c) methods:新增方法

在现有 methods 末尾插入:

    async openReadRecords(row) {
      this.readRecordArticleId = row.id
      this.readRecordPage = 1
      this.readRecordList = []
      this.readRecordTotal = 0
      this.readRecordVisible = true
      await this.loadReadRecords()
    },
    async loadReadRecords() {
      this.readRecordLoading = true
      try {
        const res = await adminArticleReadingRecords({
          articleId: this.readRecordArticleId,
          page: this.readRecordPage,
          size: this.readRecordSize
        })
        if (res.data) {
          this.readRecordList = res.data.records || []
          this.readRecordTotal = res.data.total || 0
        }
      } catch (e) {
        this.$message.error('加载阅读记录失败')
      } finally {
        this.readRecordLoading = false
      }
    },
    onReadRecordPageChange(page) {
      this.readRecordPage = page
      this.loadReadRecords()
    },
    formatDuration(seconds) {
      if (!seconds) return '0秒'
      var min = Math.floor(seconds / 60)
      var sec = seconds % 60
      if (min > 0 && sec > 0) return min + '分' + sec + '秒'
      if (min > 0) return min + '分钟'
      return sec + '秒'
    }

d) template:在操作列每个状态模板的"复制"按钮旁边新增"阅读记录"按钮

在每个状态的 <el-button size="mini" type="primary"> 或操作区末尾插入(以发布中状态为例,其他状态按需添加):

<el-button size="mini" @click="openReadRecords(row)">阅读记录</el-button>

注意:所有状态下(草稿箱、待审核、已驳回、发布中、已撤回、其他)都应有此按钮,统一放在操作区域。

  • [ ] 步骤 3:编译验证

    cd cfc-web && npm run build
    

预期:构建成功(无 TypeScript/ESLint 错误)

  • [ ] 步骤 4:Commit

    cd /sc-data/cfc && git add \
    cfc-web/src/api/article.js \
    cfc-web/src/views/admin/ArticleManage.vue && \
    git commit -m "feat(web): 文章管理页新增阅读记录弹窗"
    

Task 4: 小程序前端 — 阅读数显示 + 匿名用户处理

文件:

  • 修改:cfc-frontend/pages/article-center/index.vue
  • 修改: cfc-frontend/pages/article-center/article-detail.vue

  • [ ] 步骤 1:列表页显示阅读数

在 cfc-frontend/pages/article-center/index.vue 的卡片 footer(第 105-110 行附近)中,在 ac-card-fav 后追加阅读数:

将:

              <text class="ac-card-fav">&#x2B50; {{ item.favCount || 0 }}</text>

改为:

              <text class="ac-card-fav">&#x2B50; {{ item.favCount || 0 }}</text>
              <text class="ac-card-sep">|</text>
              <text class="ac-card-readcount">&#x1F441; {{ item.viewCount || 0 }}</text>

追加 CSS(在 .ac-card-fav 样式块之后):

.ac-card-readcount {
  color: #ccc;
}
  • 步骤 2:详情页 meta 显示阅读数

在 cfc-frontend/pages/article-center/article-detail.vue 的 meta 行(第 16-24 行)中,在现有 meta 项之后追加:

将:

           <text class="reading-time-badge completed" v-else>✅ 阅读完成</text>
        </view>

改为:

           <text class="reading-time-badge completed" v-else>✅ 阅读完成</text>
           <text class="meta-sep">|</text>
           <text class="meta-readcount">{{ article.viewCount || 0 }}人阅读</text>
        </view>

追加 CSS:

.meta-readcount {
  font-size: 20rpx;
  color: #bbb;
}
  • 步骤 3:匿名用户不启动计时器

在 article-detail.vue 中,startReadingTimer 方法加一个匿名用户检查。当前 startReadingTimer 已有 if (this.isTimerRunning || this.readingCompleted) return,在其前追加:

    startReadingTimer: function() {
      var memberId = uni.getStorageSync('currentChildId')
      if (!memberId) return
      if (this.isTimerRunning || this.readingCompleted) return
      // ... 原有逻辑

同样,在 onShow 和 startNewReadingSession 中也加入此检查,防止匿名用户启动计时:

    onShow: function() {
      var memberId = uni.getStorageSync('currentChildId')
      if (!this.article || this.error || !memberId) return
      this.startNewReadingSession()
    },

同时,在模板中阅读时长徽章(第 22-23 行)已有 v-if="!readingCompleted" 条件,但匿名用户应完全不显示该徽章,改为:

          <text class="reading-time-badge" v-if="!readingCompleted && currentChildId">{{ formatReadingTime(readingSeconds) }}</text>
          <text class="reading-time-badge completed" v-else>✅ 阅读完成</text>

在 data 中新增一个计算属性或直接用已存储的值,这里利用已存在逻辑——匿名用户 memberId 为空时计时器不启动,readingSeconds 始终为 0,徽章本身也不应显示。最简单:在 data 中加一个 currentChildId 字段,或在模板中直接读取 storage:

          <text class="reading-time-badge" v-if="!readingCompleted && !!currentChildId">{{ formatReadingTime(readingSeconds) }}</text>

注意:小程序模板中不能直接调用 uni.getStorageSync,需从 data 中取值。在 onLoad 中赋值:

    onLoad(options) {
      // ... 原有代码
      this.currentChildId = uni.getStorageSync('currentChildId') || null
    },

在 data 中加 currentChildId: null。

  • [ ] 步骤 4:编译验证

    cd cfc-frontend && node -e "require('fs').readFileSync('pages/article-center/index.vue','utf8')" && \
    node -e "require('fs').readFileSync('pages/article-center/article-detail.vue','utf8')"
    

预期:文件内容可读,无语法错误。

  • [ ] 步骤 5:Commit

    cd /sc-data/cfc && git add \
    cfc-frontend/pages/article-center/index.vue \
    cfc-frontend/pages/article-center/article-detail.vue && \
    git commit -m "feat(frontend): 文章列表/详情页显示阅读数,匿名用户不启动计时"
    

Task 5: 规格文档同步更新 + 最终验证

  • 步骤 1:更新 PROJECT-OVERVIEW.md

在 docs/superpowers/PROJECT-OVERVIEW.md 的"4.1 文章与内容运营"章节追加新条目:

| 文章阅读追踪增强(阅读数+后台查询) | Phase 6 | 🚧 实施中 | 本文档(2026-08-30) | `specs/2026-07-28-article-reading-time-tracking-design.md` + 本计划 |
  • [ ] 步骤 2:最终编译验证

    cd cfc-backend && mvn clean compile
    

预期:BUILD SUCCESS

  • [ ] 步骤 3:Commit 文档变更

    cd /sc-data/cfc && git add docs/superpowers/PROJECT-OVERVIEW.md && \
    git commit -m "docs: 更新 PROJECT-OVERVIEW.md 文章阅读追踪增强条目"