ソースを参照

chore: auto bump version and changelog [skip ci]

iwt 2 週間 前
コミット
9e3c8bbfe8

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-49a6a5969c8840a98cd758a82173692a3ba3dba4
+f8da4265e274af60feec2678809662798e43e3a2

+ 543 - 0
docs/superpowers/plans/2026-08-30-article-reading-tracking-enhancement.md

@@ -0,0 +1,543 @@
+# 文章阅读追踪增强 实现计划
+
+> **面向 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`:
+
+```java
+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:编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile
+```
+
+预期输出:`BUILD SUCCESS`
+
+- [ ] **步骤 3:Commit**
+
+```bash
+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` 末尾新增方法(替换现有内容):
+
+```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` 方法之后):
+
+```java
+/**
+ * 按文章 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` 方法之前插入:
+
+```java
+    @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` 或类似标题)末尾追加:
+
+```markdown
+### 文章阅读记录
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| 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:编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile
+```
+
+预期:`BUILD SUCCESS`
+
+- [ ] **步骤 6:Commit**
+
+```bash
+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` 末尾追加:
+
+```javascript
+// ===== 文章阅读记录 =====
+export function adminArticleReadingRecords(data) {
+  return request({ url: '/api/admin/articles/reading-records', method: 'post', data })
+}
+```
+
+- [ ] **步骤 2:修改 ArticleManage.vue**
+
+**a) data 中新增状态变量**(`rejectDialogVisible` 之后):
+
+```javascript
+      rejectDialogVisible: false,
+      rejectReason: '',
+      currentAuditRow: null,
+      // 阅读记录弹窗
+      readRecordVisible: false,
+      readRecordArticleId: null,
+      readRecordList: [],
+      readRecordTotal: 0,
+      readRecordPage: 1,
+      readRecordSize: 20,
+      readRecordLoading: false
+```
+
+**b) template:在"操作"列的操作按钮之后、`rejectDialog` 之前新增阅读记录弹窗**
+
+在 `<el-dialog title="驳回原因" ...>` 之前插入:
+
+```html
+<!-- 阅读记录弹窗 -->
+<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 末尾插入:
+
+```javascript
+    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">` 或操作区末尾插入(以发布中状态为例,其他状态按需添加):
+
+```html
+<el-button size="mini" @click="openReadRecords(row)">阅读记录</el-button>
+```
+
+注意:所有状态下(草稿箱、待审核、已驳回、发布中、已撤回、其他)都应有此按钮,统一放在操作区域。
+
+- [ ] **步骤 3:编译验证**
+
+```bash
+cd cfc-web && npm run build
+```
+
+预期:构建成功(无 TypeScript/ESLint 错误)
+
+- [ ] **步骤 4:Commit**
+
+```bash
+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` 后追加阅读数:
+
+将:
+```html
+              <text class="ac-card-fav">&#x2B50; {{ item.favCount || 0 }}</text>
+```
+改为:
+```html
+              <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` 样式块之后):
+```css
+.ac-card-readcount {
+  color: #ccc;
+}
+```
+
+- [ ] **步骤 2:详情页 meta 显示阅读数**
+
+在 `cfc-frontend/pages/article-center/article-detail.vue` 的 meta 行(第 16-24 行)中,在现有 meta 项之后追加:
+
+将:
+```html
+           <text class="reading-time-badge completed" v-else>✅ 阅读完成</text>
+        </view>
+```
+改为:
+```html
+           <text class="reading-time-badge completed" v-else>✅ 阅读完成</text>
+           <text class="meta-sep">|</text>
+           <text class="meta-readcount">{{ article.viewCount || 0 }}人阅读</text>
+        </view>
+```
+
+追加 CSS:
+```css
+.meta-readcount {
+  font-size: 20rpx;
+  color: #bbb;
+}
+```
+
+- [ ] **步骤 3:匿名用户不启动计时器**
+
+在 `article-detail.vue` 中,`startReadingTimer` 方法加一个匿名用户检查。当前 `startReadingTimer` 已有 `if (this.isTimerRunning || this.readingCompleted) return`,在其前追加:
+
+```javascript
+    startReadingTimer: function() {
+      var memberId = uni.getStorageSync('currentChildId')
+      if (!memberId) return
+      if (this.isTimerRunning || this.readingCompleted) return
+      // ... 原有逻辑
+```
+
+同样,在 `onShow` 和 `startNewReadingSession` 中也加入此检查,防止匿名用户启动计时:
+
+```javascript
+    onShow: function() {
+      var memberId = uni.getStorageSync('currentChildId')
+      if (!this.article || this.error || !memberId) return
+      this.startNewReadingSession()
+    },
+```
+
+同时,在模板中阅读时长徽章(第 22-23 行)已有 `v-if="!readingCompleted"` 条件,但匿名用户应完全不显示该徽章,改为:
+
+```html
+          <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:
+
+```html
+          <text class="reading-time-badge" v-if="!readingCompleted && !!currentChildId">{{ formatReadingTime(readingSeconds) }}</text>
+```
+
+> 注意:小程序模板中不能直接调用 `uni.getStorageSync`,需从 `data` 中取值。在 `onLoad` 中赋值:
+```javascript
+    onLoad(options) {
+      // ... 原有代码
+      this.currentChildId = uni.getStorageSync('currentChildId') || null
+    },
+```
+在 data 中加 `currentChildId: null`。
+
+- [ ] **步骤 4:编译验证**
+
+```bash
+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**
+
+```bash
+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 文章与内容运营"章节追加新条目:
+
+```markdown
+| 文章阅读追踪增强(阅读数+后台查询) | Phase 6 | 🚧 实施中 | 本文档(2026-08-30) | `specs/2026-07-28-article-reading-time-tracking-design.md` + 本计划 |
+```
+
+- [ ] **步骤 2:最终编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile
+```
+
+预期:`BUILD SUCCESS`
+
+- [ ] **步骤 3:Commit 文档变更**
+
+```bash
+cd /sc-data/cfc && git add docs/superpowers/PROJECT-OVERVIEW.md && \
+  git commit -m "docs: 更新 PROJECT-OVERVIEW.md 文章阅读追踪增强条目"
+```