面向 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 显示阅读数;匿名用户不启动计时器 |
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"
文件:
cfc-backend/src/main/java/com/etotem/cfc/mapper/ArticleReadingRecordMapper.javacfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.javacfc-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);
}
在 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,均已可复用。
在 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));
}
在 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"
文件:
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 })
}
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): 文章管理页新增阅读记录弹窗"
文件:
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">⭐ {{ item.favCount || 0 }}</text>
改为:
<text class="ac-card-fav">⭐ {{ item.favCount || 0 }}</text>
<text class="ac-card-sep">|</text>
<text class="ac-card-readcount">👁 {{ item.viewCount || 0 }}</text>
追加 CSS(在 .ac-card-fav 样式块之后):
.ac-card-readcount {
color: #ccc;
}
在 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;
}
在 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): 文章列表/详情页显示阅读数,匿名用户不启动计时"
在 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 文章阅读追踪增强条目"