Selaa lähdekoodia

抖音作品详情页

Ethanfly 1 päivä sitten
vanhempi
commit
49a3b10335

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

@@ -34,6 +34,7 @@ declare module 'vue' {
     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']
     ElSelect: typeof import('element-plus/es')['ElSelect']

+ 49 - 3
client/src/views/Analytics/Work/index.vue

@@ -224,7 +224,24 @@
                     </div>
                   </template>
 
-                  <!-- 非小红书:保持原口径 -->
+                  <!-- 抖音:按小红书样式展示可用字段 -->
+                  <template v-else-if="selectedWork.platform === 'douyin'">
+                    <div
+                      v-for="item in douyinMetricCards"
+                      :key="item.label"
+                      class="data-card"
+                      :class="{ highlight: item.key && activeTrendMetric === item.key }"
+                      role="button"
+                      tabindex="0"
+                      @click="item.key && setTrendMetric(item.key)"
+                      @keyup.enter="item.key && 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"
@@ -437,6 +454,8 @@ interface WorkDetailData {
   avgWatchDuration: string;
   completionRate: string;
   twoSecondExitRate: string;
+  // 抖音:5s 完播率(仅昨日快照,无趋势)
+  completionRate5s: string;
 }
 
 const workDetailData = ref<WorkDetailData>({
@@ -452,6 +471,7 @@ const workDetailData = ref<WorkDetailData>({
   avgWatchDuration: '0',
   completionRate: '0',
   twoSecondExitRate: '0',
+  completionRate5s: '0',
 });
 
 // 播放量趋势图
@@ -724,7 +744,13 @@ function calcDetailRangeDatesFixed14Days(): { start: string; end: string } {
   return { start: clampedStart.format('YYYY-MM-DD'), end: end.format('YYYY-MM-DD') };
 }
 
-const xhsMetricCards = computed(() => {
+interface MetricCardConfig {
+  key?: TrendMetricKey;
+  label: string;
+  value: string;
+}
+
+const xhsMetricCards = computed<MetricCardConfig[]>(() => {
   const d = workDetailData.value;
   return [
     { key: 'exposureCount' as const, label: '曝光量', value: formatNumber(d.exposureCount || 0) },
@@ -741,6 +767,25 @@ const xhsMetricCards = computed(() => {
   ];
 });
 
+// 抖音:按照小红书样式展示已有字段(不包含曝光量,新增 5s 完播率与播放总时长)
+const douyinMetricCards = computed<MetricCardConfig[]>(() => {
+  const d = workDetailData.value;
+  const base = selectedWork.value;
+  return [
+    { key: 'playCount', label: '播放量', value: formatNumber(d.playCount || base?.viewsCount || 0) },
+    { key: 'likeCount', label: '点赞量', value: formatNumber(d.likeCount || base?.likesCount || 0) },
+    { key: 'commentCount', label: '评论量', value: formatNumber(d.commentCount || base?.commentsCount || 0) },
+    { key: 'collectCount', label: '收藏量', value: formatNumber(d.collectCount || base?.collectsCount || 0) },
+    { key: 'shareCount', label: '分享量', value: formatNumber(d.shareCount || base?.sharesCount || 0) },
+    { key: 'fansIncrease', label: '涨粉量', value: formatNumber(d.fansIncrease || 0) },
+    { key: 'avgWatchDuration', label: '平均观看时长', value: formatDurationSeconds(d.avgWatchDuration) },
+    { key: 'completionRate', label: '完播率', value: formatRate(d.completionRate) },
+    { key: 'twoSecondExitRate', label: '2s退出率', value: formatRate(d.twoSecondExitRate) },
+    // 5s 完播率仅为昨日快照,不参与趋势联动
+    { label: '5s完播率', value: formatRate(d.completionRate5s) },
+  ];
+});
+
 // 查看详情
 async function handleView(row: WorkData) {
   selectedWork.value = row;
@@ -793,7 +838,7 @@ async function loadWorkBase(workId: number) {
     workDetailData.value = {
       playCount: toIntSafe(data.yesterdayPlayCount ?? 0),
       exposureCount: toIntSafe(data.yesterdayExposureCount ?? 0),
-      totalWatchDuration: workDetailData.value.totalWatchDuration || '0秒',
+      totalWatchDuration: formatDurationSeconds(data.yesterdayTotalWatchDuration ?? 0),
       likeCount: toIntSafe(data.yesterdayLikeCount ?? 0),
       commentCount: toIntSafe(data.yesterdayCommentCount ?? 0),
       collectCount: toIntSafe(data.yesterdayCollectCount ?? 0),
@@ -803,6 +848,7 @@ async function loadWorkBase(workId: number) {
       avgWatchDuration: String(data.yesterdayAvgWatchDuration ?? '0'),
       completionRate: String(data.yesterdayCompletionRate ?? '0'),
       twoSecondExitRate: String(data.yesterdayTwoSecondExitRate ?? '0'),
+      completionRate5s: String(data.yesterdayCompletionRate5s ?? '0'),
     };
   } catch (error) {
     // works 表请求失败不影响后续趋势展示

+ 5 - 2
server/src/models/entities/Work.ts

@@ -93,8 +93,11 @@ export class Work {
   @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({ name: 'yesterday_completion_rate_5s', type: 'varchar', length: 50, default: '0' })
+  yesterdayCompletionRate5s!: string;
+
+  @Column({ name: 'yesterday_exposure_count', type: 'int', default: 0 })
+  yesterdayExposureCount!: number;
 
   @Column({ type: 'datetime', name: 'created_at', default: () => 'CURRENT_TIMESTAMP' })
   createdAt!: Date;

+ 148 - 4
server/src/services/DouyinWorkStatisticsImportService.ts

@@ -174,12 +174,23 @@ 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();
+  // 小于等于 0 统一记为 "0"
+  if (n <= 0) return '0';
+  // 原始值视为 0-1 之间的小数,这里 *100 后四舍五入保留两位小数并加 "%"
+  const scaled = n * 100;
+  const rounded = Math.round(scaled * 100) / 100;
+  const s = rounded.toFixed(2).replace(/\.00$/, '').replace(/(\.\d)0$/, '$1');
   return `${s}%`;
 }
 
+/** 平均时长等:保留两位小数,四舍五入 */
+function toFixed2String(val: unknown): string | undefined {
+  const n = toNonNegativeNumber(val);
+  if (n == null) return undefined;
+  const rounded = Math.round(n * 100) / 100;
+  return rounded.toFixed(2);
+}
+
 function isDouyinLoginExpiredByApi(body: any): boolean {
   const code = Number(body?.status_code);
   const msg = String(body?.status_msg || '');
@@ -213,7 +224,8 @@ class DouyinMetricsTrendClient {
       browser_online: 'true',
       timezone_name: 'Asia/Shanghai',
       item_id: itemId,
-      trend_type: '1',
+      // 按照浏览器抓包使用 trend_type=2,表示直接使用原始指标曲线(不是增量/差值)
+      trend_type: '2',
       time_unit: '1',
       metrics_group: '0,1,3',
       metrics: metric,
@@ -338,6 +350,26 @@ class DouyinMetricsTrendClient {
   }
 }
 
+function toNonNegativeNumber(val: unknown): number | undefined {
+  const n = toNumber(val, NaN);
+  if (!Number.isFinite(n)) return undefined;
+  return n < 0 ? 0 : n;
+}
+
+/**
+ * 比率类:0 不加 "%"(返回 "0"),非 0 时 *100 后四舍五入到两位小数并加 "%"
+ * 例如: 0.12345 -> "12.35%", 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';
+  const scaled = n * 100;
+  const rounded = Math.round(scaled * 100) / 100; // 保留两位小数,四舍五入
+  const s = rounded.toFixed(2).replace(/\.00$/, '').replace(/(\.\d)0$/, '$1');
+  return `${s}%`;
+}
+
 export class DouyinWorkStatisticsImportService {
   private accountRepository = AppDataSource.getRepository(PlatformAccount);
   private workRepository = AppDataSource.getRepository(Work);
@@ -470,6 +502,19 @@ export class DouyinWorkStatisticsImportService {
             const body = await client.fetchTrend(ctx, page, itemId, m.metric, m.label, detailUrl);
             if (!body || typeof body !== 'object') continue;
 
+            // 调试:把 metrics_trend 原始返回打印成 JSON,方便和抖音后台对比
+            if (work.id === 39 && m.metric === 'completion_rate') {
+              try {
+                logger.info(
+                  `[DY WorkStats][debug metrics_trend raw] workId=${work.id} itemId=${itemId} metric=${m.metric} body=${JSON.stringify(
+                    body
+                  )}`
+                );
+              } catch {
+                // ignore JSON stringify error
+              }
+            }
+
             if (isDouyinLoginExpiredByApi(body)) {
               throw new DouyinLoginExpiredError(body.status_msg || 'metrics_trend: user not match');
             }
@@ -489,7 +534,27 @@ export class DouyinWorkStatisticsImportService {
               ? metricMap['0']
               : Object.values(metricMap).flatMap((arr) => (Array.isArray(arr) ? arr : []));
 
+            // 调试:打印 workId=39 的 completion_rate 全量 points,确认与抖音后台返回是否一致
+            if (work.id === 39 && m.metric === 'completion_rate') {
+              try {
+                logger.info(
+                  `[DY WorkStats][debug completion_rate points] workId=${work.id} itemId=${itemId} points=${JSON.stringify(
+                    points
+                  )}`
+                );
+              } catch {
+                // ignore JSON stringify error
+              }
+            }
+
             for (const pt of points) {
+              // 调试:打印指定作品的完播率原始值
+              if (work.id === 39 && m.metric === 'completion_rate') {
+                logger.info(
+                  `[DY WorkStats][debug completion_rate] workId=${work.id} itemId=${itemId} date=${pt?.date_time} raw_value=${pt?.value}`
+                );
+              }
+
               const d = parseChinaDateFromDateTimeString(pt?.date_time);
               if (!d) continue;
               const key = d.getTime();
@@ -507,6 +572,16 @@ export class DouyinWorkStatisticsImportService {
           );
           if (!patches.length) continue;
 
+          // 同时补充作品级昨日快照(works.yesterday_*),使用 item/mget metrics
+          try {
+            await this.applyWorkSnapshotFromItemMget(ctx, itemId, detailUrl, work.id);
+          } catch (e) {
+            logger.warn(
+              `[DY WorkStats] Failed to update works snapshot from item/mget. accountId=${account.id} workId=${work.id} itemId=${itemId}`,
+              e
+            );
+          }
+
           const result = await this.workDayStatisticsService.saveStatisticsForDateBatch(
             patches.map((p) => ({
               workId: p.workId,
@@ -583,6 +658,75 @@ export class DouyinWorkStatisticsImportService {
     }
   }
 
+  /**
+   * 使用 item/mget 接口为 works 表补充昨日快照(yesterday_* 字段)
+   */
+  private async applyWorkSnapshotFromItemMget(
+    ctx: BrowserContext,
+    itemId: string,
+    refererUrl: string,
+    workId: number
+  ): Promise<void> {
+    const url = `https://creator.douyin.com/web/api/creator/item/mget?ids=${encodeURIComponent(
+      itemId
+    )}&fields=metrics%2Creview%2Cplay_info`;
+
+    const headers: Record<string, string> = {
+      accept: '*/*',
+      'accept-language': 'zh-CN,zh;q=0.9',
+      referer: refererUrl,
+      'user-agent':
+        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36',
+    };
+
+    const res = await ctx.request.get(url, { headers, timeout: 25_000 });
+    const body = (await res.json().catch(() => null)) as any;
+    if (!body || typeof body !== 'object' || Number(body.status_code) !== 0) {
+      return;
+    }
+
+    const items = Array.isArray(body.items) ? body.items : [];
+    const first = items[0];
+    if (!first || typeof first !== 'object') return;
+
+    const metrics = first.metrics || {};
+    const patch: Partial<Work> = {};
+
+    const viewCount = toNonNegativeNumber(metrics.view_count);
+    if (viewCount != null) (patch as any).yesterdayPlayCount = Math.trunc(viewCount);
+
+    const likeCount = toNonNegativeNumber(metrics.like_count);
+    if (likeCount != null) (patch as any).yesterdayLikeCount = Math.trunc(likeCount);
+
+    const commentCount = toNonNegativeNumber(metrics.comment_count);
+    if (commentCount != null) (patch as any).yesterdayCommentCount = Math.trunc(commentCount);
+
+    const shareCount = toNonNegativeNumber(metrics.share_count);
+    if (shareCount != null) (patch as any).yesterdayShareCount = Math.trunc(shareCount);
+
+    const collectCount = toNonNegativeNumber(metrics.favorite_count);
+    if (collectCount != null) (patch as any).yesterdayCollectCount = Math.trunc(collectCount);
+
+    const fansIncrease = toNonNegativeNumber(metrics.subscribe_count);
+    if (fansIncrease != null) (patch as any).yesterdayFansIncrease = Math.trunc(fansIncrease);
+
+    // 平均观看时长(秒):保留两位小数
+    const avgWatchStr = toFixed2String(metrics.avg_view_second);
+    if (avgWatchStr != null) (patch as any).yesterdayAvgWatchDuration = avgWatchStr;
+
+    const completionRateStr = toRatePercentStringFromValue(metrics.completion_rate);
+    if (completionRateStr != null) (patch as any).yesterdayCompletionRate = completionRateStr;
+
+    const twoSecondExitRateStr = toRatePercentStringFromValue(metrics.bounce_rate_2s);
+    if (twoSecondExitRateStr != null) (patch as any).yesterdayTwoSecondExitRate = twoSecondExitRateStr;
+
+    const completion5sStr = toRatePercentStringFromValue(metrics.completion_rate_5s);
+    if (completion5sStr != null) (patch as any).yesterdayCompletionRate5s = completion5sStr;
+
+    if (Object.keys(patch).length === 0) return;
+    await this.workRepository.update(workId, patch as any);
+  }
+
   private async markAccountExpired(account: PlatformAccount, reason: string): Promise<void> {
     await this.accountRepository.update(account.id, { status: 'expired' as any });
     wsManager.sendToUser(account.userId, WS_EVENTS.ACCOUNT_UPDATED, {

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

@@ -752,6 +752,7 @@ export class WorkService {
       yesterdayTotalWatchDuration: work.yesterdayTotalWatchDuration,
       yesterdayCompletionRate: work.yesterdayCompletionRate,
       yesterdayTwoSecondExitRate: work.yesterdayTwoSecondExitRate,
+      yesterdayCompletionRate5s: work.yesterdayCompletionRate5s,
       yesterdayExposureCount: work.yesterdayExposureCount,
       createdAt: work.createdAt.toISOString(),
       updatedAt: work.updatedAt.toISOString(),

+ 11 - 3
server/src/services/XiaohongshuWorkNoteStatisticsImportService.ts

@@ -190,6 +190,14 @@ function toRatePercentString(item: NoteTrendItem): string | undefined {
   return `${n}%`;
 }
 
+/** 平均时长等:保留两位小数,四舍五入 */
+function toFixed2String(val: unknown): string | undefined {
+  const n = toNonNegativeNumber(val);
+  if (n == null) return undefined;
+  const rounded = Math.round(n * 100) / 100;
+  return rounded.toFixed(2);
+}
+
 export class XiaohongshuWorkNoteStatisticsImportService {
   private accountRepository = AppDataSource.getRepository(PlatformAccount);
   private workRepository = AppDataSource.getRepository(Work);
@@ -472,14 +480,14 @@ export class XiaohongshuWorkNoteStatisticsImportService {
     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));
+    if (exposureCount != null) (patch as any).yesterdayExposureCount = 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 avgWatchStr = toFixed2String(avgWatch);
+    if (avgWatchStr != null) (patch as any).yesterdayAvgWatchDuration = avgWatchStr;
 
     const completionRateStr = toRatePercentStringFromValue((data as any).full_view_rate);
     if (completionRateStr != null) (patch as any).yesterdayCompletionRate = completionRateStr;

+ 4 - 1
shared/src/types/work.ts

@@ -38,7 +38,10 @@ export interface Work {
   yesterdayTotalWatchDuration?: string;
   yesterdayCompletionRate?: string;
   yesterdayTwoSecondExitRate?: string;
-  yesterdayExposureCount?: string;
+  /** 5秒完播率 */
+  yesterdayCompletionRate5s?: string;
+  /** 曝光数 */
+  yesterdayExposureCount?: number;
   createdAt: string;
   updatedAt: string;
 }