Explorar el Código

微信号定时任务

Ethanfly hace 19 horas
padre
commit
b4eea05f80

+ 67 - 0
client/dist-electron/main.js

@@ -216,6 +216,73 @@ function setupWindowEvents() {
     mainWindow == null ? void 0 : mainWindow.webContents.send("window-maximized", false);
   });
 }
+ipcMain.handle("open-backend-external", async (_event, payload) => {
+  const { url, cookieData, title } = payload || {};
+  if (!url || typeof url !== "string") return;
+  const partition = "persist:backend-popup-" + Date.now();
+  const ses = session.fromPartition(partition);
+  if (cookieData && typeof cookieData === "string" && cookieData.trim()) {
+    const raw = cookieData.trim();
+    let cookiesToSet = [];
+    try {
+      if (raw.startsWith("[") || raw.startsWith("{")) {
+        const parsed = JSON.parse(raw);
+        const arr = Array.isArray(parsed) ? parsed : (parsed == null ? void 0 : parsed.cookies) || [];
+        cookiesToSet = arr.map((c) => ({
+          name: String((c == null ? void 0 : c.name) ?? "").trim(),
+          value: String((c == null ? void 0 : c.value) ?? "").trim(),
+          domain: (c == null ? void 0 : c.domain) ? String(c.domain) : void 0,
+          path: (c == null ? void 0 : c.path) ? String(c.path) : "/"
+        })).filter((c) => c.name);
+      } else {
+        raw.split(";").forEach((p) => {
+          const idx = p.indexOf("=");
+          if (idx > 0) {
+            const name = p.slice(0, idx).trim();
+            const value = p.slice(idx + 1).trim();
+            if (name) cookiesToSet.push({ name, value, path: "/" });
+          }
+        });
+      }
+    } catch (e) {
+      console.warn("[open-backend-external] 解析 cookie 失败", e);
+    }
+    const origin = new URL(url).origin;
+    const hostname = new URL(url).hostname;
+    const defaultDomain = hostname.startsWith("www.") ? hostname.slice(4) : hostname;
+    const domainWithDot = defaultDomain.includes(".") ? "." + defaultDomain.split(".").slice(-2).join(".") : void 0;
+    for (const c of cookiesToSet) {
+      try {
+        await ses.cookies.set({
+          url: origin + "/",
+          name: c.name,
+          value: c.value,
+          domain: c.domain || domainWithDot || hostname,
+          path: c.path || "/"
+        });
+      } catch (err) {
+        console.warn("[open-backend-external] 设置 cookie 失败", c.name, err);
+      }
+    }
+  }
+  const win = new BrowserWindow({
+    width: 1280,
+    height: 800,
+    title: title || "平台后台",
+    icon: getIconPath(),
+    webPreferences: {
+      session: ses,
+      nodeIntegration: false,
+      contextIsolation: true
+    },
+    show: false
+  });
+  win.once("ready-to-show", () => {
+    win.show();
+  });
+  await win.loadURL(url);
+  return { ok: true };
+});
 ipcMain.handle("get-webview-cookies", async (_event, partition, url) => {
   try {
     const ses = session.fromPartition(partition);

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
client/dist-electron/main.js.map


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 2 - 0
client/dist-electron/preload.js


+ 76 - 0
client/electron/main.ts

@@ -306,6 +306,82 @@ function setupWindowEvents() {
   });
 }
 
+// 弹窗打开平台后台(独立窗口,不嵌入;用于实验,可回归为嵌入)
+ipcMain.handle('open-backend-external', async (_event: unknown, payload: { url: string; cookieData?: string; title?: string }) => {
+  const { url, cookieData, title } = payload || {};
+  if (!url || typeof url !== 'string') return;
+
+  const partition = 'persist:backend-popup-' + Date.now();
+  const ses = session.fromPartition(partition);
+
+  if (cookieData && typeof cookieData === 'string' && cookieData.trim()) {
+    const raw = cookieData.trim();
+    let cookiesToSet: Array<{ name: string; value: string; domain?: string; path?: string }> = [];
+    try {
+      if (raw.startsWith('[') || raw.startsWith('{')) {
+        const parsed = JSON.parse(raw);
+        const arr = Array.isArray(parsed) ? parsed : (parsed?.cookies || []);
+        cookiesToSet = arr.map((c: { name?: string; value?: string; domain?: string; path?: string }) => ({
+          name: String(c?.name ?? '').trim(),
+          value: String(c?.value ?? '').trim(),
+          domain: c?.domain ? String(c.domain) : undefined,
+          path: c?.path ? String(c.path) : '/',
+        })).filter((c: { name: string }) => c.name);
+      } else {
+        raw.split(';').forEach((p: string) => {
+          const idx = p.indexOf('=');
+          if (idx > 0) {
+            const name = p.slice(0, idx).trim();
+            const value = p.slice(idx + 1).trim();
+            if (name) cookiesToSet.push({ name, value, path: '/' });
+          }
+        });
+      }
+    } catch (e) {
+      console.warn('[open-backend-external] 解析 cookie 失败', e);
+    }
+
+    const origin = new URL(url).origin;
+    const hostname = new URL(url).hostname;
+    const defaultDomain = hostname.startsWith('www.') ? hostname.slice(4) : hostname;
+    const domainWithDot = defaultDomain.includes('.') ? '.' + defaultDomain.split('.').slice(-2).join('.') : undefined;
+
+    for (const c of cookiesToSet) {
+      try {
+        await ses.cookies.set({
+          url: origin + '/',
+          name: c.name,
+          value: c.value,
+          domain: c.domain || domainWithDot || hostname,
+          path: c.path || '/',
+        });
+      } catch (err) {
+        console.warn('[open-backend-external] 设置 cookie 失败', c.name, err);
+      }
+    }
+  }
+
+  const win = new BrowserWindow({
+    width: 1280,
+    height: 800,
+    title: title || '平台后台',
+    icon: getIconPath(),
+    webPreferences: {
+      session: ses,
+      nodeIntegration: false,
+      contextIsolation: true,
+    },
+    show: false,
+  });
+
+  win.once('ready-to-show', () => {
+    win.show();
+  });
+
+  await win.loadURL(url);
+  return { ok: true };
+});
+
 // 获取 webview 的 cookies
 ipcMain.handle('get-webview-cookies', async (_event: unknown, partition: string, url: string) => {
   try {

+ 5 - 0
client/electron/preload.ts

@@ -26,6 +26,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
   showNotification: (title: string, body: string) =>
     ipcRenderer.send('show-notification', { title, body }),
 
+  // 弹窗打开平台后台(独立窗口,不嵌入;实验用)
+  openBackendExternal: (url: string, cookieData?: string, title?: string) =>
+    ipcRenderer.invoke('open-backend-external', { url, cookieData, title }),
+
   // Webview Cookie 操作
   getWebviewCookies: (partition: string, url: string) =>
     ipcRenderer.invoke('get-webview-cookies', partition, url),
@@ -86,6 +90,7 @@ declare global {
       webviewSendTextInput: (webContentsId: number, text: string) => Promise<boolean>;
       webviewGetElementPosition: (webContentsId: number, selector: string) => Promise<{ x: number; y: number; width: number; height: number } | null>;
       webviewClickByText: (webContentsId: number, text: string) => Promise<boolean>;
+      openBackendExternal: (url: string, cookieData?: string, title?: string) => Promise<{ ok: boolean }>;
       // CDP 网络拦截
       enableNetworkIntercept: (webContentsId: number, patterns: Array<{match: string, key: string}>) => Promise<boolean>;
       disableNetworkIntercept: (webContentsId: number) => Promise<boolean>;

+ 2 - 2
client/src/views/Accounts/index.vue

@@ -657,7 +657,7 @@ async function openPlatformAdmin(account: PlatformAccount) {
     }
     
     console.log('[账号管理] 获取到 Cookie 数据,准备打开后台');
-    
+
     // 在标签页中打开平台后台,带上 Cookie,设置为管理后台模式
     tabsStore.openBrowserTab(
       account.platform,
@@ -667,7 +667,7 @@ async function openPlatformAdmin(account: PlatformAccount) {
       cookieData,
       true // isAdminMode: 管理后台模式,不校验登录和保存账号
     );
-    
+
     ElMessage.success('正在打开后台...');
   } catch (error) {
     console.error('获取账号 Cookie 失败:', error);

+ 350 - 122
client/src/views/Analytics/Work/index.vue

@@ -159,15 +159,13 @@
             <span class="publish-time">{{ formatTime(row.publishTime) }}</span>
           </template>
         </el-table-column>
-        <!-- 操作列暂时注释
-        <el-table-column label="操作" width="80" align="center" fixed="right">
+        <el-table-column label="操作" width="100" align="center" fixed="right">
           <template #default="{ row }">
             <el-button type="primary" link @click="handleView(row)">
               查看
             </el-button>
           </template>
         </el-table-column>
-        -->
       </el-table>
       
       <!-- 分页 -->
@@ -182,67 +180,78 @@
       </div>
     </div>
     
-    <!-- 作品详情抽屉 -->
-    <el-drawer v-model="drawerVisible" title="作品详情" size="50%">
+    <!-- 作品详情弹窗 -->
+    <el-dialog
+      v-model="drawerVisible"
+      title="作品详情"
+      width="80%"
+      :close-on-click-modal="false"
+      destroy-on-close
+      class="work-detail-dialog"
+    >
       <div v-if="selectedWork" class="work-detail">
-        <!-- 作品基本信息 -->
+        <!-- 标题和发布时间 -->
         <div class="detail-header">
-          <el-image 
-            :src="selectedWork.coverUrl" 
-            class="work-cover"
-            fit="cover"
-          >
-            <template #error>
-              <div class="cover-placeholder">
-                <el-icon :size="32"><Picture /></el-icon>
+          <h3 class="work-title">{{ selectedWork.title }}</h3>
+          <div class="publish-time">{{ formatTime(selectedWork.publishTime) }}</div>
+        </div>
+
+        <!-- 标签页 -->
+        <el-tabs v-model="activeTab" class="detail-tabs">
+          <!-- 核心数据标签页 -->
+          <el-tab-pane label="核心数据" name="core">
+            <div class="core-data-content">
+              <!-- 流量数据卡片 -->
+              <div class="traffic-data">
+                <h4 class="section-title">流量数据</h4>
+                <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>
+                </div>
+              </div>
+
+              <!-- 播放量趋势 -->
+              <div class="trend-section">
+                <h4 class="section-title">播放量趋势</h4>
+                <div ref="playTrendChartRef" style="height: 300px" v-loading="detailLoading"></div>
               </div>
-            </template>
-          </el-image>
-          <div class="header-info">
-            <h3>{{ selectedWork.title }}</h3>
-            <div class="meta-info">
-              <el-tag size="small">{{ getPlatformName(selectedWork.platform) }}</el-tag>
-              <span class="publish-time">发布于 {{ formatTime(selectedWork.publishTime) }}</span>
             </div>
-          </div>
-        </div>
-        
-        <!-- 数据统计 -->
-        <div class="detail-stats">
-          <div class="stat-item">
-            <div class="stat-value">{{ selectedWork.viewsCount || 0 }}</div>
-            <div class="stat-label">阅读</div>
-          </div>
-          <div class="stat-item">
-            <div class="stat-value">{{ selectedWork.likesCount || 0 }}</div>
-            <div class="stat-label">点赞</div>
-          </div>
-          <div class="stat-item">
-            <div class="stat-value">{{ selectedWork.commentsCount || 0 }}</div>
-            <div class="stat-label">评论</div>
-          </div>
-          <div class="stat-item">
-            <div class="stat-value">{{ selectedWork.collectsCount || 0 }}</div>
-            <div class="stat-label">收藏</div>
-          </div>
-          <div class="stat-item">
-            <div class="stat-value">{{ selectedWork.sharesCount || 0 }}</div>
-            <div class="stat-label">分享</div>
-          </div>
-        </div>
-        
-        <!-- 作品内容 -->
-        <div class="detail-content" v-if="selectedWork.content">
-          <h4>作品内容</h4>
-          <div class="content-text">{{ selectedWork.content }}</div>
-        </div>
+          </el-tab-pane>
+
+        </el-tabs>
       </div>
-    </el-drawer>
+    </el-dialog>
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, computed, onMounted } from 'vue';
+import { ref, computed, onMounted, watch, nextTick } from 'vue';
 import { Search, Picture, Document, View, ChatDotRound, Share, Star, Pointer } from '@element-plus/icons-vue';
 import { PLATFORMS, AVAILABLE_PLATFORM_TYPES } from '@media-manager/shared';
 import type { PlatformType } from '@media-manager/shared';
@@ -250,6 +259,7 @@ import { useAuthStore } from '@/stores/auth';
 import { ElMessage } from 'element-plus';
 import dayjs from 'dayjs';
 import request from '@/api/request';
+import * as echarts from 'echarts';
 
 const authStore = useAuthStore();
 const loading = ref(false);
@@ -341,9 +351,36 @@ interface WorkData {
 
 const workList = ref<WorkData[]>([]);
 
-// 抽屉相关
+// 详情弹窗相关
 const drawerVisible = ref(false);
 const selectedWork = ref<WorkData | null>(null);
+const activeTab = ref('core');
+const detailLoading = ref(false);
+
+// 作品详情数据
+interface WorkDetailData {
+  playCount: number;
+  totalWatchDuration: string;
+  likeCount: number;
+  commentCount: number;
+  collectCount: number;
+  shareCount: number;
+  fansIncrease: number;
+}
+
+const workDetailData = ref<WorkDetailData>({
+  playCount: 0,
+  totalWatchDuration: '0秒',
+  likeCount: 0,
+  commentCount: 0,
+  collectCount: 0,
+  shareCount: 0,
+  fansIncrease: 0,
+});
+
+// 播放量趋势图
+const playTrendChartRef = ref<HTMLElement>();
+let playTrendChart: echarts.ECharts | null = null;
 
 function getPlatformName(platform: PlatformType) {
   return PLATFORMS[platform]?.name || platform;
@@ -511,12 +548,177 @@ async function loadData() {
   }
 }
 
+// 格式化数字
+function formatNumber(num: number | null | undefined): string {
+  if (num === null || num === undefined) return '0';
+  if (num >= 10000) {
+    return (num / 10000).toFixed(1) + '万';
+  }
+  return String(num);
+}
+
 // 查看详情
-function handleView(row: WorkData) {
+async function handleView(row: WorkData) {
   selectedWork.value = row;
   drawerVisible.value = true;
+  activeTab.value = 'core';
+  
+  // 重置数据
+  workDetailData.value = {
+    playCount: row.viewsCount || 0,
+    totalWatchDuration: '0秒',
+    likeCount: row.likesCount || 0,
+    commentCount: row.commentsCount || 0,
+    collectCount: row.collectsCount || 0,
+    shareCount: row.sharesCount || 0,
+    fansIncrease: 0,
+  };
+  
+  // 加载作品详情数据
+  await loadWorkDetail(row.id);
 }
 
+// 加载作品详情数据(历史统计数据)
+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');
+    
+    // 调用接口获取该作品的历史统计数据
+    const data = await request.get(`/api/work-day-statistics/work/${workId}`, {
+      params: {
+        start_date: startDateStr,
+        end_date: endDateStr,
+      },
+    });
+    
+    if (data && Array.isArray(data)) {
+      const workStats = data;
+      
+      // 计算汇总数据(取最新一条记录的累计值)
+      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);
+    }
+  } catch (error) {
+    console.error('加载作品详情失败:', error);
+    ElMessage.error('加载作品详情失败,请稍后重试');
+  } finally {
+    detailLoading.value = false;
+  }
+}
+
+// 更新播放量趋势图
+function updatePlayTrendChart(stats: Array<{ recordDate: string; playCount: number }>) {
+  if (!playTrendChartRef.value) return;
+  
+  if (!playTrendChart) {
+    playTrendChart = echarts.init(playTrendChartRef.value);
+  }
+  
+  // 按日期排序
+  const sortedStats = [...stats].sort((a, b) => 
+    dayjs(a.recordDate).valueOf() - dayjs(b.recordDate).valueOf()
+  );
+  
+  const dates = sortedStats.map(s => dayjs(s.recordDate).format('YYYY-MM-DD'));
+  const playCounts = sortedStats.map(s => s.playCount || 0);
+  
+  const option: echarts.EChartsOption = {
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: {
+        type: 'cross',
+      },
+    },
+    grid: {
+      left: '3%',
+      right: '4%',
+      bottom: '3%',
+      containLabel: true,
+    },
+    xAxis: {
+      type: 'category',
+      boundaryGap: false,
+      data: dates,
+      axisLabel: {
+        formatter: (value: string) => {
+          return dayjs(value).format('MM-DD');
+        },
+      },
+    },
+    yAxis: {
+      type: 'value',
+      axisLabel: {
+        formatter: (value: number) => {
+          if (value >= 10000) {
+            return (value / 10000).toFixed(1) + '万';
+          }
+          return String(value);
+        },
+      },
+    },
+    series: [
+      {
+        name: '播放量',
+        type: 'line',
+        smooth: true,
+        data: playCounts,
+        itemStyle: {
+          color: '#ff6b9d',
+        },
+        areaStyle: {
+          color: {
+            type: 'linear',
+            x: 0,
+            y: 0,
+            x2: 0,
+            y2: 1,
+            colorStops: [
+              { offset: 0, color: 'rgba(255, 107, 157, 0.3)' },
+              { offset: 1, color: 'rgba(255, 107, 157, 0.05)' },
+            ],
+          },
+        },
+      },
+    ],
+  };
+  
+  playTrendChart.setOption(option, true);
+}
+
+// 监听弹窗关闭,清理图表
+watch(drawerVisible, (visible) => {
+  if (!visible && playTrendChart) {
+    playTrendChart.dispose();
+    playTrendChart = null;
+  }
+});
+
 // 导出数据
 function handleExport() {
   ElMessage.info('导出功能开发中');
@@ -673,91 +875,117 @@ onMounted(() => {
   }
 }
 
+:deep(.work-detail-dialog) {
+  .el-dialog__header {
+    padding-bottom: 0;
+  }
+  
+  .el-dialog__body {
+    padding-top: 20px;
+  }
+}
+
 .work-detail {
   .detail-header {
-    display: flex;
-    gap: 16px;
     margin-bottom: 24px;
+    padding-bottom: 16px;
+    border-bottom: 1px solid #f0f0f0;
     
-    .work-cover {
-      width: 120px;
-      height: 120px;
-      border-radius: 8px;
-      flex-shrink: 0;
-      
-      .cover-placeholder {
-        width: 100%;
-        height: 100%;
-        background: #f3f4f6;
-        display: flex;
-        align-items: center;
-        justify-content: center;
-        color: #9ca3af;
-      }
+    .work-title {
+      margin: 0 0 8px 0;
+      font-size: 18px;
+      font-weight: 600;
+      color: $text-primary;
     }
     
-    .header-info {
-      flex: 1;
+    .publish-time {
+      font-size: 14px;
+      color: $text-secondary;
+    }
+  }
+  
+  .detail-tabs {
+    :deep(.el-tabs__header) {
+      margin-bottom: 24px;
+    }
+    
+    :deep(.el-tabs__item) {
+      font-size: 15px;
+      font-weight: 500;
+    }
+  }
+  
+  .core-data-content {
+    .traffic-data {
+      margin-bottom: 32px;
       
-      h3 {
-        margin: 0 0 12px 0;
+      .section-title {
+        margin: 0 0 16px 0;
         font-size: 16px;
-        line-height: 1.5;
+        font-weight: 600;
+        color: $text-primary;
       }
       
-      .meta-info {
-        display: flex;
-        align-items: center;
-        gap: 12px;
+      .data-cards {
+        display: grid;
+        grid-template-columns: repeat(7, 1fr);
+        gap: 16px;
         
-        .publish-time {
-          font-size: 13px;
-          color: $text-secondary;
+        @media (max-width: 1400px) {
+          grid-template-columns: repeat(4, 1fr);
+        }
+        
+        @media (max-width: 900px) {
+          grid-template-columns: repeat(3, 1fr);
+        }
+        
+        @media (max-width: 600px) {
+          grid-template-columns: repeat(2, 1fr);
+        }
+        
+        .data-card {
+          background: #f8fafc;
+          border-radius: 8px;
+          padding: 20px 16px;
+          text-align: center;
+          border: 1px solid #e5e7eb;
+          
+          &.highlight {
+            background: #fff5f7;
+            border-color: #ff6b9d;
+            
+            .card-value {
+              color: #ff6b9d;
+              font-weight: 700;
+            }
+          }
+          
+          .card-label {
+            font-size: 13px;
+            color: $text-secondary;
+            margin-bottom: 8px;
+          }
+          
+          .card-value {
+            font-size: 24px;
+            font-weight: 600;
+            color: $text-primary;
+            line-height: 1.2;
+          }
         }
       }
     }
-  }
-  
-  .detail-stats {
-    display: grid;
-    grid-template-columns: repeat(5, 1fr);
-    gap: 16px;
-    margin-bottom: 24px;
     
-    .stat-item {
-      background: #f8fafc;
-      border-radius: 12px;
-      padding: 16px;
-      text-align: center;
-      
-      .stat-value {
-        font-size: 24px;
+    .trend-section {
+      .section-title {
+        margin: 0 0 16px 0;
+        font-size: 16px;
         font-weight: 600;
-        color: $primary-color;
-      }
-      
-      .stat-label {
-        font-size: 13px;
-        color: $text-secondary;
-        margin-top: 4px;
+        color: $text-primary;
       }
     }
   }
   
-  .detail-content {
-    h4 {
-      margin: 0 0 12px 0;
-      font-size: 15px;
-      color: $text-primary;
-    }
-    
-    .content-text {
-      font-size: 14px;
-      line-height: 1.8;
-      color: $text-regular;
-      white-space: pre-wrap;
-    }
-  }
 }
 
 @media (max-width: 1400px) {

+ 14 - 0
database/migrations/delete_weixin_video_works_and_related.sql

@@ -0,0 +1,14 @@
+-- 删除 platform=weixin_video 的作品及其关联数据,便于重新同步(使用正确的 objectId)
+-- 执行顺序:先删评论(comments 无 work_id 外键),再删 works(work_day_statistics 有 ON DELETE CASCADE 会随 works 一起删除)
+-- 执行日期: 2026-02-03
+
+USE media_manager;
+
+-- 1. 删除视频号作品对应的评论(comments 表按 account_id + platform + video_id 与作品对应)
+DELETE FROM comments
+WHERE platform = 'weixin_video'
+  AND video_id IN (SELECT platform_video_id FROM works WHERE platform = 'weixin_video');
+
+-- 2. 删除视频号作品(work_day_statistics 因外键 ON DELETE CASCADE 会一并删除)
+DELETE FROM works
+WHERE platform = 'weixin_video';

+ 1 - 0
server/package.json

@@ -9,6 +9,7 @@
     "xhs:import": "tsx src/scripts/run-xhs-import.ts",
     "xhs:work-stats": "tsx src/scripts/run-xhs-work-stats-import.ts",
     "dy:work-stats": "tsx src/scripts/run-dy-work-stats-import.ts",
+    "wx:work-stats": "tsx src/scripts/run-weixin-video-work-stats-import.ts",
     "check:trend": "tsx src/scripts/check-trend-data.ts",
     "clean:work-day-orphans": "tsx src/scripts/clean-work-day-statistics-orphans.ts",
     "check:douyin-account": "tsx src/scripts/check-douyin-account.ts",

+ 2 - 0
server/python/README.md

@@ -104,6 +104,8 @@ python app.py --headless false
 python app.py --debug
 ```
 
+**作品列表调试日志**:若终端里看不到 `/works` 或视频号作品解析的打印,可查看文件 `server/python/tmp/works_debug.log`,每次请求和每条作品的 objectId/exportId/work_id 会追加写入该文件。
+
 ## API 接口
 
 ### 健康检查

+ 1 - 2
server/python/app.py

@@ -75,7 +75,6 @@ from platforms import get_publisher, PLATFORM_MAP
 from platforms.base import PublishParams
 from platforms.weixin import WeixinPublisher
 
-
 def parse_datetime(date_str: str):
     """解析日期时间字符串"""
     if not date_str:
@@ -853,7 +852,7 @@ def get_works():
         page_size = data.get("page_size", 20)
         auto_paging = bool(data.get("auto_paging", False))
         
-        print(f"[Works] 收到请求: platform={platform}, page={page}, page_size={page_size}, auto_paging={auto_paging}")
+        print(f"[Works] 收到请求: platform={platform}, page={page}, page_size={page_size}, auto_paging={auto_paging}", flush=True)
         
         if not platform:
             return jsonify({"success": False, "error": "缺少 platform 参数"}), 400

BIN
server/python/platforms/__pycache__/base.cpython-311.pyc


BIN
server/python/platforms/__pycache__/douyin.cpython-311.pyc


BIN
server/python/platforms/__pycache__/weixin.cpython-311.pyc


+ 1 - 1
server/python/platforms/base.py

@@ -124,7 +124,7 @@ class WorksResult:
     next_page: Any = ""
     error: str = ""
     debug_info: str = ""  # 调试信息
-    
+
     def to_dict(self) -> Dict[str, Any]:
         return {
             "success": self.success,

+ 3 - 4
server/python/platforms/weixin.py

@@ -19,7 +19,6 @@ import time
 # 允许通过环境变量手动指定“上传视频入口”的选择器,便于在页面结构频繁变更时快速调整
 WEIXIN_UPLOAD_SELECTOR = os.environ.get("WEIXIN_UPLOAD_SELECTOR", "").strip()
 
-
 def format_short_title(origin_title: str) -> str:
     """
     格式化短标题
@@ -326,8 +325,7 @@ class WeixinPublisher(BasePublisher):
         # 如果没有安装 Chrome,则使用默认 Chromium
         try:
             self.browser = await playwright.chromium.launch(
-                # headless=self.headless,
-                headless=False,
+                headless=self.headless,
                 channel="chrome"  # 使用系统 Chrome
             )
             print(f"[{self.platform_name}] 使用系统 Chrome 浏览器")
@@ -1148,7 +1146,8 @@ class WeixinPublisher(BasePublisher):
             
             for item in raw_list:
                 try:
-                    work_id = str(item.get("objectId") or item.get("id") or "").strip()
+                    # 存 works.platform_video_id 统一用 post_list 接口回参中的 exportId(如 export/xxx)
+                    work_id = str(item.get("exportId") or item.get("objectId") or item.get("id") or "").strip()
                     if not work_id:
                         work_id = f"weixin_{hash(item.get('createTime',0))}_{hash(item.get('desc', {}).get('description',''))}"
                     

+ 15 - 0
server/python/restart.ps1

@@ -0,0 +1,15 @@
+# 重启 Python 服务:先结束占用 5005 的进程,再启动 app.py
+# 用法:在 PowerShell 中执行 .\restart.ps1
+
+# 1. 结束占用 5005 端口的进程
+$conn = Get-NetTCPConnection -LocalPort 5005 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1
+if ($conn) {
+    Stop-Process -Id $conn.OwningProcess -Force -ErrorAction SilentlyContinue
+    Write-Host "已结束占用 5005 的进程 PID: $($conn.OwningProcess)"
+} else {
+    Write-Host "5005 端口未被占用,无需结束进程"
+}
+
+# 2. 启动 Python 服务(当前窗口会一直运行,Ctrl+C 可停止)
+Set-Location $PSScriptRoot
+python app.py

BIN
server/python/weixin_private_msg_376515.png


BIN
server/python/weixin_private_msg_376632.png


+ 60 - 0
server/src/routes/workDayStatistics.ts

@@ -7,6 +7,7 @@ import { authenticate } from '../middleware/auth.js';
 import { asyncHandler } from '../middleware/error.js';
 import { validateRequest } from '../middleware/validate.js';
 import { WorkDayStatisticsService } from '../services/WorkDayStatisticsService.js';
+import { AppDataSource, Work } from '../models/index.js';
 import { logger } from '../utils/logger.js';
 
 /**
@@ -414,5 +415,64 @@ router.get(
   })
 );
 
+/**
+ * GET /api/work-day-statistics/work/:workId
+ * 获取单个作品的历史统计数据(用于作品详情页)
+ *
+ * 查询参数:
+ * - startDate: 开始日期(可选)
+ * - endDate: 结束日期(可选)
+ */
+router.get(
+  '/work/:workId',
+  [
+    query('startDate').optional().isString().withMessage('startDate 必须是字符串'),
+    query('endDate').optional().isString().withMessage('endDate 必须是字符串'),
+    validateRequest,
+  ],
+  asyncHandler(async (req, res) => {
+    const workId = Number(req.params.workId);
+    const { startDate, endDate } = req.query;
+
+    if (Number.isNaN(workId)) {
+      return res.status(400).json({
+        success: false,
+        message: 'workId 必须是数字',
+      });
+    }
+
+    // 验证作品属于当前用户
+    const workRepository = AppDataSource.getRepository(Work);
+    const work = await workRepository.findOne({
+      where: { id: workId },
+      relations: ['account'],
+    });
+
+    if (!work) {
+      return res.status(404).json({
+        success: false,
+        message: '作品不存在',
+      });
+    }
+
+    if (work.account.userId !== req.user!.userId) {
+      return res.status(403).json({
+        success: false,
+        message: '无权访问该作品',
+      });
+    }
+
+    const data = await workDayStatisticsService.getWorkStatisticsHistory([workId], {
+      startDate: startDate as string | undefined,
+      endDate: endDate as string | undefined,
+    });
+
+    res.json({
+      success: true,
+      data: data[String(workId)] || [],
+    });
+  })
+);
+
 export default router;
 

+ 29 - 1
server/src/scheduler/index.ts

@@ -12,6 +12,7 @@ import { BaijiahaoContentOverviewImportService } from '../services/BaijiahaoCont
 import { WeixinVideoDataCenterImportService } from '../services/WeixinVideoDataCenterImportService.js';
 import { XiaohongshuWorkNoteStatisticsImportService } from '../services/XiaohongshuWorkNoteStatisticsImportService.js';
 import { DouyinWorkStatisticsImportService } from '../services/DouyinWorkStatisticsImportService.js';
+import { WeixinVideoWorkStatisticsImportService } from '../services/WeixinVideoWorkStatisticsImportService.js';
 
 /**
  * 定时任务调度器
@@ -25,6 +26,7 @@ export class TaskScheduler {
   private isDyWorkImportRunning = false; // 抖音作品日统计导入锁
   private isBjImportRunning = false; // 百家号导入锁,防止任务重叠执行
   private isWxImportRunning = false; // 视频号导入锁,防止任务重叠执行
+  private isWxWorkImportRunning = false; // 视频号作品日统计导入锁
   private isAutoReplying = false; // 私信回复锁,防止任务重叠执行
   /**
    * 启动调度器
@@ -61,7 +63,15 @@ export class TaskScheduler {
 
     // 每天 12:30:批量导出视频号“数据中心-各子菜单-增长详情(数据详情)-近30天-下载表格”,导入 user_day_statistics
     this.scheduleJob('wx-video-data-center-import', '30 12 * * *', this.importWeixinVideoDataCenterLast30Days.bind(this));
-    
+
+    // 每天 12:35:同步视频号作品维度的「作品列表 + 按天聚合-全部」数据,写入 work_day_statistics
+    // [已中止] 暂时禁用,等待接口问题解决
+    // this.scheduleJob(
+    //   'wx-video-work-statistics-import',
+    //   '35 12 * * *',
+    //   this.importWeixinVideoWorkStatistics.bind(this)
+    // );
+
     this.scheduleJob('auto-reply-messages', '* * * * *', this.autoReplyMessages.bind(this));
     // 注意:账号刷新由客户端定时触发,不在服务端自动执行
     // 这样可以确保只刷新当前登录用户的账号,避免处理其他用户的数据
@@ -79,6 +89,7 @@ export class TaskScheduler {
     logger.info('[Scheduler]   - dy-work-statistics-import:  daily at 12:50 (50 12 * * *)');
     logger.info('[Scheduler]   - bj-content-overview-import: daily at 12:20 (20 12 * * *)');
     logger.info('[Scheduler]   - wx-video-data-center-import: daily at 12:30 (30 12 * * *)');
+    // logger.info('[Scheduler]   - wx-video-work-statistics-import: daily at 12:35 (35 12 * * *)');
     logger.info('[Scheduler]   - auto-reply-messages: every minute (* * * * *)');
     logger.info('[Scheduler] Note: Account refresh is triggered by client, not server');
     logger.info('[Scheduler] ========================================');
@@ -524,6 +535,23 @@ export class TaskScheduler {
       this.isWxImportRunning = false;
     }
   }
+
+  /**
+   * 视频号:作品维度「作品列表 + feed_aggreagate_data_by_tab_type 全部」→ 导入 work_day_statistics
+   */
+  private async importWeixinVideoWorkStatistics(): Promise<void> {
+    if (this.isWxWorkImportRunning) {
+      logger.info('[Scheduler] Weixin video work statistics import is already running, skipping...');
+      return;
+    }
+
+    this.isWxWorkImportRunning = true;
+    try {
+      await WeixinVideoWorkStatisticsImportService.runDailyImport();
+    } finally {
+      this.isWxWorkImportRunning = false;
+    }
+  }
 }
 
 export const taskScheduler = new TaskScheduler();

+ 18 - 0
server/src/scripts/run-weixin-video-work-stats-import.ts

@@ -0,0 +1,18 @@
+import { initDatabase } from '../models/index.js';
+import { logger } from '../utils/logger.js';
+import { WeixinVideoWorkStatisticsImportService } from '../services/WeixinVideoWorkStatisticsImportService.js';
+
+async function main() {
+  try {
+    await initDatabase();
+    logger.info('[WX WorkStats] Manual run start...');
+    await WeixinVideoWorkStatisticsImportService.runDailyImport();
+    logger.info('[WX WorkStats] Manual run done.');
+    process.exit(0);
+  } catch (e) {
+    logger.error('[WX WorkStats] Manual run failed:', e);
+    process.exit(1);
+  }
+}
+
+void main();

+ 1 - 1
server/src/services/HeadlessBrowserService.ts

@@ -735,6 +735,7 @@ class HeadlessBrowserService {
       const pageParam: number | string = useCursorPagination ? cursor : pageIndex;
       logger.info(`[Python API] Fetching works page=${String(pageParam)}, page_size=${pageSize} for ${platform}`);
 
+      logger.info(`[Python API] 调用 Python /works: platform=${platform} -> pythonPlatform=${pythonPlatform}, page=${String(pageParam)}, url=${PYTHON_SERVICE_URL}`);
       const response: Response = await fetch(`${PYTHON_SERVICE_URL}/works`, {
         method: 'POST',
         headers: {
@@ -755,7 +756,6 @@ class HeadlessBrowserService {
 
       const result: any = await response.json();
 
-      // 记录 Python API 的详细响应(用于调试)
       if (pageIndex === 0) {
         logger.info(`[Python API] Response for ${platform}: success=${result.success}, works_count=${result.works?.length || 0}, total=${result.total || 0}, has_more=${result.has_more}, error=${result.error || 'none'}`);
         if (result.error) {

+ 447 - 0
server/src/services/WeixinVideoWorkStatisticsImportService.ts

@@ -0,0 +1,447 @@
+/**
+ * 视频号:作品维度「作品列表 + 按天聚合数据」→ 导入 work_day_statistics
+ *
+ * 流程:
+ * 1. 获取 works 表中 platform=weixin_video 的作品(platform_video_id 存的是 exportId)
+ * 2. 调用 post_list 接口获取作品列表,通过 exportId 匹配得到 objectId
+ * 3. 对每个作品调用 feed_aggreagate_data_by_tab_type,取「全部」tab 的按天数据
+ * 4. 将 browse→播放、like→点赞、comment→评论 写入 work_day_statistics(follow=关注、fav/forward 暂不入库)
+ */
+
+import crypto from 'crypto';
+import { AppDataSource, PlatformAccount, Work } from '../models/index.js';
+import { logger } from '../utils/logger.js';
+import { WorkDayStatisticsService } from './WorkDayStatisticsService.js';
+import { CookieManager } from '../automation/cookie.js';
+
+const POST_LIST_BASE =
+  'https://channels.weixin.qq.com/micro/statistic/cgi-bin/mmfinderassistant-bin/statistic/post_list';
+const FEED_AGGREGATE_BASE =
+  'https://channels.weixin.qq.com/micro/statistic/cgi-bin/mmfinderassistant-bin/statistic/feed_aggreagate_data_by_tab_type';
+
+/** 列表页 _pageUrl(与浏览器「数据统计-作品」列表一致) */
+const POST_LIST_PAGE_URL = 'https://channels.weixin.qq.com/micro/statistic/post';
+/** 详情页 _pageUrl(与浏览器 postDetail 一致,feed_aggreagate 用) */
+const POST_DETAIL_PAGE_URL = 'https://channels.weixin.qq.com/micro/statistic/postDetail';
+
+/** 生成随机 _rid(格式如 6982df69-ff6e46a5,8hex-8hex) */
+function generateRandomRid(): string {
+  const a = crypto.randomBytes(4).toString('hex');
+  const b = crypto.randomBytes(4).toString('hex');
+  return `${a}-${b}`;
+}
+
+/**
+ * 构建带 _aid、_rid、_pageUrl 的 URL。
+ * 若传入 sessionAid/sessionRid 则优先使用(本账号 post_list 生成的,复用于 feed_aggreagate);
+ * 否则读环境变量 WX_VIDEO_AID、WX_VIDEO_RID。
+ */
+function buildUrlWithAidRid(
+  base: string,
+  pageUrl: string,
+  sessionAid?: string,
+  sessionRid?: string
+): string {
+  const aid = sessionAid ?? process.env.WX_VIDEO_AID?.trim() ?? '';
+  const rid = sessionRid ?? process.env.WX_VIDEO_RID?.trim() ?? '';
+  const params = new URLSearchParams();
+  if (aid) params.set('_aid', aid);
+  if (rid) params.set('_rid', rid);
+  params.set('_pageUrl', pageUrl);
+  const qs = params.toString();
+  return qs ? `${base}?${qs}` : base;
+}
+
+function tryDecryptCookieData(cookieData: string | null): string | null {
+  if (!cookieData) return null;
+  const raw = cookieData.trim();
+  if (!raw) return null;
+  try {
+    return CookieManager.decrypt(raw);
+  } catch {
+    return raw;
+  }
+}
+
+/** 将账号 cookie_data 转为 HTTP Cookie 头字符串 */
+function getCookieHeaderString(cookieData: string | null): string {
+  const raw = tryDecryptCookieData(cookieData);
+  if (!raw) return '';
+  const s = raw.trim();
+  if (!s) return '';
+  if (s.startsWith('[') || s.startsWith('{')) {
+    try {
+      const parsed = JSON.parse(s);
+      const arr = Array.isArray(parsed) ? parsed : parsed?.cookies ?? [];
+      if (!Array.isArray(arr)) return '';
+      return arr
+        .map((c: { name?: string; value?: string }) => {
+          const name = String(c?.name ?? '').trim();
+          const value = String(c?.value ?? '').trim();
+          return name ? `${name}=${value}` : '';
+        })
+        .filter(Boolean)
+        .join('; ');
+    } catch {
+      return s;
+    }
+  }
+  return s;
+}
+
+/** 从 Cookie 字符串中解析 x-wechat-uin(可选) */
+function getXWechatUinFromCookie(cookieHeader: string): string | undefined {
+  const match = cookieHeader.match(/\bwxuin=(\d+)/i);
+  return match ? match[1] : undefined;
+}
+
+/** 从账号 account_id 得到 _log_finder_id(去掉 weixin_video_ 前缀,保证以 @finder 结尾) */
+function getLogFinderId(accountId: string | null): string {
+  if (!accountId) return '';
+  const s = String(accountId).trim();
+  const prefix = 'weixin_video_';
+  const id = s.startsWith(prefix) ? s.slice(prefix.length) : s;
+  if (!id) return '';
+  return id.endsWith('@finder') ? id : `${id}@finder`;
+}
+
+function buildPostListUrl(sessionAid?: string, sessionRid?: string): string {
+  return buildUrlWithAidRid(POST_LIST_BASE, POST_LIST_PAGE_URL, sessionAid, sessionRid);
+}
+
+function buildFeedAggregateUrl(sessionAid?: string, sessionRid?: string): string {
+  return buildUrlWithAidRid(FEED_AGGREGATE_BASE, POST_DETAIL_PAGE_URL, sessionAid, sessionRid);
+}
+
+/** 近30天到昨天:返回 [startTime, endTime] Unix 秒(中国时间 00:00:00 起算) */
+function getLast30DaysRange(): { startTime: number; endTime: number; startDate: Date; endDate: Date } {
+  const now = new Date();
+  const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
+  const startDate = new Date(yesterday.getFullYear(), yesterday.getMonth(), yesterday.getDate() - 30);
+  startDate.setHours(0, 0, 0, 0);
+  yesterday.setHours(23, 59, 59, 999);
+  const startTime = Math.floor(startDate.getTime() / 1000);
+  const endTime = Math.floor(yesterday.getTime() / 1000);
+  const endDateNorm = new Date(yesterday.getFullYear(), yesterday.getMonth(), yesterday.getDate());
+  endDateNorm.setHours(0, 0, 0, 0);
+  return { startTime, endTime, startDate, endDate: endDateNorm };
+}
+
+function toInt(val: unknown, defaultVal = 0): number {
+  if (typeof val === 'number') return Number.isFinite(val) ? Math.round(val) : defaultVal;
+  if (typeof val === 'string') {
+    const n = parseInt(val, 10);
+    return Number.isFinite(n) ? n : defaultVal;
+  }
+  return defaultVal;
+}
+
+interface PostListItem {
+  objectId?: string;
+  exportId?: string;
+}
+
+interface FeedAggregateDataByTabType {
+  tabType?: number;
+  tabTypeName?: string;
+  data?: {
+    browse?: string[];
+    like?: string[];
+    comment?: string[];
+    forward?: string[];
+    fav?: string[];
+    follow?: string[];
+  };
+}
+
+export class WeixinVideoWorkStatisticsImportService {
+  private accountRepository = AppDataSource.getRepository(PlatformAccount);
+  private workRepository = AppDataSource.getRepository(Work);
+  private workDayStatisticsService = new WorkDayStatisticsService();
+
+  static async runDailyImport(): Promise<void> {
+    const svc = new WeixinVideoWorkStatisticsImportService();
+    await svc.runDailyImportForAllWeixinVideoAccounts();
+  }
+
+  async runDailyImportForAllWeixinVideoAccounts(): Promise<void> {
+    const accounts = await this.accountRepository.find({
+      where: { platform: 'weixin_video' as any },
+    });
+    logger.info(`[WX WorkStats] Start import for ${accounts.length} weixin_video accounts`);
+    for (const account of accounts) {
+      try {
+        await this.importAccountWorksStatistics(account);
+      } catch (e) {
+        logger.error(
+          `[WX WorkStats] Account failed. accountId=${account.id} name=${account.accountName || ''}`,
+          e
+        );
+      }
+    }
+    logger.info('[WX WorkStats] All accounts done');
+  }
+
+  private async importAccountWorksStatistics(account: PlatformAccount): Promise<void> {
+    const cookieHeader = getCookieHeaderString(account.cookieData);
+    if (!cookieHeader) {
+      logger.warn(`[WX WorkStats] accountId=${account.id} cookieData 为空或无法解析,跳过`);
+      return;
+    }
+
+    const works = await this.workRepository.find({
+      where: { accountId: account.id, platform: 'weixin_video' as any },
+    });
+    if (!works.length) {
+      logger.info(`[WX WorkStats] accountId=${account.id} 没有作品,跳过`);
+      return;
+    }
+
+    const { startTime, endTime, startDate, endDate } = getLast30DaysRange();
+    const logFinderId = getLogFinderId(account.accountId);
+    const xWechatUin = getXWechatUinFromCookie(cookieHeader);
+
+    // _aid:post_list 时生成一次,本批次请求数据接口(feed_aggreagate)时复用;_rid 每次请求随机
+    const sessionAid =
+      process.env.WX_VIDEO_AID?.trim() || crypto.randomUUID();
+    logger.info(`[WX WorkStats] accountId=${account.id} post_list 生成 aid=${sessionAid},数据接口复用此 aid,rid 每次随机`);
+
+    const headers: Record<string, string> = {
+      accept: '*/*',
+      'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
+      'content-type': 'application/json',
+      cookie: cookieHeader,
+      origin: 'https://channels.weixin.qq.com',
+      referer: 'https://channels.weixin.qq.com/micro/statistic/post',
+      '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 Edg/144.0.0.0',
+    };
+    if (xWechatUin) headers['x-wechat-uin'] = xWechatUin;
+
+    const postListBody = {
+      pageSize: 100,
+      currentPage: 1,
+      sort: 0,
+      order: 0,
+      startTime,
+      endTime,
+      timestamp: String(Date.now()),
+      _log_finder_uin: '',
+      _log_finder_id: logFinderId,
+      rawKeyBuff: null,
+      pluginSessionId: null,
+      scene: 7,
+      reqScene: 7,
+    };
+
+    const postListUrl = buildPostListUrl(sessionAid, generateRandomRid());
+    let res: Response;
+    try {
+      res = await fetch(postListUrl, {
+        method: 'POST',
+        headers,
+        body: JSON.stringify(postListBody),
+        signal: AbortSignal.timeout(30_000),
+      });
+    } catch (e) {
+      logger.error(`[WX WorkStats] post_list request failed. accountId=${account.id}`, e);
+      throw e;
+    }
+
+    if (!res.ok) {
+      logger.warn(`[WX WorkStats] post_list HTTP ${res.status}. accountId=${account.id}`);
+      return;
+    }
+
+    const postListJson = (await res.json().catch(() => null)) as {
+      errCode?: number;
+      errMsg?: string;
+      data?: { list?: PostListItem[]; totalCount?: number };
+    } | null;
+
+    if (!postListJson || postListJson.errCode !== 0) {
+      logger.warn(
+        `[WX WorkStats] post_list errCode=${postListJson?.errCode} errMsg=${postListJson?.errMsg}. accountId=${account.id}`
+      );
+      return;
+    }
+
+    const list = postListJson.data?.list ?? [];
+    const totalCount = postListJson.data?.totalCount ?? list.length;
+    const exportIdToObjectId = new Map<string, string>();
+    for (const item of list) {
+      const exportId = item.exportId ?? '';
+      const objectId = item.objectId ?? '';
+      if (exportId && objectId) exportIdToObjectId.set(exportId, objectId);
+    }
+
+    // 日志:对比 API 与 DB 的 exportId
+    logger.info(
+      `[WX WorkStats] accountId=${account.id} post_list 返回 totalCount=${totalCount} list.length=${list.length}`
+    );
+    const apiExportIds: string[] = [];
+    for (let i = 0; i < list.length; i++) {
+      const item = list[i];
+      const eid = (item.exportId ?? '').trim();
+      const oid = (item.objectId ?? '').trim();
+      apiExportIds.push(eid);
+      logger.info(`[WX WorkStats] post_list[${i}] exportId=${eid} objectId=${oid}`);
+    }
+    for (const work of works) {
+      const dbExportId = (work.platformVideoId ?? '').trim();
+      if (!dbExportId) continue;
+      const matched = exportIdToObjectId.has(dbExportId);
+      logger.info(
+        `[WX WorkStats] DB workId=${work.id} platform_video_id(exportId)=${dbExportId} 匹配post_list=${matched}`
+      );
+      if (!matched && apiExportIds.length > 0) {
+        const sameLength = apiExportIds.filter((e) => e.length === dbExportId.length).length;
+        const containsDb = apiExportIds.some((e) => e === dbExportId || e.includes(dbExportId) || dbExportId.includes(e));
+        logger.info(
+          `[WX WorkStats] 对比: DB长度=${dbExportId.length} API条数=${apiExportIds.length} 同长API条数=${sameLength} 是否包含关系=${containsDb}`
+        );
+      }
+    }
+
+    let totalInserted = 0;
+    let totalUpdated = 0;
+
+    const feedHeaders: Record<string, string> = {
+      ...headers,
+      referer: 'https://channels.weixin.qq.com/micro/statistic/postDetail?isImageMode=0',
+      'finger-print-device-id':
+        process.env.WX_VIDEO_FINGERPRINT_DEVICE_ID?.trim() ||
+        '4605bc28ad3962eb9ee791897b199217',
+    };
+
+    for (const work of works) {
+      const exportId = (work.platformVideoId ?? '').trim();
+      if (!exportId) continue;
+
+      const objectId = exportIdToObjectId.get(exportId);
+      if (!objectId) {
+        logger.debug(`[WX WorkStats] workId=${work.id} exportId=${exportId} 未在 post_list 中匹配到 objectId,跳过`);
+        continue;
+      }
+
+      const feedBody = {
+        startTs: String(startTime),
+        endTs: String(endTime),
+        interval: 3,
+        feedId: objectId,
+        timestamp: String(Date.now()),
+        _log_finder_uin: '',
+        _log_finder_id: logFinderId,
+        rawKeyBuff: null,
+        pluginSessionId: null,
+        scene: 7,
+        reqScene: 7,
+      };
+
+      const feedUrl = buildFeedAggregateUrl(sessionAid, generateRandomRid());
+      let feedRes: Response;
+      try {
+        feedRes = await fetch(feedUrl, {
+          method: 'POST',
+          headers: feedHeaders,
+          body: JSON.stringify(feedBody),
+          signal: AbortSignal.timeout(30_000),
+        });
+      } catch (e) {
+        logger.error(`[WX WorkStats] feed_aggreagate request failed. workId=${work.id} feedId=${objectId}`, e);
+        continue;
+      }
+
+      if (!feedRes.ok) {
+        logger.warn(`[WX WorkStats] feed_aggreagate HTTP ${feedRes.status}. workId=${work.id}`);
+        continue;
+      }
+
+      const feedJson = (await feedRes.json().catch(() => null)) as {
+        errCode?: number;
+        data?: {
+          dataByFanstype?: { dataByTabtype?: FeedAggregateDataByTabType[] }[];
+          feedData?: { dataByTabtype?: FeedAggregateDataByTabType[] }[];
+        };
+      } | null;
+
+      const isTestWork = work.id === 866 || work.id === 867 || work.id === 902 || work.id === 903;
+      if (isTestWork) {
+        logger.info(`[WX WorkStats] feed_aggreagate 原始响应 workId=${work.id} errCode=${feedJson?.errCode} errMsg=${(feedJson as any)?.errMsg} 完整body=${JSON.stringify(feedJson ?? null)}`);
+      }
+
+      if (!feedJson || feedJson.errCode !== 0) {
+        if (isTestWork) {
+          logger.warn(`[WX WorkStats] workId=${work.id} feed_aggreagate 非成功 errCode=${feedJson?.errCode} 跳过`);
+        }
+        continue;
+      }
+
+      const dataByFanstype = feedJson.data?.dataByFanstype ?? [];
+      const firstFans = dataByFanstype[0];
+      const dataByTabtype = firstFans?.dataByTabtype ?? feedJson.data?.feedData?.[0]?.dataByTabtype ?? [];
+      const tabAll = dataByTabtype.find((t) => t.tabTypeName === '全部' || t.tabType === 999);
+      if (isTestWork) {
+        logger.info(
+          `[WX WorkStats] workId=${work.id} dataByTabtype.length=${dataByTabtype.length} tabAll=${!!tabAll} tabAll.tabTypeName=${tabAll?.tabTypeName}`
+        );
+      }
+      if (!tabAll?.data) continue;
+
+      const data = tabAll.data;
+      const browse = data.browse ?? [];
+      const like = data.like ?? [];
+      const comment = data.comment ?? [];
+      if (isTestWork) {
+        logger.info(
+          `[WX WorkStats] workId=${work.id} 「全部」data: browse.length=${browse.length} like.length=${like.length} comment.length=${comment.length}`
+        );
+        logger.info(`[WX WorkStats] workId=${work.id} browse=${JSON.stringify(browse)}`);
+        logger.info(`[WX WorkStats] workId=${work.id} like=${JSON.stringify(like)}`);
+        logger.info(`[WX WorkStats] workId=${work.id} comment=${JSON.stringify(comment)}`);
+      }
+
+      const len = Math.max(browse.length, like.length, comment.length);
+      if (len === 0) continue;
+
+      const patches: Array<{
+        workId: number;
+        recordDate: Date;
+        playCount?: number;
+        likeCount?: number;
+        commentCount?: number;
+      }> = [];
+
+      for (let i = 0; i < len; i++) {
+        const recordDate = new Date(startDate);
+        recordDate.setDate(recordDate.getDate() + i);
+        recordDate.setHours(0, 0, 0, 0);
+        if (recordDate > endDate) break;
+
+        patches.push({
+          workId: work.id,
+          recordDate,
+          playCount: toInt(browse[i], 0),
+          likeCount: toInt(like[i], 0),
+          commentCount: toInt(comment[i], 0),
+        });
+      }
+
+      if (isTestWork) {
+        logger.info(`[WX WorkStats] workId=${work.id} 生成 patches.length=${patches.length} 前3条=${JSON.stringify(patches.slice(0, 3))}`);
+      }
+      if (patches.length) {
+        const result = await this.workDayStatisticsService.saveStatisticsForDateBatch(patches);
+        if (isTestWork) {
+          logger.info(`[WX WorkStats] workId=${work.id} saveStatisticsForDateBatch inserted=${result.inserted} updated=${result.updated}`);
+        }
+        totalInserted += result.inserted;
+        totalUpdated += result.updated;
+      }
+    }
+
+    logger.info(
+      `[WX WorkStats] accountId=${account.id} completed. inserted=${totalInserted} updated=${totalUpdated}`
+    );
+  }
+}

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

@@ -57,6 +57,9 @@ interface WorkStatisticsItem {
   commentCount: number;
   shareCount: number;
   collectCount: number;
+  fansIncrease?: number;
+  totalWatchDuration?: string;
+  avgWatchDuration?: string;
 }
 
 export class WorkDayStatisticsService {
@@ -600,6 +603,9 @@ export class WorkDayStatisticsService {
       .addSelect('wds.comment_count', 'commentCount')
       .addSelect('wds.share_count', 'shareCount')
       .addSelect('wds.collect_count', 'collectCount')
+      .addSelect('wds.fans_increase', 'fansIncrease')
+      .addSelect('wds.total_watch_duration', 'totalWatchDuration')
+      .addSelect('wds.avg_watch_duration', 'avgWatchDuration')
       .where('wds.work_id IN (:...workIds)', { workIds })
       .orderBy('wds.work_id', 'ASC')
       .addOrderBy('wds.record_date', 'ASC');
@@ -633,6 +639,9 @@ export class WorkDayStatisticsService {
         commentCount: parseInt(row.commentCount) || 0,
         shareCount: parseInt(row.shareCount) || 0,
         collectCount: parseInt(row.collectCount) || 0,
+        fansIncrease: parseInt(row.fansIncrease) || 0,
+        totalWatchDuration: row.totalWatchDuration || '0',
+        avgWatchDuration: row.avgWatchDuration || '0',
       });
     }
 

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio