Explorar el Código

小红书兼容

Ethanfly hace 1 día
padre
commit
f69a897a1a

+ 0 - 11
client/src/components.d.ts

@@ -15,17 +15,11 @@ declare module 'vue' {
     ElBadge: typeof import('element-plus/es')['ElBadge']
     ElButton: typeof import('element-plus/es')['ElButton']
     ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
-    ElCascader: typeof import('element-plus/es')['ElCascader']
     ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
-    ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
-    ElCol: typeof import('element-plus/es')['ElCol']
     ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
     ElContainer: typeof import('element-plus/es')['ElContainer']
     ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
-    ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
-    ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
     ElDialog: typeof import('element-plus/es')['ElDialog']
-    ElDivider: typeof import('element-plus/es')['ElDivider']
     ElDrawer: typeof import('element-plus/es')['ElDrawer']
     ElDropdown: typeof import('element-plus/es')['ElDropdown']
     ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
@@ -34,17 +28,14 @@ declare module 'vue' {
     ElForm: typeof import('element-plus/es')['ElForm']
     ElFormItem: typeof import('element-plus/es')['ElFormItem']
     ElIcon: typeof import('element-plus/es')['ElIcon']
-    ElImage: typeof import('element-plus/es')['ElImage']
     ElInput: typeof import('element-plus/es')['ElInput']
     ElMain: typeof import('element-plus/es')['ElMain']
     ElMenu: typeof import('element-plus/es')['ElMenu']
     ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
     ElOption: typeof import('element-plus/es')['ElOption']
     ElPagination: typeof import('element-plus/es')['ElPagination']
-    ElProgress: typeof import('element-plus/es')['ElProgress']
     ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
     ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
-    ElRow: typeof import('element-plus/es')['ElRow']
     ElSelect: typeof import('element-plus/es')['ElSelect']
     ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
     ElSwitch: typeof import('element-plus/es')['ElSwitch']
@@ -53,8 +44,6 @@ declare module 'vue' {
     ElTabPane: typeof import('element-plus/es')['ElTabPane']
     ElTabs: typeof import('element-plus/es')['ElTabs']
     ElTag: typeof import('element-plus/es')['ElTag']
-    ElText: typeof import('element-plus/es')['ElText']
-    ElUpload: typeof import('element-plus/es')['ElUpload']
     Icons: typeof import('./components/icons/index.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']

+ 313 - 66
client/src/views/Analytics/Work/index.vue

@@ -203,42 +203,106 @@
             <div class="core-data-content">
               <!-- 流量数据卡片 -->
               <div class="traffic-data">
-                <h4 class="section-title">流量数据</h4>
+                <div class="section-title-row">
+                  <h4 class="section-title">流量数据</h4>
+                </div>
                 <div class="data-cards">
-                  <div class="data-card highlight">
-                    <div class="card-label">播放量</div>
-                    <div class="card-value">{{ formatNumber(workDetailData.playCount || selectedWork.viewsCount || 0) }}</div>
-                  </div>
-                  <div class="data-card">
-                    <div class="card-label">播放总时长</div>
-                    <div class="card-value">{{ workDetailData.totalWatchDuration || '0秒' }}</div>
-                  </div>
-                  <div class="data-card">
-                    <div class="card-label">点赞量</div>
-                    <div class="card-value">{{ formatNumber(workDetailData.likeCount || selectedWork.likesCount || 0) }}</div>
-                  </div>
-                  <div class="data-card">
-                    <div class="card-label">评论量</div>
-                    <div class="card-value">{{ formatNumber(workDetailData.commentCount || selectedWork.commentsCount || 0) }}</div>
-                  </div>
-                  <div class="data-card">
-                    <div class="card-label">收藏量</div>
-                    <div class="card-value">{{ formatNumber(workDetailData.collectCount || selectedWork.collectsCount || 0) }}</div>
-                  </div>
-                  <div class="data-card">
-                    <div class="card-label">分享量</div>
-                    <div class="card-value">{{ formatNumber(workDetailData.shareCount || selectedWork.sharesCount || 0) }}</div>
-                  </div>
-                  <div class="data-card">
-                    <div class="card-label">涨粉量</div>
-                    <div class="card-value">{{ formatNumber(workDetailData.fansIncrease || 0) }}</div>
-                  </div>
+                  <!-- 小红书:指标更多 + 支持点选联动趋势图 -->
+                  <template v-if="selectedWork.platform === 'xiaohongshu'">
+                    <div
+                      v-for="item in xhsMetricCards"
+                      :key="item.key"
+                      class="data-card"
+                      :class="{ highlight: activeTrendMetric === item.key }"
+                      role="button"
+                      tabindex="0"
+                      @click="setTrendMetric(item.key)"
+                      @keyup.enter="setTrendMetric(item.key)"
+                    >
+                      <div class="card-label">{{ item.label }}</div>
+                      <div class="card-value">{{ item.value }}</div>
+                    </div>
+                  </template>
+
+                  <!-- 非小红书:保持原口径 -->
+                  <template v-else>
+                    <div
+                      class="data-card highlight"
+                      role="button"
+                      tabindex="0"
+                      @click="setTrendMetric('playCount')"
+                      @keyup.enter="setTrendMetric('playCount')"
+                    >
+                      <div class="card-label">播放量</div>
+                      <div class="card-value">{{ formatNumber(workDetailData.playCount || 0) }}</div>
+                    </div>
+                    <div
+                      class="data-card"
+                      role="button"
+                      tabindex="0"
+                      @click="setTrendMetric('totalWatchDuration')"
+                      @keyup.enter="setTrendMetric('totalWatchDuration')"
+                    >
+                      <div class="card-label">播放总时长</div>
+                      <div class="card-value">{{ workDetailData.totalWatchDuration || '0秒' }}</div>
+                    </div>
+                    <div
+                      class="data-card"
+                      role="button"
+                      tabindex="0"
+                      @click="setTrendMetric('likeCount')"
+                      @keyup.enter="setTrendMetric('likeCount')"
+                    >
+                      <div class="card-label">点赞量</div>
+                      <div class="card-value">{{ formatNumber(workDetailData.likeCount || 0) }}</div>
+                    </div>
+                    <div
+                      class="data-card"
+                      role="button"
+                      tabindex="0"
+                      @click="setTrendMetric('commentCount')"
+                      @keyup.enter="setTrendMetric('commentCount')"
+                    >
+                      <div class="card-label">评论量</div>
+                      <div class="card-value">{{ formatNumber(workDetailData.commentCount || 0) }}</div>
+                    </div>
+                    <div
+                      class="data-card"
+                      role="button"
+                      tabindex="0"
+                      @click="setTrendMetric('collectCount')"
+                      @keyup.enter="setTrendMetric('collectCount')"
+                    >
+                      <div class="card-label">收藏量</div>
+                      <div class="card-value">{{ formatNumber(workDetailData.collectCount || 0) }}</div>
+                    </div>
+                    <div
+                      class="data-card"
+                      role="button"
+                      tabindex="0"
+                      @click="setTrendMetric('shareCount')"
+                      @keyup.enter="setTrendMetric('shareCount')"
+                    >
+                      <div class="card-label">分享量</div>
+                      <div class="card-value">{{ formatNumber(workDetailData.shareCount || 0) }}</div>
+                    </div>
+                    <div
+                      class="data-card"
+                      role="button"
+                      tabindex="0"
+                      @click="setTrendMetric('fansIncrease')"
+                      @keyup.enter="setTrendMetric('fansIncrease')"
+                    >
+                      <div class="card-label">涨粉量</div>
+                      <div class="card-value">{{ formatNumber(workDetailData.fansIncrease || 0) }}</div>
+                    </div>
+                  </template>
                 </div>
               </div>
 
               <!-- 播放量趋势 -->
               <div class="trend-section">
-                <h4 class="section-title">播放量趋势</h4>
+                <h4 class="section-title">{{ trendTitle }}</h4>
                 <div ref="playTrendChartRef" style="height: 300px" v-loading="detailLoading"></div>
               </div>
             </div>
@@ -354,34 +418,112 @@ const workList = ref<WorkData[]>([]);
 // 详情弹窗相关
 const drawerVisible = ref(false);
 const selectedWork = ref<WorkData | null>(null);
+// 作品基础信息(来自 works 表 /api/works/:id)
+const selectedWorkBase = ref<any | null>(null);
 const activeTab = ref('core');
 const detailLoading = ref(false);
 
 // 作品详情数据
 interface WorkDetailData {
   playCount: number;
+  exposureCount: number;
   totalWatchDuration: string;
   likeCount: number;
   commentCount: number;
   collectCount: number;
   shareCount: number;
   fansIncrease: number;
+  coverClickRate: string;
+  avgWatchDuration: string;
+  completionRate: string;
+  twoSecondExitRate: string;
 }
 
 const workDetailData = ref<WorkDetailData>({
   playCount: 0,
+  exposureCount: 0,
   totalWatchDuration: '0秒',
   likeCount: 0,
   commentCount: 0,
   collectCount: 0,
   shareCount: 0,
   fansIncrease: 0,
+  coverClickRate: '0',
+  avgWatchDuration: '0',
+  completionRate: '0',
+  twoSecondExitRate: '0',
 });
 
 // 播放量趋势图
 const playTrendChartRef = ref<HTMLElement>();
 let playTrendChart: echarts.ECharts | null = null;
 
+type TrendMetricKey =
+  | 'exposureCount'
+  | 'playCount'
+  | 'likeCount'
+  | 'commentCount'
+  | 'collectCount'
+  | 'shareCount'
+  | 'coverClickRate'
+  | 'avgWatchDuration'
+  | 'completionRate'
+  | 'twoSecondExitRate'
+  | 'fansIncrease'
+  | 'totalWatchDuration';
+
+const activeTrendMetric = ref<TrendMetricKey>('playCount');
+const workStatsHistory = ref<any[]>([]);
+
+function toIntSafe(v: any): number {
+  const n = Number(String(v ?? '0').replace(/[^\d.-]/g, ''));
+  if (!Number.isFinite(n)) return 0;
+  return Math.max(0, Math.trunc(n));
+}
+
+const trendTitle = computed(() => {
+  if (!selectedWork.value) return '趋势';
+  if (selectedWork.value.platform !== 'xiaohongshu') {
+    const map: Record<TrendMetricKey, string> = {
+      playCount: '播放量趋势',
+      totalWatchDuration: '播放总时长趋势',
+      likeCount: '点赞量趋势',
+      commentCount: '评论量趋势',
+      collectCount: '收藏量趋势',
+      shareCount: '分享量趋势',
+      fansIncrease: '涨粉量趋势',
+      exposureCount: '曝光量趋势',
+      coverClickRate: '封面点击率趋势',
+      avgWatchDuration: '平均观看时长趋势',
+      completionRate: '完播率趋势',
+      twoSecondExitRate: '2s退出率趋势',
+    };
+    return map[activeTrendMetric.value] || '趋势';
+  }
+  const map: Record<TrendMetricKey, string> = {
+    exposureCount: '曝光量趋势',
+    playCount: '播放(阅读)量趋势',
+    likeCount: '点赞量趋势',
+    commentCount: '评论量趋势',
+    collectCount: '收藏量趋势',
+    shareCount: '分享量趋势',
+    coverClickRate: '封面点击率趋势',
+    avgWatchDuration: '平均观看时长趋势',
+    completionRate: '完播率趋势',
+    twoSecondExitRate: '2s退出率趋势',
+    fansIncrease: '涨粉量趋势',
+    totalWatchDuration: '播放总时长趋势',
+  };
+  return map[activeTrendMetric.value] || '趋势';
+});
+
+function setTrendMetric(key: TrendMetricKey) {
+  activeTrendMetric.value = key;
+  if (workStatsHistory.value.length > 0) {
+    updatePlayTrendChart(workStatsHistory.value);
+  }
+}
+
 function getPlatformName(platform: PlatformType) {
   return PLATFORMS[platform]?.name || platform;
 }
@@ -557,70 +699,139 @@ function formatNumber(num: number | null | undefined): string {
   return String(num);
 }
 
+function formatDurationSeconds(secLike: any): string {
+  const s = Math.max(0, parseInt(String(secLike ?? '0'), 10) || 0);
+  if (s >= 60) return `${Math.floor(s / 60)}分${s % 60}秒`;
+  return `${s}秒`;
+}
+
+function formatRate(rateLike: any): string {
+  const raw = String(rateLike ?? '0').trim();
+  if (!raw) return '0%';
+  if (raw.includes('%')) return raw;
+  const n = Number(raw);
+  if (Number.isNaN(n)) return raw;
+  // 兼容:有的入库是 0.12(表示 12%),有的是 12(表示 12%)
+  const pct = n <= 1 ? n * 100 : n;
+  return `${pct.toFixed(2).replace(/\.00$/, '')}%`;
+}
+
+function calcDetailRangeDatesFixed14Days(): { start: string; end: string } {
+  const end = dayjs(endDate.value || dayjs().format('YYYY-MM-DD'));
+  const start = end.subtract(13, 'day'); // 近14天(含当天)
+  const publish = dayjs(selectedWork.value?.publishTime);
+  const clampedStart = publish.isValid() && publish.isAfter(start) ? publish : start;
+  return { start: clampedStart.format('YYYY-MM-DD'), end: end.format('YYYY-MM-DD') };
+}
+
+const xhsMetricCards = computed(() => {
+  const d = workDetailData.value;
+  return [
+    { key: 'exposureCount' as const, label: '曝光量', value: formatNumber(d.exposureCount || 0) },
+    { key: 'playCount' as const, label: '播放(阅读)量', value: formatNumber(d.playCount || selectedWork.value?.viewsCount || 0) },
+    { key: 'likeCount' as const, label: '点赞量', value: formatNumber(d.likeCount || selectedWork.value?.likesCount || 0) },
+    { key: 'commentCount' as const, label: '评论量', value: formatNumber(d.commentCount || selectedWork.value?.commentsCount || 0) },
+    { key: 'collectCount' as const, label: '收藏量', value: formatNumber(d.collectCount || selectedWork.value?.collectsCount || 0) },
+    { key: 'shareCount' as const, label: '分享量', value: formatNumber(d.shareCount || selectedWork.value?.sharesCount || 0) },
+    { key: 'coverClickRate' as const, label: '封面点击率', value: formatRate(d.coverClickRate) },
+    { key: 'avgWatchDuration' as const, label: '平均观看时长', value: formatDurationSeconds(d.avgWatchDuration) },
+    { key: 'completionRate' as const, label: '完播率', value: formatRate(d.completionRate) },
+    { key: 'twoSecondExitRate' as const, label: '2s退出率', value: formatRate(d.twoSecondExitRate) },
+    { key: 'fansIncrease' as const, label: '涨粉量', value: formatNumber(d.fansIncrease || 0) },
+  ];
+});
+
 // 查看详情
 async function handleView(row: WorkData) {
   selectedWork.value = row;
+  selectedWorkBase.value = null;
+  activeTrendMetric.value = row.platform === 'xiaohongshu' ? 'exposureCount' : 'playCount';
+  workStatsHistory.value = [];
   drawerVisible.value = true;
   activeTab.value = 'core';
   
-  // 重置数据
+  // 先用列表行做“瞬时占位”(列表来自区间汇总,可能不等于 works 表累计值)
   workDetailData.value = {
     playCount: row.viewsCount || 0,
+    exposureCount: 0,
     totalWatchDuration: '0秒',
     likeCount: row.likesCount || 0,
     commentCount: row.commentsCount || 0,
     collectCount: row.collectsCount || 0,
     shareCount: row.sharesCount || 0,
     fansIncrease: 0,
+    coverClickRate: '0',
+    avgWatchDuration: '0',
+    completionRate: '0',
+    twoSecondExitRate: '0',
   };
   
-  // 加载作品详情数据
+  // 1) 加载 works 表基础信息(标题、发布时间、累计播放/点赞等)
+  await loadWorkBase(row.id);
+  // 2) 加载 work_day_statistics 历史快照(用于“最新累计值”与趋势)
   await loadWorkDetail(row.id);
 }
 
+// 加载作品基础信息(works 表)
+async function loadWorkBase(workId: number) {
+  try {
+    const data = await request.get(`/api/works/${workId}`);
+    if (!data) return;
+    selectedWorkBase.value = data;
+
+    // 基础信息补齐:以 works 表为准(如果 works 缺失字段则回退到列表行)
+    if (selectedWork.value) {
+      selectedWork.value = {
+        ...selectedWork.value,
+        title: data.title || selectedWork.value.title,
+        publishTime: data.publishTime || selectedWork.value.publishTime,
+        coverUrl: data.coverUrl || selectedWork.value.coverUrl,
+      };
+    }
+
+    // 顶部卡片:按需求展示 works.yesterday_*(昨日快照)
+    workDetailData.value = {
+      playCount: toIntSafe(data.yesterdayPlayCount ?? 0),
+      exposureCount: toIntSafe(data.yesterdayExposureCount ?? 0),
+      totalWatchDuration: workDetailData.value.totalWatchDuration || '0秒',
+      likeCount: toIntSafe(data.yesterdayLikeCount ?? 0),
+      commentCount: toIntSafe(data.yesterdayCommentCount ?? 0),
+      collectCount: toIntSafe(data.yesterdayCollectCount ?? 0),
+      shareCount: toIntSafe(data.yesterdayShareCount ?? 0),
+      fansIncrease: toIntSafe(data.yesterdayFansIncrease ?? 0),
+      coverClickRate: String(data.yesterdayCoverClickRate ?? '0'),
+      avgWatchDuration: String(data.yesterdayAvgWatchDuration ?? '0'),
+      completionRate: String(data.yesterdayCompletionRate ?? '0'),
+      twoSecondExitRate: String(data.yesterdayTwoSecondExitRate ?? '0'),
+    };
+  } catch (error) {
+    // works 表请求失败不影响后续趋势展示
+    console.warn('加载作品基础信息失败:', error);
+  }
+}
+
 // 加载作品详情数据(历史统计数据)
 async function loadWorkDetail(workId: number) {
   detailLoading.value = true;
   
   try {
-    // 计算日期范围:从作品发布时间到结束日期(或今天)
-    const publishDate = dayjs(selectedWork.value?.publishTime);
-    const queryEndDate = dayjs(endDate.value || dayjs().format('YYYY-MM-DD'));
-    const startDateStr = publishDate.isValid() ? publishDate.format('YYYY-MM-DD') : dayjs().subtract(30, 'day').format('YYYY-MM-DD');
-    const endDateStr = queryEndDate.format('YYYY-MM-DD');
+    // 趋势固定:近 14 天(按 work_day_statistics 日新增量口径)
+    const { start: startDateStr, end: endDateStr } = calcDetailRangeDatesFixed14Days();
     
-    // 调用接口获取该作品的历史统计数据
+    // 调用接口获取该作品的历史统计数据(work_day_statistics 快照)
     const data = await request.get(`/api/work-day-statistics/work/${workId}`, {
       params: {
-        start_date: startDateStr,
-        end_date: endDateStr,
+        // 注意:后端校验参数名为 startDate/endDate
+        startDate: startDateStr,
+        endDate: endDateStr,
       },
     });
     
     if (data && Array.isArray(data)) {
       const workStats = data;
+      workStatsHistory.value = workStats;
       
-      // 计算汇总数据(取最新一条记录的累计值)
-      if (workStats.length > 0) {
-        const latest = workStats[workStats.length - 1];
-        const totalWatchDuration = latest.totalWatchDuration || '0';
-        const durationSeconds = parseInt(totalWatchDuration) || 0;
-        const durationStr = durationSeconds >= 60 
-          ? `${Math.floor(durationSeconds / 60)}分${durationSeconds % 60}秒`
-          : `${durationSeconds}秒`;
-        
-        workDetailData.value = {
-          playCount: latest.playCount || 0,
-          totalWatchDuration: durationStr,
-          likeCount: latest.likeCount || 0,
-          commentCount: latest.commentCount || 0,
-          collectCount: latest.collectCount || 0,
-          shareCount: latest.shareCount || 0,
-          fansIncrease: latest.fansIncrease || 0,
-        };
-      }
-      
-      // 绘制播放量趋势图
+      // 绘制趋势图(按天新增量)
       await nextTick();
       updatePlayTrendChart(workStats);
     }
@@ -633,7 +844,7 @@ async function loadWorkDetail(workId: number) {
 }
 
 // 更新播放量趋势图
-function updatePlayTrendChart(stats: Array<{ recordDate: string; playCount: number }>) {
+function updatePlayTrendChart(stats: Array<any>) {
   if (!playTrendChartRef.value) return;
   
   if (!playTrendChart) {
@@ -646,7 +857,27 @@ function updatePlayTrendChart(stats: Array<{ recordDate: string; playCount: numb
   );
   
   const dates = sortedStats.map(s => dayjs(s.recordDate).format('YYYY-MM-DD'));
-  const playCounts = sortedStats.map(s => s.playCount || 0);
+
+  const metric = activeTrendMetric.value;
+  const seriesName = trendTitle.value.replace('趋势', '');
+
+  const values = sortedStats.map((s) => {
+    const v = s?.[metric];
+    if (metric === 'coverClickRate' || metric === 'completionRate' || metric === 'twoSecondExitRate') {
+      const raw = String(v ?? '0').trim();
+      if (raw.includes('%')) return Number(raw.replace('%', '')) || 0;
+      const n = Number(raw);
+      if (Number.isNaN(n)) return 0;
+      return n <= 1 ? n * 100 : n;
+    }
+    if (metric === 'avgWatchDuration') {
+      return Math.max(0, parseInt(String(v ?? '0'), 10) || 0);
+    }
+    if (metric === 'totalWatchDuration') {
+      return Math.max(0, parseInt(String(v ?? '0'), 10) || 0);
+    }
+    return Number(v) || 0;
+  });
   
   const option: echarts.EChartsOption = {
     tooltip: {
@@ -684,10 +915,10 @@ function updatePlayTrendChart(stats: Array<{ recordDate: string; playCount: numb
     },
     series: [
       {
-        name: '播放量',
+        name: seriesName || '趋势',
         type: 'line',
         smooth: true,
-        data: playCounts,
+        data: values,
         itemStyle: {
           color: '#ff6b9d',
         },
@@ -919,12 +1150,27 @@ onMounted(() => {
     .traffic-data {
       margin-bottom: 32px;
       
+      .section-title-row {
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        gap: 12px;
+        margin-bottom: 16px;
+      }
+
       .section-title {
-        margin: 0 0 16px 0;
+        margin: 0;
         font-size: 16px;
         font-weight: 600;
         color: $text-primary;
       }
+
+      .detail-range {
+        display: flex;
+        gap: 8px;
+        flex-wrap: wrap;
+        justify-content: flex-end;
+      }
       
       .data-cards {
         display: grid;
@@ -949,6 +1195,7 @@ onMounted(() => {
           padding: 20px 16px;
           text-align: center;
           border: 1px solid #e5e7eb;
+          cursor: pointer;
           
           &.highlight {
             background: #fff5f7;

+ 38 - 0
server/src/models/entities/Work.ts

@@ -58,6 +58,44 @@ export class Work {
   @Column({ name: 'collect_count', type: 'int', default: 0 })
   collectCount!: number;
 
+  // ===== 昨日数据快照(yesterday_*)=====
+
+  @Column({ name: 'yesterday_play_count', type: 'int', default: 0 })
+  yesterdayPlayCount!: number;
+
+  @Column({ name: 'yesterday_like_count', type: 'int', default: 0 })
+  yesterdayLikeCount!: number;
+
+  @Column({ name: 'yesterday_comment_count', type: 'int', default: 0 })
+  yesterdayCommentCount!: number;
+
+  @Column({ name: 'yesterday_share_count', type: 'int', default: 0 })
+  yesterdayShareCount!: number;
+
+  @Column({ name: 'yesterday_collect_count', type: 'int', default: 0 })
+  yesterdayCollectCount!: number;
+
+  @Column({ name: 'yesterday_fans_increase', type: 'int', default: 0 })
+  yesterdayFansIncrease!: number;
+
+  @Column({ name: 'yesterday_cover_click_rate', type: 'varchar', length: 50, default: '0' })
+  yesterdayCoverClickRate!: string;
+
+  @Column({ name: 'yesterday_avg_watch_duration', type: 'varchar', length: 50, default: '0' })
+  yesterdayAvgWatchDuration!: string;
+
+  @Column({ name: 'yesterday_total_watch_duration', type: 'varchar', length: 50, default: '0' })
+  yesterdayTotalWatchDuration!: string;
+
+  @Column({ name: 'yesterday_completion_rate', type: 'varchar', length: 50, default: '0' })
+  yesterdayCompletionRate!: string;
+
+  @Column({ name: 'yesterday_two_second_exit_rate', type: 'varchar', length: 50, default: '0' })
+  yesterdayTwoSecondExitRate!: string;
+
+  @Column({ name: 'yesterday_exposure_count', type: 'varchar', length: 50, default: '0' })
+  yesterdayExposureCount!: string;
+
   @Column({ type: 'datetime', name: 'created_at', default: () => 'CURRENT_TIMESTAMP' })
   createdAt!: Date;
 

+ 1 - 0
server/src/services/DouyinWorkStatisticsImportService.ts

@@ -174,6 +174,7 @@ function toInt(val: unknown, defaultValue = 0): number {
 function normalizePercentString(val: unknown): string | undefined {
   const n = toNumber(val, NaN);
   if (!Number.isFinite(n)) return undefined;
+  if (n === 0) return '0';
   // 去掉多余的 0:48.730000 -> 48.73
   const s = n.toString();
   return `${s}%`;

+ 12 - 0
server/src/services/WorkDayStatisticsService.ts

@@ -53,6 +53,7 @@ interface PlatformStatItem {
 interface WorkStatisticsItem {
   recordDate: string;
   playCount: number;
+  exposureCount?: number;
   likeCount: number;
   commentCount: number;
   shareCount: number;
@@ -60,6 +61,9 @@ interface WorkStatisticsItem {
   fansIncrease?: number;
   totalWatchDuration?: string;
   avgWatchDuration?: string;
+  coverClickRate?: string;
+  completionRate?: string;
+  twoSecondExitRate?: string;
 }
 
 export class WorkDayStatisticsService {
@@ -599,6 +603,7 @@ export class WorkDayStatisticsService {
       .select('wds.work_id', 'workId')
       .addSelect('wds.record_date', 'recordDate')
       .addSelect('wds.play_count', 'playCount')
+      .addSelect('wds.exposure_count', 'exposureCount')
       .addSelect('wds.like_count', 'likeCount')
       .addSelect('wds.comment_count', 'commentCount')
       .addSelect('wds.share_count', 'shareCount')
@@ -606,6 +611,9 @@ export class WorkDayStatisticsService {
       .addSelect('wds.fans_increase', 'fansIncrease')
       .addSelect('wds.total_watch_duration', 'totalWatchDuration')
       .addSelect('wds.avg_watch_duration', 'avgWatchDuration')
+      .addSelect('wds.cover_click_rate', 'coverClickRate')
+      .addSelect('wds.completion_rate', 'completionRate')
+      .addSelect('wds.two_second_exit_rate', 'twoSecondExitRate')
       .where('wds.work_id IN (:...workIds)', { workIds })
       .orderBy('wds.work_id', 'ASC')
       .addOrderBy('wds.record_date', 'ASC');
@@ -635,6 +643,7 @@ export class WorkDayStatisticsService {
       groupedData[workId].push({
         recordDate,
         playCount: parseInt(row.playCount) || 0,
+        exposureCount: parseInt(row.exposureCount) || 0,
         likeCount: parseInt(row.likeCount) || 0,
         commentCount: parseInt(row.commentCount) || 0,
         shareCount: parseInt(row.shareCount) || 0,
@@ -642,6 +651,9 @@ export class WorkDayStatisticsService {
         fansIncrease: parseInt(row.fansIncrease) || 0,
         totalWatchDuration: row.totalWatchDuration || '0',
         avgWatchDuration: row.avgWatchDuration || '0',
+        coverClickRate: row.coverClickRate || '0',
+        completionRate: row.completionRate || '0',
+        twoSecondExitRate: row.twoSecondExitRate || '0',
       });
     }
 

+ 50 - 1
server/src/services/WorkService.ts

@@ -1,4 +1,4 @@
-import { AppDataSource, Work, PlatformAccount, Comment } from '../models/index.js';
+import { AppDataSource, Work, PlatformAccount, Comment, WorkDayStatistics } from '../models/index.js';
 import { AppError } from '../middleware/error.js';
 import { ERROR_CODES, HTTP_STATUS } from '@media-manager/shared';
 import type { PlatformType, Work as WorkType, WorkStats, WorksQueryParams } from '@media-manager/shared';
@@ -6,6 +6,7 @@ import { logger } from '../utils/logger.js';
 import { headlessBrowserService } from './HeadlessBrowserService.js';
 import { CookieManager } from '../automation/cookie.js';
 import { WorkDayStatisticsService } from './WorkDayStatisticsService.js';
+import { XiaohongshuWorkNoteStatisticsImportService } from './XiaohongshuWorkNoteStatisticsImportService.js';
 
 export class WorkService {
   private workRepository = AppDataSource.getRepository(Work);
@@ -446,6 +447,42 @@ export class WorkService {
       logger.error(`[SyncAccountWorks] Failed to save day statistics for account ${account.id}:`, error);
     }
 
+    // 小红书:如果是新作品且 work_day_statistics 中尚无任何记录,则补首批日统计 & works.yesterday_*(不受14天限制)
+    if (platform === 'xiaohongshu') {
+      try {
+        const works = await this.workRepository.find({
+          where: { accountId: account.id, platform },
+          select: ['id'],
+        });
+        const workIds = works.map((w) => w.id);
+        if (workIds.length > 0) {
+          const rows = await AppDataSource.getRepository(WorkDayStatistics)
+            .createQueryBuilder('wds')
+            .select('DISTINCT wds.work_id', 'workId')
+            .where('wds.work_id IN (:...ids)', { ids: workIds })
+            .getRawMany();
+          const hasStats = new Set<number>(rows.map((r: any) => Number(r.workId)));
+          const needInitIds = workIds.filter((id) => !hasStats.has(id));
+
+          if (needInitIds.length > 0) {
+            logger.info(
+              `[SyncAccountWorks] XHS account ${account.id} has ${needInitIds.length} works without statistics, running initial note/base import.`
+            );
+            const svc = new XiaohongshuWorkNoteStatisticsImportService();
+            await svc.importAccountWorksStatistics(account, false, {
+              workIdFilter: needInitIds,
+              ignorePublishTimeLimit: true,
+            });
+          }
+        }
+      } catch (err) {
+        logger.error(
+          `[SyncAccountWorks] Failed to backfill XHS work_day_statistics for account ${account.id}:`,
+          err
+        );
+      }
+    }
+
     return {
       syncedCount,
       worksListLength: accountInfo.worksList?.length || 0,
@@ -704,6 +741,18 @@ export class WorkService {
       commentCount: work.commentCount,
       shareCount: work.shareCount,
       collectCount: work.collectCount,
+      yesterdayPlayCount: work.yesterdayPlayCount,
+      yesterdayLikeCount: work.yesterdayLikeCount,
+      yesterdayCommentCount: work.yesterdayCommentCount,
+      yesterdayShareCount: work.yesterdayShareCount,
+      yesterdayCollectCount: work.yesterdayCollectCount,
+      yesterdayFansIncrease: work.yesterdayFansIncrease,
+      yesterdayCoverClickRate: work.yesterdayCoverClickRate,
+      yesterdayAvgWatchDuration: work.yesterdayAvgWatchDuration,
+      yesterdayTotalWatchDuration: work.yesterdayTotalWatchDuration,
+      yesterdayCompletionRate: work.yesterdayCompletionRate,
+      yesterdayTwoSecondExitRate: work.yesterdayTwoSecondExitRate,
+      yesterdayExposureCount: work.yesterdayExposureCount,
       createdAt: work.createdAt.toISOString(),
       updatedAt: work.updatedAt.toISOString(),
     };

+ 113 - 9
server/src/services/XiaohongshuWorkNoteStatisticsImportService.ts

@@ -5,6 +5,7 @@ import { WorkDayStatisticsService } from './WorkDayStatisticsService.js';
 import { AccountService } from './AccountService.js';
 import type { ProxyConfig } from '@media-manager/shared';
 import { BrowserManager } from '../automation/browser.js';
+import { In } from 'typeorm';
 
 /** 小红书笔记详情页跳转到登录时抛出,用于触发「先刷新登录、再决定是否账号失效」 */
 export class XhsLoginExpiredError extends Error {
@@ -50,6 +51,23 @@ interface NoteDaySection {
 
 interface NoteBaseData {
   day?: NoteDaySection;
+  view_count?: number;
+  like_count?: number;
+  comment_count?: number;
+  share_count?: number;
+  collect_count?: number;
+  rise_fans_count?: number;
+  /** 展现量/曝光数 */
+  impl_count?: number;
+  /** 封面点击率(数值,非百分号字符串) */
+  cover_click_rate?: number;
+  /** 2s 退出率(数值,非百分号字符串) */
+  exit_view2s_rate?: number;
+  /** 完播率(full_view_rate) */
+  full_view_rate?: number;
+  /** 平均观看时长(秒) */
+  view_time_avg_with_double?: number;
+  view_time_avg?: number;
 }
 
 interface DailyWorkStatPatch {
@@ -143,17 +161,32 @@ function toNumber(val: unknown, defaultValue = 0): number {
   return defaultValue;
 }
 
+function toNonNegativeNumber(val: unknown): number | undefined {
+  const n = toNumber(val, NaN);
+  if (!Number.isFinite(n)) return undefined;
+  return n < 0 ? 0 : n;
+}
+
 function toRateString(val: unknown): string | undefined {
   const n = toNumber(val, NaN);
   if (!Number.isFinite(n)) return undefined;
   return n.toString();
 }
 
+/** 比率类(非 0 加 %,0 保持为 "0") */
+function toRatePercentStringFromValue(val: unknown): string | undefined {
+  const n = toNumber(val, NaN);
+  if (!Number.isFinite(n)) return undefined;
+  if (n <= 0) return '0';
+  return `${n}%`;
+}
+
 /** 从 item 取 coun(或 count)转为字符串并追加 "%",用于比率类字段 */
 function toRatePercentString(item: NoteTrendItem): string | undefined {
   const raw = item?.coun ?? item?.count;
   const n = toNumber(raw, NaN);
   if (!Number.isFinite(n)) return undefined;
+  if (n === 0) return '0';
   return `${n}%`;
 }
 
@@ -196,19 +229,31 @@ export class XiaohongshuWorkNoteStatisticsImportService {
    * 按账号同步作品日统计。检测到登录失效时:先尝试刷新登录一次;刷新仍失效则执行账号失效,刷新成功则用新 cookie 重试一次。
    * @param isRetry 是否为「刷新登录后的重试」,避免无限递归
    */
-  private async importAccountWorksStatistics(account: PlatformAccount, isRetry = false): Promise<void> {
+  /**
+   * 按账号同步作品日统计
+   * @param options.workIdFilter 仅处理指定 workId(用于「同步作品后为新作品补首批日统计」)
+   * @param options.ignorePublishTimeLimit 是否忽略「发布日期+14天」限制(用于首批补数据)
+   */
+  async importAccountWorksStatistics(
+    account: PlatformAccount,
+    isRetry = false,
+    options?: { workIdFilter?: number[]; ignorePublishTimeLimit?: boolean }
+  ): Promise<void> {
     const cookies = parseCookiesFromAccount(account.cookieData);
     if (!cookies.length) {
       logger.warn(`[XHS WorkStats] accountId=${account.id} cookieData 为空或无法解析,跳过`);
       return;
     }
 
-    const works = await this.workRepository.find({
-      where: {
-        accountId: account.id,
-        platform: 'xiaohongshu' as any,
-      },
-    });
+    const where: any = {
+      accountId: account.id,
+      platform: 'xiaohongshu' as any,
+    };
+    if (options?.workIdFilter && options.workIdFilter.length > 0) {
+      where.id = In(options.workIdFilter);
+    }
+
+    const works = await this.workRepository.find({ where });
 
     if (!works.length) {
       logger.info(`[XHS WorkStats] accountId=${account.id} 没有作品,跳过`);
@@ -242,7 +287,23 @@ export class XiaohongshuWorkNoteStatisticsImportService {
           const data = await this.fetchNoteBaseData(page, noteId);
           if (!data) continue;
 
-          const patches = this.buildDailyStatisticsFromNoteData(work.id, data);
+          // 同步 base 顶层“汇总指标”到 works 表(用于作品列表/总览等按 work 累计口径展示)
+          await this.applyWorkSnapshotFromBaseData(work.id, data).catch((e) => {
+            logger.warn(
+              `[XHS WorkStats] Failed to update works snapshot from base data. workId=${work.id} noteId=${noteId}`,
+              e
+            );
+          });
+
+          let patches = this.buildDailyStatisticsFromNoteData(work.id, data);
+          // 默认:只保留「作品发布后 14 天内」的日统计;首批补数(ignorePublishTimeLimit=true)时不过滤
+          if (!options?.ignorePublishTimeLimit && work.publishTime) {
+            const publishDay = new Date(work.publishTime);
+            publishDay.setHours(0, 0, 0, 0);
+            const lastAllowed = new Date(publishDay);
+            lastAllowed.setDate(lastAllowed.getDate() + 13); // 发布当日 + 13 天 = 共 14 天
+            patches = patches.filter((p) => p.recordDate.getTime() <= lastAllowed.getTime());
+          }
           if (!patches.length) continue;
 
           const result = await this.workDayStatisticsService.saveStatisticsForDateBatch(
@@ -291,7 +352,7 @@ export class XiaohongshuWorkNoteStatisticsImportService {
                   const refreshed = await this.accountRepository.findOne({ where: { id: account.id } });
                   if (refreshed) {
                     logger.info(`[XHS WorkStats] accountId=${account.id} 刷新成功,重新同步数据`);
-                    return this.importAccountWorksStatistics(refreshed, true);
+                    return this.importAccountWorksStatistics(refreshed, true, options);
                   }
                 } catch (refreshErr) {
                   logger.error(`[XHS WorkStats] accountId=${account.id} 刷新登录失败`, refreshErr);
@@ -388,6 +449,49 @@ export class XiaohongshuWorkNoteStatisticsImportService {
     return data;
   }
 
+  private async applyWorkSnapshotFromBaseData(workId: number, data: NoteBaseData): Promise<void> {
+    // base 接口字段:以 note/base 的 data 顶层为准
+    const patch: Partial<Work> = {};
+
+    const viewCount = toNonNegativeNumber((data as any).view_count);
+    if (viewCount != null) (patch as any).yesterdayPlayCount = Math.trunc(viewCount);
+
+    const likeCount = toNonNegativeNumber((data as any).like_count);
+    if (likeCount != null) (patch as any).yesterdayLikeCount = Math.trunc(likeCount);
+
+    const commentCount = toNonNegativeNumber((data as any).comment_count);
+    if (commentCount != null) (patch as any).yesterdayCommentCount = Math.trunc(commentCount);
+
+    const shareCount = toNonNegativeNumber((data as any).share_count);
+    if (shareCount != null) (patch as any).yesterdayShareCount = Math.trunc(shareCount);
+
+    const collectCount = toNonNegativeNumber((data as any).collect_count);
+    if (collectCount != null) (patch as any).yesterdayCollectCount = Math.trunc(collectCount);
+
+    const fansIncrease = toNonNegativeNumber((data as any).rise_fans_count);
+    if (fansIncrease != null) (patch as any).yesterdayFansIncrease = Math.trunc(fansIncrease);
+
+    const exposureCount = toNonNegativeNumber((data as any).impl_count);
+    if (exposureCount != null) (patch as any).yesterdayExposureCount = String(Math.trunc(exposureCount));
+
+    const coverClickRateStr = toRatePercentStringFromValue((data as any).cover_click_rate);
+    if (coverClickRateStr != null) (patch as any).yesterdayCoverClickRate = coverClickRateStr;
+
+    const avgWatch = (data as any).view_time_avg_with_double ?? (data as any).view_time_avg;
+    const avgWatchN = toNonNegativeNumber(avgWatch);
+    if (avgWatchN != null) (patch as any).yesterdayAvgWatchDuration = String(avgWatchN);
+
+    const completionRateStr = toRatePercentStringFromValue((data as any).full_view_rate);
+    if (completionRateStr != null) (patch as any).yesterdayCompletionRate = completionRateStr;
+
+    const twoSecondExitRateStr = toRatePercentStringFromValue((data as any).exit_view2s_rate);
+    if (twoSecondExitRateStr != null) (patch as any).yesterdayTwoSecondExitRate = twoSecondExitRateStr;
+
+    // 没有任何字段可更新就跳过
+    if (Object.keys(patch).length === 0) return;
+    await this.workRepository.update(workId, patch as any);
+  }
+
   private buildDailyStatisticsFromNoteData(workId: number, data: NoteBaseData): DailyWorkStatPatch[] {
     const day = data.day;
     if (!day) return [];

+ 14 - 0
shared/src/types/work.ts

@@ -25,6 +25,20 @@ export interface Work {
   commentCount: number;
   shareCount: number;
   collectCount?: number;
+
+  // ===== 昨日数据快照(yesterday_*)=====
+  yesterdayPlayCount?: number;
+  yesterdayLikeCount?: number;
+  yesterdayCommentCount?: number;
+  yesterdayShareCount?: number;
+  yesterdayCollectCount?: number;
+  yesterdayFansIncrease?: number;
+  yesterdayCoverClickRate?: string;
+  yesterdayAvgWatchDuration?: string;
+  yesterdayTotalWatchDuration?: string;
+  yesterdayCompletionRate?: string;
+  yesterdayTwoSecondExitRate?: string;
+  yesterdayExposureCount?: string;
   createdAt: string;
   updatedAt: string;
 }