Просмотр исходного кода

chore: auto bump version and changelog [skip ci]

iwt 2 месяцев назад
Родитель
Сommit
700ba3b4e5

+ 10 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java

@@ -380,6 +380,16 @@ public class AdminArticleController {
         return Result.success("更新成功");
     }
 
+    @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));
+    }
+
     @PostMapping("/categories/delete")
     public Result<String> categoryDelete(@RequestBody Map<String, Object> body) {
         Long id = ParamUtils.getLong(body.get("id"));

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleController.java

@@ -77,6 +77,7 @@ public class ArticleController {
         if (article == null) {
             return Result.error(403, "无权访问该文章");
         }
+        articleService.incrementViewCount(id);
         return Result.success(article);
     }
 

+ 39 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ArticleReadingRecordMapper.java

@@ -4,7 +4,11 @@ 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.Result;
+import org.apache.ibatis.annotations.Results;
 import org.apache.ibatis.annotations.Select;
+import java.util.List;
+import java.util.Map;
 
 @Mapper
 public interface ArticleReadingRecordMapper extends BaseMapper<ArticleReadingRecord> {
@@ -13,4 +17,39 @@ public interface ArticleReadingRecordMapper extends BaseMapper<ArticleReadingRec
             "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}")
+    @Results(id = "readRecordResultMap", value = {
+            @Result(column = "id", property = "id"),
+            @Result(column = "user_id", property = "user_id"),
+            @Result(column = "child_id", property = "child_id"),
+            @Result(column = "duration_seconds", property = "duration_seconds"),
+            @Result(column = "read_at", property = "read_at"),
+            @Result(column = "updated_at", property = "updated_at"),
+            @Result(column = "nickname", property = "nickname"),
+            @Result(column = "read_date", property = "read_date")
+    })
+    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);
 }

+ 29 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java

@@ -344,6 +344,35 @@ public class ArticleService {
         }
     }
 
+    /**
+     * 递增文章浏览数(打开详情页即计数,匿名/登录用户均计)
+     */
+    public void incrementViewCount(Long id) {
+        if (id == null) return;
+        articleMapper.update(null, Wrappers.<Article>lambdaUpdate()
+                .eq(Article::getId, id)
+                .setSql("view_count = view_count + 1"));
+    }
+
+    /**
+     * 按文章 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;
+    }
+
     public Article getById(Long id) {
         return articleMapper.selectById(id);
     }

+ 17 - 3
cfc-frontend/pages/article-center/article-detail.vue

@@ -19,8 +19,10 @@
           <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" v-if="!readingCompleted">{{ formatReadingTime(readingSeconds) }}</text>
-          <text class="reading-time-badge completed" v-else>✅ 阅读完成</text>
+           <text class="reading-time-badge" v-if="currentChildId && !readingCompleted">{{ formatReadingTime(readingSeconds) }}</text>
+           <text class="reading-time-badge completed" v-else-if="currentChildId && readingCompleted">✅ 阅读完成</text>
+           <text class="meta-sep" v-if="currentChildId">|</text>
+           <text class="meta-readcount">{{ article.viewCount || 0 }}人阅读</text>
         </view>
         <view class="detail-category-row">
           <text class="detail-category">{{ article.categoryName || '' }}</text>
@@ -93,6 +95,7 @@ export default {
       articleId: '', article: null, loading: true, error: false, errorMsg: '',
       readingSeconds: 0, lastReportedSeconds: 0, readingCompleted: false,
       isTimerRunning: false, timerHandle: null, syncHandle: null,
+      currentChildId: null,
       showPoster: false, posterQrCode: '',
       posterDimension: '', posterSummary: '', posterInviteText: '',
       // 答题
@@ -123,8 +126,13 @@ export default {
     }
     if (aid) { this.articleId = aid; this.loadDetail(aid) }
     else { this.error = true; this.errorMsg = '参数错误'; this.loading = false }
+    this.currentChildId = uni.getStorageSync('currentChildId') || null
+  },
+  onShow() {
+    var memberId = uni.getStorageSync('currentChildId')
+    if (!this.article || this.error || !memberId) return
+    this.startNewReadingSession()
   },
-  onShow() { if (this.article && !this.error) this.startNewReadingSession() },
   onHide() { this.pauseReadingTimer() },
   onUnload() { this.pauseReadingTimer() },
   methods: {
@@ -183,6 +191,8 @@ export default {
       this.startReadingTimer()
     },
     startReadingTimer: function() {
+      var memberId = uni.getStorageSync('currentChildId')
+      if (!memberId) return
       if (this.isTimerRunning || this.readingCompleted) return
       this.isTimerRunning = true
       var self = this
@@ -309,6 +319,10 @@ export default {
 .meta-sep { margin: 0 12rpx; color: #ddd; }
 .reading-time-badge { margin-left: auto; font-size: 20rpx; color: #5B9BD5; background: rgba(91,155,213,0.08); padding: 4rpx 12rpx; border-radius: 20rpx; white-space: nowrap; }
 .reading-time-badge.completed { color: #10B981; background: rgba(16,185,129,0.1); }
+.meta-readcount {
+  font-size: 20rpx;
+  color: #bbb;
+}
 .detail-category-row { padding: 16rpx 30rpx 0; }
 .detail-category { display: inline-block; font-size: 20rpx; color: #5B9BD5; background: rgba(91,155,213,0.1); padding: 4rpx 16rpx; border-radius: 8rpx; }
 .detail-dimensions { padding: 16rpx 30rpx 0; display: flex; gap: 12rpx; }

+ 5 - 0
cfc-frontend/pages/article-center/index.vue

@@ -107,6 +107,8 @@
               <text class="ac-card-sep">|</text>
               <text class="ac-card-date">{{ formatDate(item.publishedAt) }}</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>
             </view>
           </view>
         </view>
@@ -579,6 +581,9 @@ export default {
 .ac-card-fav {
   color: #ccc;
 }
+.ac-card-readcount {
+  color: #ccc;
+}
 
 /* ---- 加载更多 / 没有更多 ---- */
 .ac-loading-more,

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-72b1036290612581de4887424675c8364639d9a0
+73d213462caae132fa9f19279c4649f1b8ffa7de

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1258",
+  "version": "1.0.1259",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.1258",
+      "version": "1.0.1259",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1259",
+  "version": "1.0.1260",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 20 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,26 @@
 
 ---
 
+## v1.0.1260 (2026-08-30)
+
+### Bug 修复
+- 手风琴异常统计口径改为白名单
+
+### 其他
+- - 原来 'status !== '正常'' 会将未知/英文状态误判为异常(如 PHYLUM/CLASS 等层级行的空 status)
+- - _cssForStatus 增加中英文双语言映射,与 report-confirm 保持一致
+- 
+
+### 文档
+- DAN报告按类型分别处理实施计划
+- 记录DAN报告按类型分别处理实施计划
+
+### 新功能
+- DAN报告详情页复用blocks渲染器,按类型分别展示
+- DAN报告详情接口按类型重算blocks(历史数据生效)
+- DAN报告按category分组组装blocks(类型分别处理)
+
+
 ## v1.0.1259 (2026-08-30)
 
 ### 新功能

+ 21 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1259
+> 当前版本: v1.0.1260
 
 ## 历史版本
 
@@ -8,6 +8,26 @@
 
 ---
 
+## v1.0.1260 (2026-08-30)
+
+### Bug 修复
+- 手风琴异常统计口径改为白名单
+
+### 其他
+- - 原来 'status !== '正常'' 会将未知/英文状态误判为异常(如 PHYLUM/CLASS 等层级行的空 status)
+- - _cssForStatus 增加中英文双语言映射,与 report-confirm 保持一致
+- 
+
+### 文档
+- DAN报告按类型分别处理实施计划
+- 记录DAN报告按类型分别处理实施计划
+
+### 新功能
+- DAN报告详情页复用blocks渲染器,按类型分别展示
+- DAN报告详情接口按类型重算blocks(历史数据生效)
+- DAN报告按category分组组装blocks(类型分别处理)
+
+
 ## v1.0.1259 (2026-08-30)
 
 ### 新功能

+ 6 - 0
cfc-web/src/api/article.js

@@ -86,3 +86,9 @@ export function adminArticleDetail(data) {
 export function adminCommentList(data) {
   return request({ url: '/api/admin/articles/comments/list', method: 'post', data })
 }
+
+// ===== 文章阅读记录 =====
+
+export function adminArticleReadingRecords(data) {
+  return request({ url: '/api/admin/articles/reading-records', method: 'post', data })
+}

+ 95 - 3
cfc-web/src/views/admin/ArticleManage.vue

@@ -123,11 +123,12 @@
           </template>
         </el-table-column>
 
-        <el-table-column label="操作" width="200" fixed="right">
+        <el-table-column label="操作" width="260" fixed="right">
           <template slot-scope="{ row }">
             <!-- 草稿箱:可编辑 -->
             <template v-if="row.status === 'draft' && !row.auditStatus">
               <el-button size="mini" type="primary" @click="$router.push('/article-edit?id=' + row.id)">编辑</el-button>
+              <el-button size="mini" @click="openReadRecords(row)">阅读记录</el-button>
               <el-dropdown trigger="hover" @command="(cmd) => handleActionCmd(row, cmd)">
                 <el-button size="mini">
                   更多<i class="el-icon-arrow-down el-icon--right"></i>
@@ -143,6 +144,7 @@
             <!-- 待审核:审核操作 -->
             <template v-else-if="row.status === 'draft' && row.auditStatus === 'pending'">
               <el-button size="mini" type="primary" @click="$router.push('/article-edit?id=' + row.id + '&readonly=1')">查看</el-button>
+              <el-button size="mini" @click="openReadRecords(row)">阅读记录</el-button>
               <el-dropdown trigger="hover" @command="(cmd) => handleActionCmd(row, cmd)">
                 <el-button size="mini">
                   更多<i class="el-icon-arrow-down el-icon--right"></i>
@@ -157,6 +159,7 @@
             <!-- 已驳回:查看 + 重新编辑 -->
             <template v-else-if="row.status === 'rejected'">
               <el-button size="mini" type="primary" @click="$router.push('/article-edit?id=' + row.id + '&readonly=1')">查看</el-button>
+              <el-button size="mini" @click="openReadRecords(row)">阅读记录</el-button>
               <el-dropdown trigger="hover" @command="(cmd) => handleActionCmd(row, cmd)">
                 <el-button size="mini">
                   更多<i class="el-icon-arrow-down el-icon--right"></i>
@@ -170,6 +173,7 @@
             <!-- 发布中:编辑 + 下架 + 精选 -->
             <template v-else-if="row.status === 'published'">
               <el-button size="mini" type="primary" @click="$router.push('/article-edit?id=' + row.id)">编辑</el-button>
+              <el-button size="mini" @click="openReadRecords(row)">阅读记录</el-button>
               <el-dropdown trigger="hover" @command="(cmd) => handleActionCmd(row, cmd)">
                 <el-button size="mini">
                   更多<i class="el-icon-arrow-down el-icon--right"></i>
@@ -184,6 +188,7 @@
             <!-- 已撤回:查看 + 重新发布 -->
             <template v-else-if="row.status === 'withdrawn'">
               <el-button size="mini" type="primary" @click="$router.push('/article-edit?id=' + row.id + '&readonly=1')">查看</el-button>
+              <el-button size="mini" @click="openReadRecords(row)">阅读记录</el-button>
               <el-dropdown trigger="hover" @command="(cmd) => handleActionCmd(row, cmd)">
                 <el-button size="mini">
                   更多<i class="el-icon-arrow-down el-icon--right"></i>
@@ -197,6 +202,7 @@
             <!-- 其他 -->
             <template v-else>
               <el-button size="mini" type="primary" @click="$router.push('/article-edit?id=' + row.id + '&readonly=1')">查看</el-button>
+              <el-button size="mini" @click="openReadRecords(row)">阅读记录</el-button>
               <el-dropdown trigger="hover" @command="(cmd) => handleActionCmd(row, cmd)">
                 <el-button size="mini">
                   更多<i class="el-icon-arrow-down el-icon--right"></i>
@@ -225,6 +231,46 @@
     </el-card>
     </div>
 
+  <!-- 阅读记录弹窗 -->
+  <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">
+          <template slot-scope="{ row }">
+            {{ row.nickname || '-' }}
+          </template>
+        </el-table-column>
+        <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>
+
   <!-- 驳回弹窗 -->
   <el-dialog title="驳回原因" :visible.sync="rejectDialogVisible" width="400px">
     <el-input
@@ -254,7 +300,8 @@ import {
   adminArticleCreate,
   adminArticleSubmitReview,
   adminArticleWithdraw,
-  adminArticleReDraft
+  adminArticleReDraft,
+  adminArticleReadingRecords
 } from '@/api/article.js'
 
 import { getTagList } from '@/api/dimension.js'
@@ -297,7 +344,14 @@ export default {
       tagOptions: [],
       rejectDialogVisible: false,
       rejectReason: '',
-      currentAuditRow: null
+      currentAuditRow: null,
+      readRecordVisible: false,
+      readRecordArticleId: null,
+      readRecordList: [],
+      readRecordTotal: 0,
+      readRecordPage: 1,
+      readRecordSize: 20,
+      readRecordLoading: false
     }
   },
   created() {
@@ -503,6 +557,44 @@ export default {
       if (!t) return '-'
       return t.replace ? t.replace('T', ' ').substring(0, 19) : t
     },
+    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 + '秒'
+    },
+    openReadRecords(row) {
+      this.readRecordArticleId = row.id
+      this.readRecordPage = 1
+      this.readRecordList = []
+      this.readRecordTotal = 0
+      this.readRecordVisible = true
+      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()
+    },
     handleActionCmd(row, cmd) {
       switch (cmd) {
         case 'submitReview': this.handleSubmitReview(row); break;

+ 1 - 0
docs/superpowers/PROJECT-OVERVIEW.md

@@ -179,6 +179,7 @@
 | 文章中心主页(发现页重建) | ✅ 已完成(4页面 1429行 + ArticleController + DB表) | `specs/2026-07-03-article-center-design.md` + `plans/2026-07-03-article-center-implementation.md` |
 | 文章发布系统(运营审核流程) | ✅ 已实施(AdminArticleController + ReviewCenterController + ArticleService.auditArticle) | `plans/2026-07-22-comprehensive-implementation.md` |
 | 文章与活动发布系统(合并版) | ✅ 已实施(同上) | `specs/2026-06-09-article-activity-publishing-design.md` |
+| 文章阅读追踪增强(阅读数显示+匿名/登录区分+后台阅读记录查询) | ✅ 已实施(detail 接口递增 view_count;匿名不显示时长;`POST /api/admin/articles/reading-records`;后台弹窗) | `specs/2026-07-28-article-reading-time-tracking-design.md` + `plans/2026-08-30-article-reading-tracking-enhancement.md` |
 | 公众号内容发布(LangGraph工作流) | 🔄 已废弃(由LangGraph + Python发布脚本替代) | — |
 
 ---

+ 3 - 0
docs/superpowers/api/API_REFERENCE.md

@@ -373,6 +373,8 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `POST /api/articles/detail` | 文章详情 |
 | `POST /api/articles/featured` | 精选文章 |
 | `POST /api/articles/record-read` | 记录阅读 |
+| `POST /api/articles/report-reading-time` | 上报阅读时长(按 articleId+childId+日期累加,首次上报递增 view_count) |
+| `POST /api/articles/reading-stats` | 阅读统计 |
 | `POST /api/articles/daily-tip` | 每日提示 |
 | `POST /api/articles/ai-questions` | AI 生成问题 |
 | `POST /api/articles/submit-answers` | 提交答案 |
@@ -392,6 +394,7 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | 路径前缀 | 说明 |
 |----------|------|
 | `/api/admin/articles/*` | 文章管理 |
+| `/api/admin/articles/reading-records` | 按文章查询阅读记录明细(分页,含孩子昵称/时长) |
 | `/api/admin/assessment/*` | 测评材料管理 |
 | `/api/admin/bazi-config/*` | 八字配置 |
 | `/api/admin/blood-type-config/*` | 血型配置 |