Prechádzať zdrojové kódy

feat(admin): 商品停留改按累计排序 + 新增停留时段热力图

1. 商品停留时长 Top 由「平均停留」排序改为「累计停留」排序,抗单用户单次超长刷时长,更反映商品真实总吸引力。
2. 新增「内容吸引力 · 停留时段热力」:复用已预取的 articleReads/productViews,按 0-23 点聚合近30天真实停留时长,返回 dwellByHour/dwellByHourTotal/dwellByHourPeak。
3. Dashboard 渲染 24 小时热力图(颜色深度=相对峰值停留),含时段轴、图例、合计,帮助运营把握内容推送黄金时段;商品卡片副文案同步改「按累计停留排序」。

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
Agnes Agent 2 týždňov pred
rodič
commit
5af7fdcd40

+ 32 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/stats/StatsController.java

@@ -707,12 +707,42 @@ public class StatsController {
                 m.put("views", v[1]);
                 productDwellList.add(m);
             });
+            // 按「累计停留时长」排序(抗单用户单次超长刷时长,比平均值更反映真实总吸引力)
             productDwellList.sort((x, y) -> Long.compare(
-                    ((Number) y.get("avgDurationSeconds")).longValue(),
-                    ((Number) x.get("avgDurationSeconds")).longValue()));
+                    ((Number) y.get("totalDurationSeconds")).longValue(),
+                    ((Number) x.get("totalDurationSeconds")).longValue()));
             if (productDwellList.size() > 10) productDwellList = productDwellList.subList(0, 10);
             data.put("productDwellList", productDwellList);
 
+            // 10. 停留时长 · 时段分布(近30天真实停留,按小时聚合 0-23 点)
+            // 复用已预取的 articleReads / productViews,避免额外查询
+            long[] dwellByHour = new long[24];
+            Calendar hourCal = Calendar.getInstance();
+            for (ArticleReadingRecord r : articleReads) {
+                if (r.getReadAt() != null && r.getDurationSeconds() != null) {
+                    hourCal.setTime(r.getReadAt());
+                    int h = hourCal.get(Calendar.HOUR_OF_DAY);
+                    if (h >= 0 && h < 24) dwellByHour[h] += r.getDurationSeconds();
+                }
+            }
+            for (ProductViewRecord r : productViews) {
+                if (r.getViewAt() != null && r.getDurationSeconds() != null) {
+                    hourCal.setTime(r.getViewAt());
+                    int h = hourCal.get(Calendar.HOUR_OF_DAY);
+                    if (h >= 0 && h < 24) dwellByHour[h] += r.getDurationSeconds();
+                }
+            }
+            List<Long> dwellByHourList = new ArrayList<>();
+            long dwellByHourTotal = 0, dwellByHourPeak = 0;
+            for (long v : dwellByHour) {
+                dwellByHourList.add(v);
+                dwellByHourTotal += v;
+                if (v > dwellByHourPeak) dwellByHourPeak = v;
+            }
+            data.put("dwellByHour", dwellByHourList);
+            data.put("dwellByHourTotal", dwellByHourTotal);
+            data.put("dwellByHourPeak", dwellByHourPeak);
+
         } catch (Exception e) {
             log.error("获取流量分析数据失败", e);
             return Result.error("获取流量分析数据失败: " + e.getMessage());

+ 94 - 3
cfc-web/src/views/Dashboard.vue

@@ -78,6 +78,38 @@
       </el-col>
     </el-row>
 
+    <!-- 停留时段热力(运营看「一天中哪些时段内容最抓人」) -->
+    <div class="adm-section-title">内容吸引力 · 停留时段热力</div>
+    <div class="section-caption">近 30 天真实停留时长按小时聚合(文章阅读 + 商品浏览),颜色越深代表该时段用户停留越久,帮助运营把握内容推送黄金时段</div>
+    <el-card class="heatmap-card" shadow="never" v-loading="trafficLoading">
+      <div class="heatmap" v-if="dwellByHour.length">
+        <div class="heatmap__row">
+          <div
+            v-for="(v, h) in dwellByHour"
+            :key="h"
+            class="heatmap__cell"
+            :style="heatCellStyle(v)"
+            :title="h + ':00-' + h + ':59 | 真实停留 ' + fmtDuration(v)"
+          >
+            <span class="heatmap__hour" v-if="h % 3 === 0">{{ h }}</span>
+          </div>
+        </div>
+        <div class="heatmap__axis">
+          <span>0</span><span>6</span><span>12</span><span>18</span><span>23 时</span>
+        </div>
+        <div class="heatmap__legend">
+          <span class="heatmap__legend-label">浅</span>
+          <span class="heatmap__legend-bar"></span>
+          <span class="heatmap__legend-label">深(停留越久)</span>
+          <span class="heatmap__total">近 30 天真实停留合计 <b>{{ fmtDuration(dwellByHourTotal) }}</b></span>
+        </div>
+      </div>
+      <div v-else class="dwell-empty">
+        <i class="el-icon-data-analysis"></i>
+        <span>暂无真实停留数据,文章阅读 / 商品浏览埋点接入后展示</span>
+      </div>
+    </el-card>
+
     <!-- 内容广度 · 热度排行 -->
     <div class="adm-section-title">内容广度 · 热度排行</div>
     <div class="section-caption">以下均基于用户真实行为数据(实际阅读记录 / 真实成交订单 / 真实分享点击),不使用文章浏览计数等虚高指标</div>
@@ -244,6 +276,10 @@ export default {
       articleDwell: [],
       productDwell: [],
       userDwell: [],
+      // 停留时段热力(近30天真实停留按小时聚合 0-23 点)
+      dwellByHour: [],
+      dwellByHourPeak: 0,
+      dwellByHourTotal: 0,
       // 热度排行(归一化后用于渲染)
       hotShares: [],
       hotArticles: [],
@@ -275,7 +311,7 @@ export default {
       ],
       dwellSections: [
         { key: 'articleDwell', title: '文章停留时长 Top', sub: '按平均停留排序 · 近30天', icon: 'el-icon-document', iconClass: 'sec--primary', emptyHint: '暂无文章阅读停留数据' },
-        { key: 'productDwell', title: '商品停留时长 Top', sub: '按平均停留排序 · 近30天', icon: 'el-icon-shopping-bag-1', iconClass: 'sec--warning', emptyHint: '商品详情页埋点接入后展示' },
+        { key: 'productDwell', title: '商品停留时长 Top', sub: '按累计停留排序 · 近30天', icon: 'el-icon-shopping-bag-1', iconClass: 'sec--warning', emptyHint: '商品详情页埋点接入后展示' },
         { key: 'userDwell', title: '用户停留时长 Top', sub: '阅读+分享累计 · 近30天', icon: 'el-icon-user', iconClass: 'sec--success', emptyHint: '暂无用户停留数据' }
       ],
       hotSections: [
@@ -355,6 +391,14 @@ export default {
       if (!max) return 0
       return Math.max(4, Math.round((Number(value) || 0) / max * 100))
     },
+    // 时段热力单元格颜色:按相对峰值线性加深(浅蓝 -> 品牌主色 #1677FF)
+    heatCellStyle(v) {
+      const peak = this.dwellByHourPeak || 0
+      if (!peak || !v) return { background: 'var(--adm-fill-quaternary, #f0f3f7)' }
+      const ratio = Math.min(1, Number(v) / peak)
+      const alpha = (0.1 + ratio * 0.9).toFixed(3)
+      return { background: `rgba(22, 119, 255, ${alpha})` }
+    },
     fmtDuration(sec) {
       sec = Number(sec) || 0
       if (sec < 60) return sec + '秒'
@@ -408,14 +452,18 @@ export default {
           }))
           this.productDwell = (d.productDwellList || []).map(it => ({
             name: it.productName,
-            value: it.avgDurationSeconds || 0,
-            meta: (it.views || 0) + ' 次浏览'
+            value: it.totalDurationSeconds || 0,
+            meta: '平均 ' + this.fmtDuration(it.avgDurationSeconds || 0) + ' · ' + (it.views || 0) + ' 次浏览'
           }))
           this.userDwell = (d.userDwellList || []).map(it => ({
             name: it.userName,
             value: it.totalDurationSeconds || 0,
             meta: '累计停留'
           }))
+          // 停留时段热力
+          this.dwellByHour = d.dwellByHour || []
+          this.dwellByHourPeak = d.dwellByHourPeak || 0
+          this.dwellByHourTotal = d.dwellByHourTotal || 0
           // 热度 TOP5(归一化)
           this.hotShares = (d.hotShareList || []).slice(0, 5).map(it => ({
             name: it.shareTitle || this.shareTypeLabel(it.targetType),
@@ -919,6 +967,49 @@ export default {
 }
 .dwell-empty i { font-size: 26px; color: var(--adm-text-25); }
 
+/* ===== 停留时段热力图 ===== */
+.heatmap-card { margin-bottom: 24px; border-radius: var(--adm-radius-card); }
+.heatmap { padding: 8px 4px; }
+.heatmap__row { display: flex; gap: 3px; }
+.heatmap__cell {
+  flex: 1;
+  min-width: 0;
+  height: 48px;
+  border-radius: 4px;
+  display: flex;
+  align-items: flex-end;
+  justify-content: center;
+  cursor: default;
+  transition: background 0.25s ease;
+  overflow: hidden;
+}
+.heatmap__cell:hover { opacity: 0.88; }
+.heatmap__hour {
+  font-size: 11px;
+  font-weight: 600;
+  color: rgba(255,255,255,0.88);
+  line-height: 1;
+  margin-bottom: 3px;
+}
+.heatmap__axis { display: flex; justify-content: space-between; margin-top: 6px; }
+.heatmap__axis span { font-size: 11px; color: var(--adm-text-45); }
+.heatmap__legend {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-top: 8px;
+  font-size: 12px;
+  color: var(--adm-text-65);
+}
+.heatmap__legend-bar {
+  flex: 1;
+  height: 8px;
+  border-radius: 4px;
+  background: linear-gradient(90deg, #e6f4ff, #1677FF);
+}
+.heatmap__total { margin-left: auto; color: var(--adm-text-65); white-space: nowrap; }
+.heatmap__total b { color: var(--adm-primary); font-weight: 700; }
+
 /* ===== 图表 ===== */
 .chart-row { margin-bottom: 8px; }
 .chart-card {