Quellcode durchsuchen

定时任务同步小红书数据

Ethanfly vor 7 Stunden
Ursprung
Commit
c3cc160c32
41 geänderte Dateien mit 3593 neuen und 97 gelöschten Zeilen
  1. 28 6
      client/src/layouts/MainLayout.vue
  2. 5 0
      client/src/router/index.ts
  3. 38 5
      client/src/stores/tabs.ts
  4. 14 1
      client/src/stores/taskQueue.ts
  5. 32 6
      client/src/views/Analytics/Platform/index.vue
  6. 556 0
      client/src/views/Analytics/PlatformDetail/index.vue
  7. 15 0
      database/migrations/add_fields_to_user_day_statistics.sql
  8. 143 0
      database/migrations/add_or_update_user_day_statistics_fields.sql
  9. 22 0
      database/migrations/add_user_day_statistics_fields_complete.sql
  10. 19 0
      database/migrations/add_user_day_statistics_fields_execute.sql
  11. 32 0
      database/migrations/add_user_day_statistics_fields_manual.sql
  12. 21 0
      database/migrations/add_user_day_statistics_fields_simple.sql
  13. 11 0
      database/migrations/change_user_day_statistics_fields_to_string.sql
  14. 8 0
      database/schema.sql
  15. 424 0
      docs/platform-detail-data-calculation-logic.md
  16. 284 0
      docs/platform-detail-data-logic.md
  17. 250 0
      docs/platform-list-api-error-fix.md
  18. 65 35
      pnpm-lock.yaml
  19. 4 1
      server/package.json
  20. BIN
      server/python/platforms/__pycache__/xiaohongshu.cpython-311.pyc
  21. 24 0
      server/src/models/entities/UserDayStatistics.ts
  22. 82 27
      server/src/routes/analytics.ts
  23. 27 0
      server/src/routes/workDayStatistics.ts
  24. 25 0
      server/src/scheduler/index.ts
  25. 20 0
      server/src/scripts/run-xhs-import.ts
  26. 15 14
      server/src/services/AccountService.ts
  27. 97 2
      server/src/services/UserDayStatisticsService.ts
  28. 264 0
      server/src/services/WorkDayStatisticsService.ts
  29. 415 0
      server/src/services/XiaohongshuAccountOverviewImportService.ts
  30. BIN
      server/tmp/xhs-account-overview/35_1769581578665_近30日观看数据.xlsx
  31. BIN
      server/tmp/xhs-account-overview/35_1769582575166_近30日观看数据.xlsx
  32. BIN
      server/tmp/xhs-account-overview/35_1769582795460_近30日观看数据.xlsx
  33. BIN
      server/tmp/xhs-account-overview/35_1769582877553_近30日观看数据.xlsx
  34. BIN
      server/tmp/xhs-account-overview/8_1769581512725_近30日观看数据.xlsx
  35. BIN
      server/tmp/xhs-account-overview/8_1769582508941_近30日观看数据.xlsx
  36. BIN
      server/tmp/xhs-account-overview/8_1769582729004_近30日观看数据.xlsx
  37. BIN
      server/tmp/xhs-account-overview/8_1769582810145_近30日观看数据.xlsx
  38. 217 0
      server/tmp/xhs-storage-state/27.json
  39. 217 0
      server/tmp/xhs-storage-state/35.json
  40. 217 0
      server/tmp/xhs-storage-state/8.json
  41. 2 0
      shared/src/constants/api.ts

+ 28 - 6
client/src/layouts/MainLayout.vue

@@ -252,8 +252,8 @@
               />
               <!-- 页面标签页 -->
               <component 
-                v-else-if="tab.type === 'page' && pageComponents[tab.path || '']"
-                :is="pageComponents[tab.path || '']"
+                v-else-if="tab.type === 'page' && getPageComponent(tab.path || '')"
+                :is="getPageComponent(tab.path || '')"
                 :key="tab.path"
               />
             </div>
@@ -450,15 +450,21 @@ function initTabsFromRoute() {
   }
   
   // 如果当前路由是有效的页面路由,打开对应标签页
-  if (currentPath && currentPath !== '/' && pageComponents.value[currentPath]) {
-    tabsStore.openPageTab(currentPath);
+  if (currentPath && currentPath !== '/') {
+    // 检查精确匹配或动态路由匹配
+    if (pageComponents.value[currentPath] || getPageComponent(currentPath)) {
+      tabsStore.openPageTab(currentPath);
+    }
   }
 }
 
 // 监听路由变化,同步标签页
 watch(() => route.path, (newPath) => {
-  if (newPath && pageComponents.value[newPath]) {
-    tabsStore.openPageTab(newPath);
+  if (newPath) {
+    // 检查精确匹配或动态路由匹配
+    if (pageComponents.value[newPath] || getPageComponent(newPath)) {
+      tabsStore.openPageTab(newPath);
+    }
   }
 });
 
@@ -492,6 +498,22 @@ const activeMenuPath = computed(() => {
   return '/';
 });
 
+// 获取页面组件(支持动态路由)
+function getPageComponent(path: string) {
+  // 精确匹配
+  if (pageComponents.value[path]) {
+    return pageComponents.value[path];
+  }
+  
+  // 动态路由匹配:/analytics/platform-detail/:platform
+  if (path.startsWith('/analytics/platform-detail/')) {
+    return pageComponents.value['/analytics/platform-detail'] || 
+           defineAsyncComponent(() => import('@/views/Analytics/PlatformDetail/index.vue'));
+  }
+  
+  return undefined;
+}
+
 // 获取标签页图标组件
 function getTabIcon(iconName?: string) {
   if (!iconName) return Document;

+ 5 - 0
client/src/router/index.ts

@@ -73,6 +73,11 @@ const routes: RouteRecordRaw[] = [
             component: () => import('@/views/Analytics/Platform/index.vue'),
           },
           {
+            path: 'platform-detail/:platform',
+            name: 'AnalyticsPlatformDetail',
+            component: () => import('@/views/Analytics/PlatformDetail/index.vue'),
+          },
+          {
             path: 'account',
             name: 'AnalyticsAccount',
             component: () => import('@/views/Analytics/Account/index.vue'),

+ 38 - 5
client/src/stores/tabs.ts

@@ -100,21 +100,54 @@ export const useTabsStore = defineStore('tabs', () => {
   
   // 打开页面标签页
   function openPageTab(path: string, component?: Component): Tab {
-    // 检查是否已存在
-    const existingTab = tabs.value.find(t => t.type === 'page' && t.path === path);
+    // 检查是否已存在(支持动态路由匹配)
+    const existingTab = tabs.value.find(t => {
+      if (t.type === 'page' && t.path) {
+        // 精确匹配
+        if (t.path === path) return true;
+        // 动态路由匹配:/analytics/platform-detail/:platform
+        if (path.startsWith('/analytics/platform-detail/') && 
+            t.path?.startsWith('/analytics/platform-detail/')) {
+          return true;
+        }
+      }
+      return false;
+    });
+    
     if (existingTab) {
+      // 如果路径不同,更新路径(用于动态路由)
+      if (existingTab.path !== path) {
+        existingTab.path = path;
+      }
       activeTabId.value = existingTab.id;
       return existingTab;
     }
     
-    const config = PAGE_CONFIG[path] || { title: '未知页面', icon: 'Document' };
+    // 获取页面配置(支持动态路由)
+    let config = PAGE_CONFIG[path];
+    if (!config && path.startsWith('/analytics/platform-detail/')) {
+      // 从路径中提取平台名称
+      const platform = path.split('/').pop() || '';
+      const platformNames: Record<string, string> = {
+        'douyin': '抖音',
+        'xiaohongshu': '小红书',
+        'baijiahao': '百家号',
+        'weixin_video': '视频号',
+      };
+      config = { 
+        title: `${platformNames[platform] || platform}平台数据详情`, 
+        icon: 'TrendCharts' 
+      };
+    }
+    
+    const finalConfig = config || { title: '未知页面', icon: 'Document' };
     
     const tab = addTab({
-      title: config.title,
+      title: finalConfig.title,
       type: 'page',
       path,
       component: component ? markRaw(component) : undefined,
-      icon: config.icon,
+      icon: finalConfig.icon,
       // 首页不可关闭
       closable: path !== '/',
     });

+ 14 - 1
client/src/stores/taskQueue.ts

@@ -1,7 +1,8 @@
 import { defineStore } from 'pinia';
 import { ref, computed } from 'vue';
+import { ElMessage } from 'element-plus';
 import type { Task, TaskType, CreateTaskRequest, TaskProgressUpdate } from '@media-manager/shared';
-import { TASK_TYPE_CONFIG, TASK_WS_EVENTS } from '@media-manager/shared';
+import { TASK_TYPE_CONFIG, TASK_WS_EVENTS, WS_EVENTS } from '@media-manager/shared';
 import { useAuthStore } from './auth';
 import { useServerStore } from './server';
 import request from '@/api/request';
@@ -181,6 +182,18 @@ export const useTaskQueueStore = defineStore('taskQueue', () => {
 
   // 处理 WebSocket 消息
   function handleWebSocketMessage(data: { type?: string; payload?: Record<string, unknown> }) {
+    // 系统通知:直接按 type 处理(不走 task 事件映射)
+    if (data.type === WS_EVENTS.SYSTEM_MESSAGE || data.type === 'system:message') {
+      const payload = (data.payload || {}) as any;
+      const msg = payload.message || '系统通知';
+      const level = payload.level || 'warning';
+      if (level === 'success') ElMessage.success(msg);
+      else if (level === 'info') ElMessage.info(msg);
+      else if (level === 'error') ElMessage.error(msg);
+      else ElMessage.warning(msg);
+      return;
+    }
+
     const event = (data.payload?.event as string) || data.type?.split(':')[1] || '';
 
     switch (event) {

+ 32 - 6
client/src/views/Analytics/Platform/index.vue

@@ -79,7 +79,11 @@
         </el-table-column>
         <el-table-column label="操作" width="100" align="center" fixed="right">
           <template #default="{ row }">
-            <el-button type="primary" link @click="handleDetail(row)">
+            <el-button 
+              type="primary" 
+              link 
+              @click.stop.prevent="handleDetail(row)"
+            >
               详情
             </el-button>
           </template>
@@ -146,6 +150,7 @@
 
 <script setup lang="ts">
 import { ref, computed, onMounted, watch, nextTick } from 'vue';
+import { useRouter } from 'vue-router';
 import * as echarts from 'echarts';
 import { PLATFORMS } from '@media-manager/shared';
 import type { PlatformType } from '@media-manager/shared';
@@ -162,6 +167,7 @@ import kuaishouIconUrl from '@/assets/platforms/kuaishou.svg?url';
 import weixinVideoIconUrl from '@/assets/platforms/weixin_video.svg?url';
 import baijiahaoIconUrl from '@/assets/platforms/baijiahao.svg?url';
 
+const router = useRouter();
 const authStore = useAuthStore();
 const serverStore = useServerStore();
 const loading = ref(false);
@@ -301,12 +307,32 @@ async function loadData() {
 }
 
 // 查看详情
-async function handleDetail(row: PlatformData) {
-  selectedPlatform.value = row;
-  drawerVisible.value = true;
+function handleDetail(row: PlatformData) {
+  console.log('[Platform] handleDetail called, row:', row);
+  console.log('[Platform] platform:', row.platform);
+  console.log('[Platform] startDate:', startDate.value, 'endDate:', endDate.value);
   
-  await nextTick();
-  loadPlatformDetail(row.platform);
+  try {
+    // 跳转到平台详情页面
+    router.push({
+      name: 'AnalyticsPlatformDetail',
+      params: {
+        platform: row.platform,
+      },
+      query: {
+        startDate: startDate.value,
+        endDate: endDate.value,
+      },
+    }).then(() => {
+      console.log('[Platform] 路由跳转成功');
+    }).catch((error) => {
+      console.error('[Platform] 路由跳转失败:', error);
+      ElMessage.error('跳转失败: ' + (error?.message || '未知错误'));
+    });
+  } catch (error: any) {
+    console.error('[Platform] handleDetail 错误:', error);
+    ElMessage.error('跳转失败: ' + (error?.message || '未知错误'));
+  }
 }
 
 // 加载平台详情

+ 556 - 0
client/src/views/Analytics/PlatformDetail/index.vue

@@ -0,0 +1,556 @@
+<template>
+  <div class="platform-detail-page">
+    <!-- 调试信息 -->
+    <div v-if="false" style="display: none;">
+      {{ console.log('[PlatformDetail] 模板渲染') }}
+    </div>
+    <!-- 顶部筛选栏 -->
+    <div class="filter-bar">
+      <div class="filter-left">
+        <span class="filter-label">开始时间</span>
+        <el-date-picker
+          v-model="startDate"
+          type="date"
+          placeholder="选择日期"
+          format="YYYY-MM-DD"
+          value-format="YYYY-MM-DD"
+          style="width: 140px"
+        />
+        <span class="filter-label">结束时间</span>
+        <el-date-picker
+          v-model="endDate"
+          type="date"
+          placeholder="选择日期"
+          format="YYYY-MM-DD"
+          value-format="YYYY-MM-DD"
+          style="width: 140px"
+        />
+        <div class="quick-btns">
+          <el-button 
+            v-for="btn in quickDateBtns" 
+            :key="btn.value"
+            :type="activeQuickBtn === btn.value ? 'primary' : 'default'"
+            size="small"
+            @click="handleQuickDate(btn.value)"
+          >
+            {{ btn.label }}
+          </el-button>
+        </div>
+        <el-select v-model="selectedPlatform" placeholder="全部平台" clearable style="width: 120px">
+          <el-option label="全部平台" value="" />
+          <el-option 
+            v-for="platform in availablePlatforms" 
+            :key="platform.value" 
+            :label="platform.label" 
+            :value="platform.value"
+          />
+        </el-select>
+        <el-button type="primary" @click="handleQuery">查询</el-button>
+      </div>
+      <div class="filter-right">
+        <!-- <el-button @click="handleViewReport">查看报表</el-button>
+        <el-button @click="handleExport">导出数据</el-button> -->
+      </div>
+    </div>
+
+    <!-- 汇总统计卡片 -->
+    <div class="summary-cards">
+      <div class="stat-card">
+        <div class="stat-icon">👤</div>
+        <div class="stat-content">
+          <div class="stat-label">账号总数</div>
+          <div class="stat-value">{{ summaryData.totalAccounts || 0 }}</div>
+        </div>
+      </div>
+      <div class="stat-card">
+        <div class="stat-icon">▶️</div>
+        <div class="stat-content">
+          <div class="stat-label">播放(阅读)量</div>
+          <div class="stat-value">{{ formatNumber(summaryData.viewsCount || 0) }}</div>
+        </div>
+      </div>
+      <div class="stat-card">
+        <div class="stat-icon">💬</div>
+        <div class="stat-content">
+          <div class="stat-label">评论量</div>
+          <div class="stat-value">{{ formatNumber(summaryData.commentsCount || 0) }}</div>
+        </div>
+      </div>
+      <div class="stat-card">
+        <div class="stat-icon">👍</div>
+        <div class="stat-content">
+          <div class="stat-label">点赞量</div>
+          <div class="stat-value">{{ formatNumber(summaryData.likesCount || 0) }}</div>
+        </div>
+      </div>
+      <div class="stat-card">
+        <div class="stat-icon">👥</div>
+        <div class="stat-content">
+          <div class="stat-label">涨粉量</div>
+          <div class="stat-value">{{ formatNumber(summaryData.fansIncrease || 0) }}</div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 每日汇总数据表格 -->
+    <div class="data-section">
+      <h3 class="section-title">每日汇总数据</h3>
+      <el-table :data="dailyData" v-loading="loading" stripe>
+        <el-table-column prop="date" label="时间" width="120" align="center" />
+        <el-table-column prop="viewsCount" label="播放(阅读)量" width="140" align="center">
+          <template #default="{ row }">{{ formatNumber(row.viewsCount || 0) }}</template>
+        </el-table-column>
+        <el-table-column prop="commentsCount" label="评论量" width="100" align="center">
+          <template #default="{ row }">{{ formatNumber(row.commentsCount || 0) }}</template>
+        </el-table-column>
+        <el-table-column prop="likesCount" label="点赞量" width="100" align="center">
+          <template #default="{ row }">{{ formatNumber(row.likesCount || 0) }}</template>
+        </el-table-column>
+        <el-table-column prop="fansIncrease" label="涨粉量" width="100" align="center">
+          <template #default="{ row }">{{ formatNumber(row.fansIncrease || 0) }}</template>
+        </el-table-column>
+      </el-table>
+    </div>
+
+    <!-- 账号详细数据表格 -->
+    <div class="data-section">
+      <h3 class="section-title">账号详细数据</h3>
+      <el-table :data="accountList" v-loading="loading" stripe>
+        <el-table-column label="账号" min-width="180">
+          <template #default="{ row }">
+            <div class="account-cell">
+              <el-avatar :size="32" :src="row.avatarUrl">
+                {{ row.nickname?.[0] || '?' }}
+              </el-avatar>
+              <span class="account-name">{{ row.nickname || row.username }}</span>
+            </div>
+          </template>
+        </el-table-column>
+        <el-table-column prop="platform" label="平台" width="100" align="center">
+          <template #default="{ row }">
+            <span>{{ getPlatformName(row.platform) }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="viewsCount" label="播放(阅读)量" width="140" align="center">
+          <template #default="{ row }">
+            <span :class="{ 'error-text': row.viewsCount === null }">
+              {{ row.viewsCount === null ? '获取失败' : formatNumber(row.viewsCount) }}
+            </span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="commentsCount" label="评论量" width="100" align="center">
+          <template #default="{ row }">{{ formatNumber(row.commentsCount || 0) }}</template>
+        </el-table-column>
+        <el-table-column prop="likesCount" label="点赞量" width="100" align="center">
+          <template #default="{ row }">{{ formatNumber(row.likesCount || 0) }}</template>
+        </el-table-column>
+        <el-table-column prop="fansIncrease" label="涨粉量" width="100" align="center">
+          <template #default="{ row }">{{ formatNumber(row.fansIncrease || 0) }}</template>
+        </el-table-column>
+        <el-table-column prop="updateTime" label="更新时间" width="140" align="center">
+          <template #default="{ row }">
+            <span class="update-time">{{ row.updateTime || '-' }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="100" align="center" fixed="right">
+          <template #default="{ row }">
+            <el-button type="primary" link @click="handleAccountDetail(row)">
+              详情
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+// 立即输出日志,确认组件脚本执行(在任何导入之前)
+console.log('[PlatformDetail] ========== 组件脚本开始执行 ==========');
+
+import { ref, computed, onMounted, nextTick, watch } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import { PLATFORMS } from '@media-manager/shared';
+import type { PlatformType } from '@media-manager/shared';
+import { ElMessage } from 'element-plus';
+import dayjs from 'dayjs';
+import request from '@/api/request';
+
+const route = useRoute();
+const router = useRouter();
+
+// 立即输出日志,确认组件脚本执行
+console.log('[PlatformDetail] 组件脚本执行中, route:', route.path, route.params, route.query);
+console.log('[PlatformDetail] router:', router);
+
+const loading = ref(false);
+const startDate = ref(dayjs().subtract(1, 'day').format('YYYY-MM-DD'));
+const endDate = ref(dayjs().subtract(1, 'day').format('YYYY-MM-DD'));
+const activeQuickBtn = ref('yesterday');
+const selectedPlatform = ref<string>('');
+
+// 从路由参数获取平台
+const platformFromRoute = computed(() => {
+  const platform = route.params.platform as string;
+  console.log('[PlatformDetail] platformFromRoute computed:', platform, 'route.params:', route.params);
+  return platform;
+});
+
+// 快捷日期按钮
+const quickDateBtns = [
+  { label: '昨天', value: 'yesterday' },
+  { label: '前天', value: 'beforeYesterday' },
+  { label: '近三天', value: 'last3days' },
+  { label: '近七天', value: 'last7days' },
+  { label: '近一个月', value: 'lastMonth' },
+];
+
+// 可用平台
+const availablePlatforms = computed(() => {
+  return Object.entries(PLATFORMS).map(([key, value]) => ({
+    value: key,
+    label: value.name,
+  }));
+});
+
+// 汇总数据
+const summaryData = ref({
+  totalAccounts: 0,
+  viewsCount: 0,
+  commentsCount: 0,
+  likesCount: 0,
+  fansIncrease: 0,
+});
+
+// 每日汇总数据
+const dailyData = ref<Array<{
+  date: string;
+  viewsCount: number;
+  commentsCount: number;
+  likesCount: number;
+  fansIncrease: number;
+}>>([]);
+
+// 账号列表
+const accountList = ref<Array<{
+  id: number;
+  nickname: string;
+  username: string;
+  avatarUrl: string | null;
+  platform: string;
+  viewsCount: number | null;
+  commentsCount: number;
+  likesCount: number;
+  fansIncrease: number;
+  updateTime: string;
+}>>([]);
+
+function getPlatformName(platform: string) {
+  return PLATFORMS[platform as PlatformType]?.name || platform;
+}
+
+function formatNumber(num: number) {
+  if (num >= 10000) return (num / 10000).toFixed(1) + 'w';
+  return num.toString();
+}
+
+// 快捷日期选择
+function handleQuickDate(type: string) {
+  activeQuickBtn.value = type;
+  const today = dayjs();
+  
+  switch (type) {
+    case 'yesterday':
+      startDate.value = today.subtract(1, 'day').format('YYYY-MM-DD');
+      endDate.value = today.subtract(1, 'day').format('YYYY-MM-DD');
+      break;
+    case 'beforeYesterday':
+      startDate.value = today.subtract(2, 'day').format('YYYY-MM-DD');
+      endDate.value = today.subtract(2, 'day').format('YYYY-MM-DD');
+      break;
+    case 'last3days':
+      startDate.value = today.subtract(2, 'day').format('YYYY-MM-DD');
+      endDate.value = today.format('YYYY-MM-DD');
+      break;
+    case 'last7days':
+      startDate.value = today.subtract(6, 'day').format('YYYY-MM-DD');
+      endDate.value = today.format('YYYY-MM-DD');
+      break;
+    case 'lastMonth':
+      startDate.value = today.subtract(29, 'day').format('YYYY-MM-DD');
+      endDate.value = today.format('YYYY-MM-DD');
+      break;
+  }
+}
+
+// 查询
+function handleQuery() {
+  loadData();
+}
+
+// 加载数据
+async function loadData() {
+  // 优先使用 selectedPlatform,如果没有则从路由参数获取
+  const platform = selectedPlatform.value || (route.params.platform as string) || (route.query.platform as string);
+  
+  console.log('[PlatformDetail] loadData 开始, platform:', platform, 'selectedPlatform.value:', selectedPlatform.value, 'route.params:', route.params);
+  
+  if (!platform) {
+    console.error('[PlatformDetail] loadData: 缺少平台参数');
+    ElMessage.error('请选择平台');
+    return;
+  }
+
+  loading.value = true;
+  
+  try {
+    console.log('[PlatformDetail] 开始调用接口:', { 
+      platform, 
+      startDate: startDate.value, 
+      endDate: endDate.value,
+      url: '/api/work-day-statistics/platform-detail'
+    });
+
+    const data = await request.get('/api/work-day-statistics/platform-detail', {
+      params: {
+        platform,
+        startDate: startDate.value,
+        endDate: endDate.value,
+      },
+    });
+
+    console.log('[PlatformDetail] 接口响应:', data);
+
+    // request 拦截器已经解包了响应,直接使用返回的数据
+    if (data && typeof data === 'object') {
+      console.log('[PlatformDetail] 数据解析成功:', data);
+      
+      // 更新汇总数据(使用 ?? 避免 0 值被误判)
+      if (data.summary) {
+        summaryData.value = {
+          totalAccounts: data.summary.totalAccounts ?? 0,
+          viewsCount: data.summary.viewsCount ?? 0,
+          commentsCount: data.summary.commentsCount ?? 0,
+          likesCount: data.summary.likesCount ?? 0,
+          fansIncrease: data.summary.fansIncrease ?? 0,
+        };
+      }
+      
+      // 更新每日数据
+      dailyData.value = Array.isArray(data.dailyData) ? data.dailyData : [];
+      
+      // 更新账号列表
+      accountList.value = Array.isArray(data.accounts) ? data.accounts : [];
+      
+      console.log('[PlatformDetail] 数据已更新到界面:', {
+        summary: summaryData.value,
+        dailyDataCount: dailyData.value.length,
+        accountCount: accountList.value.length,
+      });
+    } else {
+      console.error('[PlatformDetail] 响应格式错误:', data);
+      ElMessage.error('数据格式错误');
+    }
+  } catch (error: any) {
+    console.error('[PlatformDetail] 加载平台详情失败:', error);
+    console.error('[PlatformDetail] 错误详情:', {
+      message: error?.message,
+      response: error?.response,
+      stack: error?.stack,
+    });
+    ElMessage.error(error?.response?.data?.message || error?.message || '加载数据失败');
+  } finally {
+    loading.value = false;
+  }
+}
+
+// 查看报表
+function handleViewReport() {
+  ElMessage.info('查看报表功能开发中');
+}
+
+// 导出数据
+function handleExport() {
+  ElMessage.info('导出数据功能开发中');
+}
+
+// 账号详情
+function handleAccountDetail(row: any) {
+  router.push({
+    name: 'AnalyticsAccount',
+    query: {
+      accountId: row.id,
+      startDate: startDate.value,
+      endDate: endDate.value,
+    },
+  });
+}
+
+// 初始化数据加载
+function initDataLoad() {
+  console.log('[PlatformDetail] initDataLoad, route:', route.path, route.params, route.query);
+  
+  // 直接从 route.params 获取平台参数(不依赖 computed)
+  const platformParam = (route.params.platform as string) || (route.query.platform as string);
+  console.log('[PlatformDetail] initDataLoad, platformParam:', platformParam);
+  
+  if (platformParam) {
+    selectedPlatform.value = platformParam;
+    // 如果 query 中有日期参数,使用它们
+    if (route.query.startDate) {
+      startDate.value = route.query.startDate as string;
+    }
+    if (route.query.endDate) {
+      endDate.value = route.query.endDate as string;
+    }
+    
+    console.log('[PlatformDetail] 准备加载数据, selectedPlatform:', selectedPlatform.value, 'startDate:', startDate.value, 'endDate:', endDate.value);
+    
+    // 使用 nextTick 确保响应式更新完成
+    nextTick(() => {
+      console.log('[PlatformDetail] nextTick 后, selectedPlatform:', selectedPlatform.value);
+      loadData();
+    });
+  } else {
+    console.error('[PlatformDetail] 缺少平台参数, route.params:', route.params, 'route.query:', route.query);
+    ElMessage.warning('缺少平台参数,请从平台列表页进入');
+  }
+}
+
+// 监听路由变化
+watch(() => route.params.platform, (newPlatform) => {
+  console.log('[PlatformDetail] 路由参数变化, newPlatform:', newPlatform);
+  if (newPlatform) {
+    selectedPlatform.value = newPlatform as string;
+    nextTick(() => {
+      loadData();
+    });
+  }
+}, { immediate: false });
+
+onMounted(() => {
+  console.log('[PlatformDetail] ========== onMounted 执行 ==========');
+  console.log('[PlatformDetail] route.path:', route.path);
+  console.log('[PlatformDetail] route.params:', route.params);
+  console.log('[PlatformDetail] route.query:', route.query);
+  console.log('[PlatformDetail] route.name:', route.name);
+  initDataLoad();
+});
+</script>
+
+<style lang="scss" scoped>
+@use '@/styles/variables.scss' as *;
+
+.platform-detail-page {
+  padding: 20px;
+  background: #f5f5f5;
+  min-height: 100vh;
+
+  .filter-bar {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    margin-bottom: 20px;
+    padding: 16px 20px;
+    background: #fff;
+    border-radius: $radius-lg;
+    box-shadow: $shadow-sm;
+    
+    .filter-left {
+      display: flex;
+      align-items: center;
+      gap: 12px;
+      
+      .filter-label {
+        font-size: 14px;
+        color: $text-regular;
+      }
+      
+      .quick-btns {
+        display: flex;
+        gap: 8px;
+        margin-left: 8px;
+      }
+    }
+  }
+
+  .summary-cards {
+    display: grid;
+    grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+    gap: 16px;
+    margin-bottom: 20px;
+
+    .stat-card {
+      display: flex;
+      align-items: center;
+      gap: 16px;
+      padding: 20px;
+      background: #fff;
+      border-radius: $radius-lg;
+      box-shadow: $shadow-sm;
+
+      .stat-icon {
+        font-size: 32px;
+        width: 48px;
+        height: 48px;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        background: #f0f0f0;
+        border-radius: 8px;
+      }
+
+      .stat-content {
+        flex: 1;
+
+        .stat-label {
+          font-size: 13px;
+          color: $text-secondary;
+          margin-bottom: 4px;
+        }
+
+        .stat-value {
+          font-size: 24px;
+          font-weight: 600;
+          color: $text-primary;
+        }
+      }
+    }
+  }
+
+  .data-section {
+    background: #fff;
+    border-radius: $radius-lg;
+    box-shadow: $shadow-sm;
+    padding: 20px;
+    margin-bottom: 20px;
+
+    .section-title {
+      margin: 0 0 16px 0;
+      font-size: 16px;
+      font-weight: 600;
+      color: $text-primary;
+    }
+
+    .account-cell {
+      display: flex;
+      align-items: center;
+      gap: 12px;
+
+      .account-name {
+        font-weight: 500;
+        color: $text-primary;
+      }
+    }
+
+    .error-text {
+      color: #ef4444;
+    }
+
+    .update-time {
+      font-size: 12px;
+      color: $text-secondary;
+    }
+  }
+}
+</style>

+ 15 - 0
database/migrations/add_fields_to_user_day_statistics.sql

@@ -0,0 +1,15 @@
+-- 为 user_day_statistics 表添加新字段的迁移脚本
+-- 执行日期: 2026-01-28
+
+USE media_manager;
+
+-- 添加新字段
+ALTER TABLE user_day_statistics
+ADD COLUMN play_count INT DEFAULT 0 COMMENT '播放数' AFTER works_count,
+ADD COLUMN comment_count INT DEFAULT 0 COMMENT '评论数' AFTER play_count,
+ADD COLUMN fans_increase INT DEFAULT 0 COMMENT '涨粉数' AFTER comment_count,
+ADD COLUMN like_count INT DEFAULT 0 COMMENT '点赞数' AFTER fans_increase,
+ADD COLUMN cover_click_rate DECIMAL(10,4) DEFAULT 0 COMMENT '封面点击率' AFTER like_count,
+ADD COLUMN avg_watch_duration DECIMAL(10,2) DEFAULT 0 COMMENT '平均观看时长(秒)' AFTER cover_click_rate,
+ADD COLUMN total_watch_duration BIGINT DEFAULT 0 COMMENT '观看总时长(秒)' AFTER avg_watch_duration,
+ADD COLUMN completion_rate DECIMAL(10,4) DEFAULT 0 COMMENT '视频完播率' AFTER total_watch_duration;

+ 143 - 0
database/migrations/add_or_update_user_day_statistics_fields.sql

@@ -0,0 +1,143 @@
+-- 为 user_day_statistics 表添加或更新字段
+-- 执行日期: 2026-01-28
+-- 说明:如果字段不存在则添加,如果存在则更新类型
+
+USE media_manager;
+
+-- 检查并添加 play_count 字段(如果不存在)
+SET @dbname = DATABASE();
+SET @tablename = 'user_day_statistics';
+SET @columnname = 'play_count';
+SET @preparedStatement = (SELECT IF(
+  (
+    SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+    WHERE
+      (TABLE_SCHEMA = @dbname)
+      AND (TABLE_NAME = @tablename)
+      AND (COLUMN_NAME = @columnname)
+  ) > 0,
+  'SELECT 1',
+  CONCAT('ALTER TABLE ', @tablename, ' ADD COLUMN ', @columnname, ' INT DEFAULT 0 COMMENT ''播放数'' AFTER works_count')
+));
+PREPARE alterIfNotExists FROM @preparedStatement;
+EXECUTE alterIfNotExists;
+DEALLOCATE PREPARE alterIfNotExists;
+
+-- 检查并添加 comment_count 字段(如果不存在)
+SET @columnname = 'comment_count';
+SET @preparedStatement = (SELECT IF(
+  (
+    SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+    WHERE
+      (TABLE_SCHEMA = @dbname)
+      AND (TABLE_NAME = @tablename)
+      AND (COLUMN_NAME = @columnname)
+  ) > 0,
+  'SELECT 1',
+  CONCAT('ALTER TABLE ', @tablename, ' ADD COLUMN ', @columnname, ' INT DEFAULT 0 COMMENT ''评论数'' AFTER play_count')
+));
+PREPARE alterIfNotExists FROM @preparedStatement;
+EXECUTE alterIfNotExists;
+DEALLOCATE PREPARE alterIfNotExists;
+
+-- 检查并添加 fans_increase 字段(如果不存在)
+SET @columnname = 'fans_increase';
+SET @preparedStatement = (SELECT IF(
+  (
+    SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+    WHERE
+      (TABLE_SCHEMA = @dbname)
+      AND (TABLE_NAME = @tablename)
+      AND (COLUMN_NAME = @columnname)
+  ) > 0,
+  'SELECT 1',
+  CONCAT('ALTER TABLE ', @tablename, ' ADD COLUMN ', @columnname, ' INT DEFAULT 0 COMMENT ''涨粉数'' AFTER comment_count')
+));
+PREPARE alterIfNotExists FROM @preparedStatement;
+EXECUTE alterIfNotExists;
+DEALLOCATE PREPARE alterIfNotExists;
+
+-- 检查并添加 like_count 字段(如果不存在)
+SET @columnname = 'like_count';
+SET @preparedStatement = (SELECT IF(
+  (
+    SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+    WHERE
+      (TABLE_SCHEMA = @dbname)
+      AND (TABLE_NAME = @tablename)
+      AND (COLUMN_NAME = @columnname)
+  ) > 0,
+  'SELECT 1',
+  CONCAT('ALTER TABLE ', @tablename, ' ADD COLUMN ', @columnname, ' INT DEFAULT 0 COMMENT ''点赞数'' AFTER fans_increase')
+));
+PREPARE alterIfNotExists FROM @preparedStatement;
+EXECUTE alterIfNotExists;
+DEALLOCATE PREPARE alterIfNotExists;
+
+-- 检查并添加 cover_click_rate 字段(如果不存在则添加,如果存在则修改类型)
+SET @columnname = 'cover_click_rate';
+SET @preparedStatement = (SELECT IF(
+  (
+    SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+    WHERE
+      (TABLE_SCHEMA = @dbname)
+      AND (TABLE_NAME = @tablename)
+      AND (COLUMN_NAME = @columnname)
+  ) > 0,
+  CONCAT('ALTER TABLE ', @tablename, ' MODIFY COLUMN ', @columnname, ' VARCHAR(50) DEFAULT ''0'' COMMENT ''封面点击率'''),
+  CONCAT('ALTER TABLE ', @tablename, ' ADD COLUMN ', @columnname, ' VARCHAR(50) DEFAULT ''0'' COMMENT ''封面点击率'' AFTER like_count')
+));
+PREPARE alterIfNotExists FROM @preparedStatement;
+EXECUTE alterIfNotExists;
+DEALLOCATE PREPARE alterIfNotExists;
+
+-- 检查并添加 avg_watch_duration 字段(如果不存在则添加,如果存在则修改类型)
+SET @columnname = 'avg_watch_duration';
+SET @preparedStatement = (SELECT IF(
+  (
+    SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+    WHERE
+      (TABLE_SCHEMA = @dbname)
+      AND (TABLE_NAME = @tablename)
+      AND (COLUMN_NAME = @columnname)
+  ) > 0,
+  CONCAT('ALTER TABLE ', @tablename, ' MODIFY COLUMN ', @columnname, ' VARCHAR(50) DEFAULT ''0'' COMMENT ''平均观看时长(秒)'''),
+  CONCAT('ALTER TABLE ', @tablename, ' ADD COLUMN ', @columnname, ' VARCHAR(50) DEFAULT ''0'' COMMENT ''平均观看时长(秒)'' AFTER cover_click_rate')
+));
+PREPARE alterIfNotExists FROM @preparedStatement;
+EXECUTE alterIfNotExists;
+DEALLOCATE PREPARE alterIfNotExists;
+
+-- 检查并添加 total_watch_duration 字段(如果不存在则添加,如果存在则修改类型)
+SET @columnname = 'total_watch_duration';
+SET @preparedStatement = (SELECT IF(
+  (
+    SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+    WHERE
+      (TABLE_SCHEMA = @dbname)
+      AND (TABLE_NAME = @tablename)
+      AND (COLUMN_NAME = @columnname)
+  ) > 0,
+  CONCAT('ALTER TABLE ', @tablename, ' MODIFY COLUMN ', @columnname, ' VARCHAR(50) DEFAULT ''0'' COMMENT ''观看总时长(秒)'''),
+  CONCAT('ALTER TABLE ', @tablename, ' ADD COLUMN ', @columnname, ' VARCHAR(50) DEFAULT ''0'' COMMENT ''观看总时长(秒)'' AFTER avg_watch_duration')
+));
+PREPARE alterIfNotExists FROM @preparedStatement;
+EXECUTE alterIfNotExists;
+DEALLOCATE PREPARE alterIfNotExists;
+
+-- 检查并添加 completion_rate 字段(如果不存在则添加,如果存在则修改类型)
+SET @columnname = 'completion_rate';
+SET @preparedStatement = (SELECT IF(
+  (
+    SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+    WHERE
+      (TABLE_SCHEMA = @dbname)
+      AND (TABLE_NAME = @tablename)
+      AND (COLUMN_NAME = @columnname)
+  ) > 0,
+  CONCAT('ALTER TABLE ', @tablename, ' MODIFY COLUMN ', @columnname, ' VARCHAR(50) DEFAULT ''0'' COMMENT ''视频完播率'''),
+  CONCAT('ALTER TABLE ', @tablename, ' ADD COLUMN ', @columnname, ' VARCHAR(50) DEFAULT ''0'' COMMENT ''视频完播率'' AFTER total_watch_duration')
+));
+PREPARE alterIfNotExists FROM @preparedStatement;
+EXECUTE alterIfNotExists;
+DEALLOCATE PREPARE alterIfNotExists;

+ 22 - 0
database/migrations/add_user_day_statistics_fields_complete.sql

@@ -0,0 +1,22 @@
+-- 为 user_day_statistics 表添加所有字段的完整迁移脚本
+-- 执行日期: 2026-01-28
+-- 
+-- 说明:
+-- 1. 如果字段已存在,会报错,需要先检查或手动删除重复的 ADD COLUMN 语句
+-- 2. 四个统计字段(cover_click_rate, avg_watch_duration, total_watch_duration, completion_rate)使用字符串类型
+
+USE media_manager;
+
+-- 添加基础统计字段
+ALTER TABLE user_day_statistics
+ADD COLUMN IF NOT EXISTS play_count INT DEFAULT 0 COMMENT '播放数' AFTER works_count,
+ADD COLUMN IF NOT EXISTS comment_count INT DEFAULT 0 COMMENT '评论数' AFTER play_count,
+ADD COLUMN IF NOT EXISTS fans_increase INT DEFAULT 0 COMMENT '涨粉数' AFTER comment_count,
+ADD COLUMN IF NOT EXISTS like_count INT DEFAULT 0 COMMENT '点赞数' AFTER fans_increase;
+
+-- 添加字符串格式的统计字段
+ALTER TABLE user_day_statistics
+ADD COLUMN IF NOT EXISTS cover_click_rate VARCHAR(50) DEFAULT '0' COMMENT '封面点击率' AFTER like_count,
+ADD COLUMN IF NOT EXISTS avg_watch_duration VARCHAR(50) DEFAULT '0' COMMENT '平均观看时长(秒)' AFTER cover_click_rate,
+ADD COLUMN IF NOT EXISTS total_watch_duration VARCHAR(50) DEFAULT '0' COMMENT '观看总时长(秒)' AFTER avg_watch_duration,
+ADD COLUMN IF NOT EXISTS completion_rate VARCHAR(50) DEFAULT '0' COMMENT '视频完播率' AFTER total_watch_duration;

+ 19 - 0
database/migrations/add_user_day_statistics_fields_execute.sql

@@ -0,0 +1,19 @@
+-- 为 user_day_statistics 表添加字段
+-- 执行日期: 2026-01-28
+-- MySQL 8.0.23 兼容版本
+
+USE media_manager;
+
+-- 添加基础统计字段
+ALTER TABLE user_day_statistics
+ADD COLUMN play_count INT DEFAULT 0 COMMENT '播放数' AFTER works_count,
+ADD COLUMN comment_count INT DEFAULT 0 COMMENT '评论数' AFTER play_count,
+ADD COLUMN fans_increase INT DEFAULT 0 COMMENT '涨粉数' AFTER comment_count,
+ADD COLUMN like_count INT DEFAULT 0 COMMENT '点赞数' AFTER fans_increase;
+
+-- 添加字符串格式的统计字段
+ALTER TABLE user_day_statistics
+ADD COLUMN cover_click_rate VARCHAR(50) DEFAULT '0' COMMENT '封面点击率' AFTER like_count,
+ADD COLUMN avg_watch_duration VARCHAR(50) DEFAULT '0' COMMENT '平均观看时长(秒)' AFTER cover_click_rate,
+ADD COLUMN total_watch_duration VARCHAR(50) DEFAULT '0' COMMENT '观看总时长(秒)' AFTER avg_watch_duration,
+ADD COLUMN completion_rate VARCHAR(50) DEFAULT '0' COMMENT '视频完播率' AFTER total_watch_duration;

+ 32 - 0
database/migrations/add_user_day_statistics_fields_manual.sql

@@ -0,0 +1,32 @@
+-- 为 user_day_statistics 表添加字段(手动检查版本)
+-- 执行日期: 2026-01-28
+-- 
+-- 使用说明:
+-- 1. 如果字段已存在,请注释掉对应的 ADD COLUMN 语句
+-- 2. 执行前请备份数据库
+-- 3. 建议在测试环境先执行
+
+USE media_manager;
+
+-- ============================================
+-- 第一步:添加基础统计字段
+-- ============================================
+-- 如果字段已存在,请注释掉对应的行
+
+ALTER TABLE user_day_statistics
+ADD COLUMN play_count INT DEFAULT 0 COMMENT '播放数' AFTER works_count,
+ADD COLUMN comment_count INT DEFAULT 0 COMMENT '评论数' AFTER play_count,
+ADD COLUMN fans_increase INT DEFAULT 0 COMMENT '涨粉数' AFTER comment_count,
+ADD COLUMN like_count INT DEFAULT 0 COMMENT '点赞数' AFTER fans_increase;
+
+-- ============================================
+-- 第二步:添加字符串格式的统计字段
+-- ============================================
+-- 如果字段已存在,请注释掉对应的行
+-- 如果字段存在但类型不是 VARCHAR,请先执行 change_user_day_statistics_fields_to_string.sql
+
+ALTER TABLE user_day_statistics
+ADD COLUMN cover_click_rate VARCHAR(50) DEFAULT '0' COMMENT '封面点击率' AFTER like_count,
+ADD COLUMN avg_watch_duration VARCHAR(50) DEFAULT '0' COMMENT '平均观看时长(秒)' AFTER cover_click_rate,
+ADD COLUMN total_watch_duration VARCHAR(50) DEFAULT '0' COMMENT '观看总时长(秒)' AFTER avg_watch_duration,
+ADD COLUMN completion_rate VARCHAR(50) DEFAULT '0' COMMENT '视频完播率' AFTER total_watch_duration;

+ 21 - 0
database/migrations/add_user_day_statistics_fields_simple.sql

@@ -0,0 +1,21 @@
+-- 为 user_day_statistics 表添加字段(如果字段已存在会报错,需要先检查)
+-- 执行日期: 2026-01-28
+-- 
+-- 注意:如果字段已存在,请先执行 change_user_day_statistics_fields_to_string.sql
+-- 如果字段不存在,请执行此脚本
+
+USE media_manager;
+
+-- 添加基础统计字段(如果不存在)
+ALTER TABLE user_day_statistics
+ADD COLUMN IF NOT EXISTS play_count INT DEFAULT 0 COMMENT '播放数' AFTER works_count,
+ADD COLUMN IF NOT EXISTS comment_count INT DEFAULT 0 COMMENT '评论数' AFTER play_count,
+ADD COLUMN IF NOT EXISTS fans_increase INT DEFAULT 0 COMMENT '涨粉数' AFTER comment_count,
+ADD COLUMN IF NOT EXISTS like_count INT DEFAULT 0 COMMENT '点赞数' AFTER fans_increase;
+
+-- 添加字符串格式的统计字段(如果不存在)
+ALTER TABLE user_day_statistics
+ADD COLUMN IF NOT EXISTS cover_click_rate VARCHAR(50) DEFAULT '0' COMMENT '封面点击率' AFTER like_count,
+ADD COLUMN IF NOT EXISTS avg_watch_duration VARCHAR(50) DEFAULT '0' COMMENT '平均观看时长(秒)' AFTER cover_click_rate,
+ADD COLUMN IF NOT EXISTS total_watch_duration VARCHAR(50) DEFAULT '0' COMMENT '观看总时长(秒)' AFTER avg_watch_duration,
+ADD COLUMN IF NOT EXISTS completion_rate VARCHAR(50) DEFAULT '0' COMMENT '视频完播率' AFTER total_watch_duration;

+ 11 - 0
database/migrations/change_user_day_statistics_fields_to_string.sql

@@ -0,0 +1,11 @@
+-- 将 user_day_statistics 表的四个字段改为字符串格式
+-- 执行日期: 2026-01-28
+
+USE media_manager;
+
+-- 修改字段类型为 VARCHAR
+ALTER TABLE user_day_statistics
+MODIFY COLUMN cover_click_rate VARCHAR(50) DEFAULT '0' COMMENT '封面点击率',
+MODIFY COLUMN avg_watch_duration VARCHAR(50) DEFAULT '0' COMMENT '平均观看时长(秒)',
+MODIFY COLUMN total_watch_duration VARCHAR(50) DEFAULT '0' COMMENT '观看总时长(秒)',
+MODIFY COLUMN completion_rate VARCHAR(50) DEFAULT '0' COMMENT '视频完播率';

+ 8 - 0
database/schema.sql

@@ -208,6 +208,14 @@ CREATE TABLE IF NOT EXISTS user_day_statistics (
     record_date DATE NOT NULL COMMENT '记录日期',
     fans_count INT DEFAULT 0 COMMENT '粉丝数',
     works_count INT DEFAULT 0 COMMENT '作品数',
+    play_count INT DEFAULT 0 COMMENT '播放数',
+    comment_count INT DEFAULT 0 COMMENT '评论数',
+    fans_increase INT DEFAULT 0 COMMENT '涨粉数',
+    like_count INT DEFAULT 0 COMMENT '点赞数',
+    cover_click_rate VARCHAR(50) DEFAULT '0' COMMENT '封面点击率',
+    avg_watch_duration VARCHAR(50) DEFAULT '0' COMMENT '平均观看时长(秒)',
+    total_watch_duration VARCHAR(50) DEFAULT '0' COMMENT '观看总时长(秒)',
+    completion_rate VARCHAR(50) DEFAULT '0' COMMENT '视频完播率',
     created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
     updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     UNIQUE KEY uk_account_date (account_id, record_date),

+ 424 - 0
docs/platform-detail-data-calculation-logic.md

@@ -0,0 +1,424 @@
+# 平台数据详情页数据计算逻辑详解
+
+## 概述
+
+本文档详细说明平台数据详情页(`/api/work-day-statistics/platform-detail`)中各项数据的计算逻辑,帮助理解为什么某些数据可能显示为 0 或 null。
+
+## 数据计算流程
+
+### 1. 接口入口
+
+**文件**: `server/src/routes/workDayStatistics.ts`
+
+**路由**: `GET /api/work-day-statistics/platform-detail`
+
+**参数**:
+- `platform`: 平台类型(必填)
+- `startDate`: 开始日期(必填,格式:YYYY-MM-DD)
+- `endDate`: 结束日期(必填,格式:YYYY-MM-DD)
+
+### 2. 服务方法
+
+**文件**: `server/src/services/WorkDayStatisticsService.ts`
+
+**方法**: `getPlatformDetail(userId, platform, { startDate, endDate })`
+
+## 详细计算逻辑
+
+### 2.1 获取账号列表
+
+```typescript
+// 获取该平台的所有账号
+const accounts = await this.accountRepository.find({
+  where: {
+    userId,
+    platform: platform as any,
+  },
+  relations: ['group'],
+});
+```
+
+### 2.2 遍历每个账号计算数据
+
+对每个账号执行以下步骤:
+
+#### 步骤 1: 获取账号的所有作品
+
+```typescript
+const works = await this.workRepository.find({
+  where: { accountId: account.id },
+  select: ['id'],
+});
+const workIds = works.map(w => w.id);
+```
+
+**注意**: 如果账号没有作品,`workIds` 为空数组,后续所有统计数据都会是 0。
+
+#### 步骤 2: 计算账号在时间范围内的增量数据
+
+**核心方法**: `getWorkSumsAtDate(workIds, targetDate)`
+
+**作用**: 获取指定日期(<= targetDate)时,该账号所有作品的累计数据总和
+
+**SQL 逻辑**:
+```sql
+SELECT
+  COALESCE(SUM(wds.play_count), 0) AS views,
+  COALESCE(SUM(wds.like_count), 0) AS likes,
+  COALESCE(SUM(wds.comment_count), 0) AS comments,
+  COALESCE(SUM(wds.collect_count), 0) AS collects
+FROM work_day_statistics wds
+INNER JOIN (
+  SELECT wds2.work_id, MAX(wds2.record_date) AS record_date
+  FROM work_day_statistics wds2
+  WHERE wds2.work_id IN (...)
+    AND wds2.record_date <= ?
+  GROUP BY wds2.work_id
+) latest
+ON latest.work_id = wds.work_id AND latest.record_date = wds.record_date
+```
+
+**计算增量**:
+```typescript
+// 获取结束日期和开始日期的累计数据
+const [endSums, startSums] = await Promise.all([
+  this.getWorkSumsAtDate(workIds, endDateStr),
+  this.getWorkSumsAtDate(workIds, startDateStr),
+]);
+
+// 计算增量(结束日期累计值 - 开始日期累计值)
+const accountViews = endSums.views - startSums.views;
+const accountComments = endSums.comments - startSums.comments;
+const accountLikes = endSums.likes - startSums.likes;
+```
+
+**关键点**:
+- 如果开始日期和结束日期相同,增量 = 当日累计值 - 前一日累计值
+- 如果账号没有作品,`workIds` 为空,`getWorkSumsAtDate` 返回 `{ views: 0, likes: 0, comments: 0, collects: 0 }`
+- 如果账号没有统计数据,`getWorkSumsAtDate` 也会返回 0
+
+#### 步骤 3: 计算粉丝增量
+
+```typescript
+// 获取结束日期的粉丝数(<= endDate 的最近一条记录)
+const endUserStat = await this.userDayStatisticsRepository
+  .createQueryBuilder('uds')
+  .where('uds.account_id = :accountId', { accountId: account.id })
+  .andWhere('DATE(uds.record_date) <= :d', { d: endDateStr })
+  .orderBy('uds.record_date', 'DESC')
+  .getOne();
+
+// 获取开始日期的粉丝数(<= startDate 的最近一条记录)
+const startUserStat = await this.userDayStatisticsRepository
+  .createQueryBuilder('uds')
+  .where('uds.account_id = :accountId', { accountId: account.id })
+  .andWhere('DATE(uds.record_date) <= :d', { d: startDateStr })
+  .orderBy('uds.record_date', 'DESC')
+  .getOne();
+
+// 计算粉丝增量
+const accountFansIncrease = (endUserStat?.fansCount || 0) - (startUserStat?.fansCount || 0);
+```
+
+**关键点**:
+- 如果开始日期和结束日期相同,增量 = 当日粉丝数 - 前一日粉丝数
+- 如果没有统计数据,`endUserStat` 或 `startUserStat` 为 `null`,使用 `|| 0` 默认值
+- 如果开始日期和结束日期都没有数据,增量 = 0 - 0 = 0
+
+#### 步骤 4: 获取更新时间
+
+```typescript
+// 获取时间范围内的最新更新时间
+const latestUserStat = await this.userDayStatisticsRepository
+  .createQueryBuilder('uds')
+  .where('uds.account_id = :accountId', { accountId: account.id })
+  .andWhere('DATE(uds.record_date) >= :startDate', { startDate: startDateStr })
+  .andWhere('DATE(uds.record_date) <= :endDate', { endDate: endDateStr })
+  .orderBy('uds.updated_at', 'DESC')
+  .getOne();
+
+const updateTime = latestUserStat?.updatedAt 
+  ? this.formatUpdateTime(latestUserStat.updatedAt)
+  : '';
+```
+
+**关键点**:
+- 只有在时间范围内有 `user_day_statistics` 记录时才有值
+- 如果没有记录,返回空字符串 `""`
+
+#### 步骤 5: 组装账号数据
+
+```typescript
+accountList.push({
+  id: account.id,
+  nickname: account.accountName || '',
+  username: account.accountId || '',
+  avatarUrl: account.avatarUrl,
+  platform: account.platform,
+  income: null, // 收益数据需要从其他表获取
+  recommendationCount: null, // 推荐量(部分平台支持)
+  viewsCount: accountViews > 0 ? accountViews : null,  // ⚠️ 关键:只有 > 0 才返回,否则为 null
+  commentsCount: Math.max(0, accountComments),        // ⚠️ 关键:负数会被截断为 0
+  likesCount: Math.max(0, accountLikes),              // ⚠️ 关键:负数会被截断为 0
+  fansIncrease: Math.max(0, accountFansIncrease),     // ⚠️ 关键:负数会被截断为 0
+  updateTime,
+});
+```
+
+**关键逻辑**:
+
+1. **viewsCount**:
+   - 如果 `accountViews > 0`,返回实际值
+   - 如果 `accountViews <= 0`,返回 `null`
+   - **这意味着如果增量为 0 或负数,前端会显示为 null**
+
+2. **commentsCount / likesCount / fansIncrease**:
+   - 使用 `Math.max(0, value)` 确保不为负数
+   - 如果计算结果为负数,会被截断为 0
+   - **这意味着如果数据减少(比如掉粉),会显示为 0 而不是负数**
+
+### 2.3 计算每日汇总数据
+
+```typescript
+// 遍历日期范围内的每一天
+while (currentDate <= dateEnd) {
+  const dateStr = this.formatDate(currentDate);
+  
+  // 获取该日期的累计数据
+  const [daySums, prevDaySums] = await Promise.all([
+    this.getWorkSumsAtDate(workIds, dateStr),
+    this.getWorkSumsAtDate(workIds, this.formatDate(new Date(currentDate.getTime() - 24 * 60 * 60 * 1000))),
+  ]);
+
+  // 计算当日增量
+  const dayViews = daySums.views - prevDaySums.views;
+  const dayComments = daySums.comments - prevDaySums.comments;
+  const dayLikes = daySums.likes - prevDaySums.likes;
+
+  // 获取粉丝增量
+  const dayUserStat = await this.userDayStatisticsRepository
+    .createQueryBuilder('uds')
+    .where('uds.account_id = :accountId', { accountId: account.id })
+    .andWhere('DATE(uds.record_date) = :d', { d: dateStr })
+    .getOne();
+
+  const prevDayUserStat = await this.userDayStatisticsRepository
+    .createQueryBuilder('uds')
+    .where('uds.account_id = :accountId', { accountId: account.id })
+    .andWhere('DATE(uds.record_date) = :d', { d: this.formatDate(new Date(currentDate.getTime() - 24 * 60 * 60 * 1000)) })
+    .getOne();
+
+  const dayFans = (dayUserStat?.fansCount || 0) - (prevDayUserStat?.fansCount || 0);
+
+  // 累加到每日汇总
+  if (!dailyMap.has(dateStr)) {
+    dailyMap.set(dateStr, {
+      views: 0,
+      comments: 0,
+      likes: 0,
+      fansIncrease: 0,
+    });
+  }
+
+  const daily = dailyMap.get(dateStr)!;
+  daily.views += Math.max(0, dayViews);
+  daily.comments += Math.max(0, dayComments);
+  daily.likes += Math.max(0, dayLikes);
+  daily.fansIncrease += Math.max(0, dayFans);
+
+  currentDate.setDate(currentDate.getDate() + 1);
+}
+```
+
+**关键点**:
+- 每日增量 = 当日累计值 - 前一日累计值
+- 如果前一日没有数据,前一日累计值 = 0
+- 负数会被 `Math.max(0, ...)` 截断为 0
+
+### 2.4 计算汇总统计
+
+```typescript
+// 累加所有账号的数据
+totalAccounts++;
+totalViews += Math.max(0, accountViews);
+totalComments += Math.max(0, accountComments);
+totalLikes += Math.max(0, accountLikes);
+totalFansIncrease += Math.max(0, accountFansIncrease);
+```
+
+## 数据为 0 或 null 的原因分析
+
+### 情况 1: viewsCount 为 null
+
+**原因**:
+- `accountViews <= 0` 时返回 `null`
+- 可能的情况:
+  1. 账号没有作品(`workIds` 为空)
+  2. 账号有作品但没有统计数据
+  3. 开始日期和结束日期的累计播放量相同(增量为 0)
+  4. 播放量减少(增量为负数)
+
+**解决方案**:
+- 检查账号是否有作品
+- 检查 `work_day_statistics` 表中是否有该账号作品的数据
+- 检查时间范围是否合理
+
+### 情况 2: commentsCount / likesCount / fansIncrease 为 0
+
+**原因**:
+- 计算结果为 0 或负数时,使用 `Math.max(0, value)` 截断为 0
+- 可能的情况:
+  1. 账号没有作品或统计数据
+  2. 开始日期和结束日期的累计值相同(增量为 0)
+  3. 数据减少(增量为负数,被截断为 0)
+
+**解决方案**:
+- 检查账号是否有作品
+- 检查统计数据是否存在
+- 检查时间范围是否合理
+- 如果需要显示负数(如掉粉),需要修改逻辑
+
+### 情况 3: updateTime 为空字符串
+
+**原因**:
+- 时间范围内没有 `user_day_statistics` 记录
+
+**解决方案**:
+- 检查账号是否有统计数据
+- 检查时间范围是否合理
+- 可能需要触发数据同步
+
+### 情况 4: income / recommendationCount 为 null
+
+**原因**:
+- 这些字段目前未实现,固定返回 `null`
+
+**解决方案**:
+- 需要实现收益和推荐量的数据获取逻辑
+
+## 数据依赖关系
+
+### 数据来源表
+
+1. **作品统计数据**: `work_day_statistics` 表
+   - 字段: `work_id`, `record_date`, `play_count`, `like_count`, `comment_count`, `collect_count`
+   - 用途: 计算播放量、点赞量、评论量增量
+
+2. **账号统计数据**: `user_day_statistics` 表
+   - 字段: `account_id`, `record_date`, `fans_count`, `updated_at`
+   - 用途: 计算粉丝增量、获取更新时间
+
+3. **账号表**: `platform_accounts` 表
+   - 字段: `id`, `account_name`, `account_id`, `avatar_url`, `platform`
+   - 用途: 获取账号基本信息
+
+4. **作品表**: `works` 表
+   - 字段: `id`, `account_id`
+   - 用途: 获取账号下的所有作品ID
+
+### 数据计算依赖链
+
+```
+账号 (platform_accounts)
+  ↓
+作品 (works) → 作品ID列表 (workIds)
+  ↓
+作品统计数据 (work_day_statistics) → 累计数据 (getWorkSumsAtDate)
+  ↓
+增量计算 (endSums - startSums) → 账号数据 (viewsCount, commentsCount, likesCount)
+  ↓
+账号统计数据 (user_day_statistics) → 粉丝增量 (fansIncrease)
+```
+
+## 调试建议
+
+### 1. 检查账号是否有作品
+
+```sql
+SELECT COUNT(*) FROM works WHERE account_id = ?;
+```
+
+### 2. 检查作品统计数据
+
+```sql
+SELECT COUNT(*) FROM work_day_statistics wds
+INNER JOIN works w ON wds.work_id = w.id
+WHERE w.account_id = ?;
+```
+
+### 3. 检查账号统计数据
+
+```sql
+SELECT COUNT(*) FROM user_day_statistics 
+WHERE account_id = ?;
+```
+
+### 4. 检查特定日期的数据
+
+```sql
+-- 检查作品统计数据
+SELECT * FROM work_day_statistics wds
+INNER JOIN works w ON wds.work_id = w.id
+WHERE w.account_id = ? 
+  AND DATE(wds.record_date) = '2026-01-27';
+
+-- 检查账号统计数据
+SELECT * FROM user_day_statistics 
+WHERE account_id = ? 
+  AND DATE(record_date) = '2026-01-27';
+```
+
+## 修复建议
+
+### 1. 改进 viewsCount 的逻辑
+
+**当前逻辑**:
+```typescript
+viewsCount: accountViews > 0 ? accountViews : null,
+```
+
+**建议**:
+```typescript
+// 如果账号有作品,即使增量为 0 也显示 0,而不是 null
+viewsCount: workIds.length > 0 ? (accountViews > 0 ? accountViews : 0) : null,
+```
+
+### 2. 允许显示负数(如果需要)
+
+**当前逻辑**:
+```typescript
+fansIncrease: Math.max(0, accountFansIncrease),
+```
+
+**建议**:
+```typescript
+// 如果需要显示掉粉(负数),可以改为:
+fansIncrease: accountFansIncrease, // 允许负数
+```
+
+### 3. 改进 updateTime 的逻辑
+
+**当前逻辑**:
+```typescript
+const updateTime = latestUserStat?.updatedAt 
+  ? this.formatUpdateTime(latestUserStat.updatedAt)
+  : '';
+```
+
+**建议**:
+```typescript
+// 如果没有统计数据,可以使用账号表的更新时间
+const updateTime = latestUserStat?.updatedAt 
+  ? this.formatUpdateTime(latestUserStat.updatedAt)
+  : (account.updatedAt ? this.formatUpdateTime(account.updatedAt) : '');
+```
+
+## 相关文件
+
+- `server/src/services/WorkDayStatisticsService.ts` - 数据计算逻辑
+- `server/src/routes/workDayStatistics.ts` - API 路由
+- `client/src/views/Analytics/PlatformDetail/index.vue` - 前端展示
+
+## 更新日期
+
+2026-01-28

+ 284 - 0
docs/platform-detail-data-logic.md

@@ -0,0 +1,284 @@
+# 平台数据详情页数据展示逻辑
+
+## 概述
+
+平台数据详情页(`/analytics/platform-detail/:platform`)用于展示指定平台在指定时间范围内的详细统计数据,包括汇总统计、每日汇总数据和账号详细数据。
+
+## 数据流程
+
+### 1. 前端数据请求
+
+**文件位置**: `client/src/views/Analytics/PlatformDetail/index.vue`
+
+**请求接口**: `GET /api/work-day-statistics/platform-detail`
+
+**请求参数**:
+- `platform`: 平台类型(必填,如:douyin、xiaohongshu、baijiahao、weixin_video)
+- `startDate`: 开始日期(必填,格式:YYYY-MM-DD)
+- `endDate`: 结束日期(必填,格式:YYYY-MM-DD)
+
+**请求示例**:
+```typescript
+const data = await request.get('/api/work-day-statistics/platform-detail', {
+  params: {
+    platform: 'douyin',
+    startDate: '2026-01-27',
+    endDate: '2026-01-27',
+  },
+});
+```
+
+### 2. 后端数据处理
+
+**文件位置**: 
+- 路由: `server/src/routes/workDayStatistics.ts`
+- 服务: `server/src/services/WorkDayStatisticsService.ts`
+
+**处理流程**:
+
+1. **获取平台账号列表**
+   - 根据 `userId` 和 `platform` 查询该平台下的所有账号
+   - 如果没有账号,返回空数据
+
+2. **计算每日汇总数据**
+   - 遍历日期范围内的每一天
+   - 对每个账号:
+     - 获取该账号的所有作品ID
+     - 计算该日期的增量数据:
+       - **播放量增量** = 当日累计播放量 - 前一日累计播放量
+       - **评论量增量** = 当日累计评论量 - 前一日累计评论量
+       - **点赞量增量** = 当日累计点赞量 - 前一日累计点赞量
+       - **涨粉量** = 当日粉丝数 - 前一日粉丝数
+     - 将所有账号的增量数据累加到对应日期
+
+3. **计算账号详细数据**
+   - 对每个账号:
+     - 计算整个时间范围内的增量数据:
+       - **播放量增量** = 结束日期累计播放量 - 开始日期累计播放量
+       - **评论量增量** = 结束日期累计评论量 - 开始日期累计评论量
+       - **点赞量增量** = 结束日期累计点赞量 - 开始日期累计点赞量
+       - **涨粉量** = 结束日期粉丝数 - 开始日期粉丝数
+     - 获取更新时间(该账号在时间范围内的最新更新时间)
+
+4. **计算汇总统计**
+   - 将所有账号的增量数据累加:
+     - `totalAccounts`: 账号总数
+     - `viewsCount`: 总播放量增量
+     - `commentsCount`: 总评论量增量
+     - `likesCount`: 总点赞量增量
+     - `fansIncrease`: 总涨粉量
+
+### 3. 后端响应格式
+
+```typescript
+{
+  success: true,
+  data: {
+    summary: {
+      totalAccounts: number;        // 账号总数
+      totalIncome: number;           // 总收益(目前为 0)
+      viewsCount: number;            // 总播放量增量
+      commentsCount: number;         // 总评论量增量
+      likesCount: number;            // 总点赞量增量
+      fansIncrease: number;          // 总涨粉量
+      recommendationCount: number | null; // 推荐量(部分平台支持)
+    };
+    dailyData: Array<{
+      date: string;                  // 日期(YYYY-MM-DD)
+      income: number;                // 收益(目前为 0)
+      recommendationCount: number | null; // 推荐量
+      viewsCount: number;            // 播放量增量
+      commentsCount: number;         // 评论量增量
+      likesCount: number;            // 点赞量增量
+      fansIncrease: number;          // 涨粉量
+    }>;
+    accounts: Array<{
+      id: number;                    // 账号ID
+      nickname: string;               // 账号昵称
+      username: string;              // 账号用户名
+      avatarUrl: string | null;      // 头像URL
+      platform: string;              // 平台类型
+      income: number | null;         // 收益(目前为 null)
+      recommendationCount: number | null; // 推荐量
+      viewsCount: number | null;     // 播放量增量(可能为 null)
+      commentsCount: number;         // 评论量增量
+      likesCount: number;            // 点赞量增量
+      fansIncrease: number;          // 涨粉量
+      updateTime: string;            // 更新时间(MM-DD HH:mm)
+    }>;
+  }
+}
+```
+
+### 4. 前端数据展示
+
+**文件位置**: `client/src/views/Analytics/PlatformDetail/index.vue`
+
+**数据解包**:
+- `request` 拦截器会自动解包响应,将 `{ success: true, data: {...} }` 转换为 `{...}`
+- 前端直接使用解包后的数据
+
+**数据赋值**:
+```typescript
+// 更新汇总数据
+if (data.summary) {
+  summaryData.value = {
+    totalAccounts: data.summary.totalAccounts ?? 0,
+    viewsCount: data.summary.viewsCount ?? 0,
+    commentsCount: data.summary.commentsCount ?? 0,
+    likesCount: data.summary.likesCount ?? 0,
+    fansIncrease: data.summary.fansIncrease ?? 0,
+  };
+}
+
+// 更新每日数据
+dailyData.value = Array.isArray(data.dailyData) ? data.dailyData : [];
+
+// 更新账号列表
+accountList.value = Array.isArray(data.accounts) ? data.accounts : [];
+```
+
+**展示区域**:
+
+1. **汇总统计卡片**(第 57-93 行)
+   - 账号总数
+   - 播放(阅读)量
+   - 评论量
+   - 点赞量
+   - 涨粉量
+
+2. **每日汇总数据表格**(第 95-113 行)
+   - 时间列:显示日期
+   - 播放(阅读)量列:显示每日增量
+   - 评论量列:显示每日增量
+   - 点赞量列:显示每日增量
+   - 涨粉量列:显示每日增量
+
+3. **账号详细数据表格**(第 115-163 行)
+   - 账号列:显示头像和昵称
+   - 平台列:显示平台名称
+   - 播放(阅读)量列:显示账号增量(如果为 null 显示"获取失败")
+   - 评论量列:显示账号增量
+   - 点赞量列:显示账号增量
+   - 涨粉量列:显示账号增量
+   - 更新时间列:显示最后更新时间
+   - 操作列:提供"详情"按钮跳转到账号详情页
+
+## 数据计算逻辑
+
+### 播放量/评论量/点赞量计算
+
+**数据来源**: `work_day_statistics` 表
+
+**计算方式**:
+1. 对于每个作品,获取指定日期的最新累计数据
+2. 计算增量 = 当日累计值 - 前一日累计值
+3. 将所有作品的增量累加
+
+**SQL 逻辑**:
+```sql
+-- 获取指定日期的最新累计数据
+SELECT
+  COALESCE(SUM(wds.play_count), 0) AS views,
+  COALESCE(SUM(wds.like_count), 0) AS likes,
+  COALESCE(SUM(wds.comment_count), 0) AS comments
+FROM work_day_statistics wds
+INNER JOIN (
+  SELECT wds2.work_id, MAX(wds2.record_date) AS record_date
+  FROM work_day_statistics wds2
+  WHERE wds2.work_id IN (...)
+    AND wds2.record_date <= ?
+  GROUP BY wds2.work_id
+) latest
+ON latest.work_id = wds.work_id AND latest.record_date = wds.record_date
+```
+
+### 粉丝数计算
+
+**数据来源**: `user_day_statistics` 表
+
+**计算方式**:
+1. 获取指定日期的粉丝数记录
+2. 如果没有记录,获取 <= 指定日期的最近一条记录
+3. 计算增量 = 当日粉丝数 - 前一日粉丝数
+
+**查询逻辑**:
+```typescript
+// 获取当日粉丝数
+const dayUserStat = await userDayStatisticsRepository
+  .createQueryBuilder('uds')
+  .where('uds.account_id = :accountId', { accountId })
+  .andWhere('DATE(uds.record_date) = :d', { d: dateStr })
+  .getOne();
+
+// 如果没有记录,获取最近一条
+const recentUserStat = await userDayStatisticsRepository
+  .createQueryBuilder('uds')
+  .where('uds.account_id = :accountId', { accountId })
+  .andWhere('DATE(uds.record_date) <= :d', { d: dateStr })
+  .orderBy('uds.record_date', 'DESC')
+  .getOne();
+```
+
+## 常见问题
+
+### 1. 为什么数据不显示?
+
+**可能原因**:
+1. **平台参数缺失**: 检查路由参数或查询参数中是否有 `platform`
+2. **日期参数错误**: 检查 `startDate` 和 `endDate` 格式是否正确
+3. **账号不存在**: 该平台下没有账号数据
+4. **数据为空**: 该时间范围内没有统计数据
+
+**调试方法**:
+- 打开浏览器控制台,查看日志输出
+- 检查网络请求,查看接口返回的数据
+- 确认后端日志,查看数据查询结果
+
+### 2. 为什么播放量显示为 null?
+
+**原因**: 
+- 该账号在指定时间范围内没有作品数据
+- 或者作品数据获取失败
+
+**处理**: 
+- 前端会显示"获取失败"文本
+- 检查该账号是否有作品,以及作品统计数据是否正常
+
+### 3. 为什么增量数据为 0?
+
+**可能原因**:
+1. **时间范围问题**: 如果开始日期和结束日期相同,且该日期没有数据,增量可能为 0
+2. **数据未更新**: 该时间范围内的数据还没有统计
+3. **确实没有增量**: 该时间范围内确实没有新增数据
+
+**检查方法**:
+- 查看每日汇总数据,确认是否有数据
+- 检查账号的更新时间,确认数据是否已同步
+
+### 4. 数据更新频率
+
+**数据来源**:
+- 作品统计数据:通过定时任务或手动触发更新
+- 账号粉丝数据:通过定时任务或手动触发更新
+
+**更新时间**:
+- 账号列表中的 `updateTime` 字段显示该账号在时间范围内的最新更新时间
+
+## 修复记录
+
+### 2026-01-28
+
+**问题**: 数据不显示
+
+**原因**: 
+- 前端数据赋值逻辑使用了 `||` 运算符,导致 0 值被误判为 falsy
+- 当 `data.summary` 存在但某些字段为 0 时,会使用默认值而不是实际值
+
+**修复**:
+- 改用 `??` 运算符(空值合并运算符)
+- 添加类型检查和数组检查
+- 改进日志输出,便于调试
+
+**修改文件**:
+- `client/src/views/Analytics/PlatformDetail/index.vue` (第 324-340 行)

+ 250 - 0
docs/platform-list-api-error-fix.md

@@ -0,0 +1,250 @@
+# 平台数据列表页 API 错误修复
+
+## 问题描述
+
+平台数据列表页(`/analytics/platform`)在加载数据时出现 500 错误,错误信息显示:
+```
+Unexpected token '<', "<!doctype \"... is not valid JSON
+```
+
+这表明服务器返回的是 HTML 错误页面而不是预期的 JSON 数据。
+
+## 问题原因
+
+1. **Python API 服务未运行或连接失败**
+   - Python 服务(默认端口 5005)可能未启动
+   - 网络连接问题导致无法访问 Python 服务
+
+2. **Node.js 端错误处理不完善**
+   - `callPythonAnalyticsApi` 函数直接调用 `resp.json()`,没有检查响应状态和内容类型
+   - 当 Python 服务返回 HTML 错误页面时,JSON 解析失败导致错误
+
+3. **错误信息不明确**
+   - 前端无法获取详细的错误信息,难以定位问题
+
+## 修复方案
+
+### 1. 改进 `callPythonAnalyticsApi` 函数
+
+**文件**: `server/src/routes/analytics.ts`
+
+**改进内容**:
+- 添加响应状态检查(`resp.ok`)
+- 检查 Content-Type,确保是 JSON 响应
+- 添加详细的错误处理和错误信息
+- 处理网络连接错误(连接失败、超时等)
+
+**修复后的代码**:
+```typescript
+async function callPythonAnalyticsApi(pathname: string, params: Record<string, string | number | undefined>) {
+  const base = process.env.PYTHON_API_URL || 'http://localhost:5005';
+  const url = new URL(base);
+  url.pathname = pathname.startsWith('/') ? pathname : `/${pathname}`;
+
+  const search = new URLSearchParams();
+  Object.entries(params).forEach(([key, value]) => {
+    if (value !== undefined && value !== null) {
+      search.append(key, String(value));
+    }
+  });
+  url.search = search.toString();
+
+  try {
+    const resp = await fetch(url.toString(), { method: 'GET' });
+    
+    // 检查响应状态
+    if (!resp.ok) {
+      // 尝试解析 JSON 错误响应
+      let errorData: any;
+      try {
+        errorData = await resp.json();
+      } catch {
+        // 如果不是 JSON,读取文本内容
+        const text = await resp.text();
+        errorData = {
+          success: false,
+          error: `Python API 返回错误 (${resp.status}): ${text.substring(0, 500)}`,
+        };
+      }
+      throw new Error(errorData.error || `Python API 返回错误: ${resp.status} ${resp.statusText}`);
+    }
+
+    // 检查 Content-Type
+    const contentType = resp.headers.get('content-type') || '';
+    if (!contentType.includes('application/json')) {
+      const text = await resp.text();
+      throw new Error(`Python API 返回非 JSON 响应: ${text.substring(0, 500)}`);
+    }
+
+    const json = await resp.json();
+    return json;
+  } catch (error: any) {
+    // 如果是网络错误(连接失败、超时等)
+    if (error.name === 'TypeError' && error.message.includes('fetch')) {
+      throw new Error(`无法连接 Python API (${base}): ${error.message}`);
+    }
+    // 其他错误直接抛出
+    throw error;
+  }
+}
+```
+
+### 2. 改进路由错误处理
+
+**文件**: `server/src/routes/analytics.ts`
+
+**改进内容**:
+- 添加 try-catch 块捕获所有错误
+- 返回详细的错误信息给前端
+- 使用正确的 HTTP 状态码(500)
+
+**修复后的代码**:
+```typescript
+router.get(
+  '/platforms-from-python',
+  [
+    query('startDate').notEmpty().withMessage('开始日期不能为空'),
+    query('endDate').notEmpty().withMessage('结束日期不能为空'),
+    validateRequest,
+  ],
+  asyncHandler(async (req, res) => {
+    const { startDate, endDate } = req.query;
+
+    try {
+      const pythonResult = await callPythonAnalyticsApi('/work_day_statistics/platforms', {
+        user_id: req.user!.userId,
+        start_date: String(startDate),
+        end_date: String(endDate),
+      });
+
+      if (!pythonResult || pythonResult.success === false) {
+        return res.status(500).json({
+          success: false,
+          error: pythonResult?.error || '获取平台数据失败',
+          message: pythonResult?.error || '获取平台数据失败',
+        });
+      }
+
+      return res.json({
+        success: true,
+        data: pythonResult.data,
+      });
+    } catch (error: any) {
+      // 捕获并返回详细的错误信息
+      console.error('[platforms-from-python] 调用 Python API 失败:', error);
+      return res.status(500).json({
+        success: false,
+        error: error.message || '调用 Python API 失败',
+        message: error.message || '调用 Python API 失败',
+      });
+    }
+  })
+);
+```
+
+## 检查清单
+
+如果问题仍然存在,请按以下步骤检查:
+
+### 1. 检查 Python 服务是否运行
+
+```bash
+# 检查端口 5005 是否被占用
+netstat -ano | findstr :5005  # Windows
+lsof -i :5005                 # Linux/Mac
+
+# 检查 Python 服务日志
+# 查看 Python 服务控制台输出,确认服务是否正常启动
+```
+
+### 2. 检查环境变量
+
+确保 Node.js 服务能正确访问 Python 服务:
+
+```bash
+# 检查环境变量
+echo $PYTHON_API_URL  # Linux/Mac
+echo %PYTHON_API_URL% # Windows
+
+# 默认值应该是 http://localhost:5005
+```
+
+### 3. 手动测试 Python API
+
+```bash
+# 测试 Python API 健康检查
+curl http://localhost:5005/health
+
+# 测试平台统计接口(需要替换 user_id)
+curl "http://localhost:5005/work_day_statistics/platforms?user_id=1&start_date=2026-01-27&end_date=2026-01-27"
+```
+
+### 4. 检查 Node.js 服务日志
+
+查看 Node.js 服务的控制台输出,确认:
+- 是否有连接错误
+- 是否有超时错误
+- 是否有其他异常信息
+
+### 5. 检查网络连接
+
+确保 Node.js 服务能够访问 Python 服务:
+- 如果 Python 服务运行在不同的机器上,检查防火墙设置
+- 如果使用 Docker,检查容器网络配置
+
+## 常见错误及解决方案
+
+### 错误 1: "无法连接 Python API"
+
+**原因**: Python 服务未启动或无法访问
+
+**解决方案**:
+1. 启动 Python 服务:
+   ```bash
+   cd server/python
+   python app.py
+   ```
+
+2. 检查 Python 服务是否在正确的端口运行(默认 5005)
+
+3. 如果 Python 服务运行在不同的地址,设置环境变量:
+   ```bash
+   export PYTHON_API_URL=http://your-python-server:5005
+   ```
+
+### 错误 2: "Python API 返回错误 (500)"
+
+**原因**: Python 服务内部错误
+
+**解决方案**:
+1. 查看 Python 服务日志,找出具体错误
+2. 检查 Python 服务是否能正常连接数据库
+3. 检查 Python 服务的依赖是否完整安装
+
+### 错误 3: "Python API 返回非 JSON 响应"
+
+**原因**: Python 服务返回了 HTML 错误页面
+
+**解决方案**:
+1. 检查 Python 服务的错误处理逻辑
+2. 确保所有路由都返回 JSON 格式
+3. 检查 Flask 的错误处理中间件
+
+## 测试
+
+修复后,请测试以下场景:
+
+1. **正常情况**: Python 服务正常运行,数据正常返回
+2. **Python 服务未启动**: 应该返回明确的错误信息
+3. **Python 服务返回错误**: 应该返回详细的错误信息
+4. **网络超时**: 应该返回超时错误信息
+
+## 相关文件
+
+- `server/src/routes/analytics.ts` - Node.js API 路由
+- `server/python/app.py` - Python Flask 服务
+- `client/src/views/Analytics/Platform/index.vue` - 前端页面
+
+## 修复日期
+
+2026-01-28

+ 65 - 35
pnpm-lock.yaml

@@ -174,6 +174,9 @@ importers:
       ws:
         specifier: ^8.16.0
         version: 8.19.0
+      xlsx:
+        specifier: ^0.18.5
+        version: 0.18.5
     devDependencies:
       '@types/bcryptjs':
         specifier: ^2.4.6
@@ -674,105 +677,89 @@ packages:
     resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
     cpu: [arm64]
     os: [linux]
-    libc: [glibc]
 
   '@img/sharp-libvips-linux-arm@1.2.4':
     resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
     cpu: [arm]
     os: [linux]
-    libc: [glibc]
 
   '@img/sharp-libvips-linux-ppc64@1.2.4':
     resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
     cpu: [ppc64]
     os: [linux]
-    libc: [glibc]
 
   '@img/sharp-libvips-linux-riscv64@1.2.4':
     resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
     cpu: [riscv64]
     os: [linux]
-    libc: [glibc]
 
   '@img/sharp-libvips-linux-s390x@1.2.4':
     resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
     cpu: [s390x]
     os: [linux]
-    libc: [glibc]
 
   '@img/sharp-libvips-linux-x64@1.2.4':
     resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
     cpu: [x64]
     os: [linux]
-    libc: [glibc]
 
   '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
     resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
     cpu: [arm64]
     os: [linux]
-    libc: [musl]
 
   '@img/sharp-libvips-linuxmusl-x64@1.2.4':
     resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
     cpu: [x64]
     os: [linux]
-    libc: [musl]
 
   '@img/sharp-linux-arm64@0.34.5':
     resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
     engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
     cpu: [arm64]
     os: [linux]
-    libc: [glibc]
 
   '@img/sharp-linux-arm@0.34.5':
     resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
     engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
     cpu: [arm]
     os: [linux]
-    libc: [glibc]
 
   '@img/sharp-linux-ppc64@0.34.5':
     resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
     engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
     cpu: [ppc64]
     os: [linux]
-    libc: [glibc]
 
   '@img/sharp-linux-riscv64@0.34.5':
     resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
     engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
     cpu: [riscv64]
     os: [linux]
-    libc: [glibc]
 
   '@img/sharp-linux-s390x@0.34.5':
     resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
     engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
     cpu: [s390x]
     os: [linux]
-    libc: [glibc]
 
   '@img/sharp-linux-x64@0.34.5':
     resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
     engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
     cpu: [x64]
     os: [linux]
-    libc: [glibc]
 
   '@img/sharp-linuxmusl-arm64@0.34.5':
     resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
     engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
     cpu: [arm64]
     os: [linux]
-    libc: [musl]
 
   '@img/sharp-linuxmusl-x64@0.34.5':
     resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
     engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
     cpu: [x64]
     os: [linux]
-    libc: [musl]
 
   '@img/sharp-wasm32@0.34.5':
     resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
@@ -898,42 +885,36 @@ packages:
     engines: {node: '>= 10.0.0'}
     cpu: [arm]
     os: [linux]
-    libc: [glibc]
 
   '@parcel/watcher-linux-arm-musl@2.5.4':
     resolution: {integrity: sha512-kGO8RPvVrcAotV4QcWh8kZuHr9bXi9a3bSZw7kFarYR0+fGliU7hd/zevhjw8fnvIKG3J9EO5G6sXNGCSNMYPQ==}
     engines: {node: '>= 10.0.0'}
     cpu: [arm]
     os: [linux]
-    libc: [musl]
 
   '@parcel/watcher-linux-arm64-glibc@2.5.4':
     resolution: {integrity: sha512-KU75aooXhqGFY2W5/p8DYYHt4hrjHZod8AhcGAmhzPn/etTa+lYCDB2b1sJy3sWJ8ahFVTdy+EbqSBvMx3iFlw==}
     engines: {node: '>= 10.0.0'}
     cpu: [arm64]
     os: [linux]
-    libc: [glibc]
 
   '@parcel/watcher-linux-arm64-musl@2.5.4':
     resolution: {integrity: sha512-Qx8uNiIekVutnzbVdrgSanM+cbpDD3boB1f8vMtnuG5Zau4/bdDbXyKwIn0ToqFhIuob73bcxV9NwRm04/hzHQ==}
     engines: {node: '>= 10.0.0'}
     cpu: [arm64]
     os: [linux]
-    libc: [musl]
 
   '@parcel/watcher-linux-x64-glibc@2.5.4':
     resolution: {integrity: sha512-UYBQvhYmgAv61LNUn24qGQdjtycFBKSK3EXr72DbJqX9aaLbtCOO8+1SkKhD/GNiJ97ExgcHBrukcYhVjrnogA==}
     engines: {node: '>= 10.0.0'}
     cpu: [x64]
     os: [linux]
-    libc: [glibc]
 
   '@parcel/watcher-linux-x64-musl@2.5.4':
     resolution: {integrity: sha512-YoRWCVgxv8akZrMhdyVi6/TyoeeMkQ0PGGOf2E4omODrvd1wxniXP+DBynKoHryStks7l+fDAMUBRzqNHrVOpg==}
     engines: {node: '>= 10.0.0'}
     cpu: [x64]
     os: [linux]
-    libc: [musl]
 
   '@parcel/watcher-win32-arm64@2.5.4':
     resolution: {integrity: sha512-iby+D/YNXWkiQNYcIhg8P5hSjzXEHaQrk2SLrWOUD7VeC4Ohu0WQvmV+HDJokZVJ2UjJ4AGXW3bx7Lls9Ln4TQ==}
@@ -1033,79 +1014,66 @@ packages:
     resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==}
     cpu: [arm]
     os: [linux]
-    libc: [glibc]
 
   '@rollup/rollup-linux-arm-musleabihf@4.55.1':
     resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==}
     cpu: [arm]
     os: [linux]
-    libc: [musl]
 
   '@rollup/rollup-linux-arm64-gnu@4.55.1':
     resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==}
     cpu: [arm64]
     os: [linux]
-    libc: [glibc]
 
   '@rollup/rollup-linux-arm64-musl@4.55.1':
     resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==}
     cpu: [arm64]
     os: [linux]
-    libc: [musl]
 
   '@rollup/rollup-linux-loong64-gnu@4.55.1':
     resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==}
     cpu: [loong64]
     os: [linux]
-    libc: [glibc]
 
   '@rollup/rollup-linux-loong64-musl@4.55.1':
     resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==}
     cpu: [loong64]
     os: [linux]
-    libc: [musl]
 
   '@rollup/rollup-linux-ppc64-gnu@4.55.1':
     resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==}
     cpu: [ppc64]
     os: [linux]
-    libc: [glibc]
 
   '@rollup/rollup-linux-ppc64-musl@4.55.1':
     resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==}
     cpu: [ppc64]
     os: [linux]
-    libc: [musl]
 
   '@rollup/rollup-linux-riscv64-gnu@4.55.1':
     resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==}
     cpu: [riscv64]
     os: [linux]
-    libc: [glibc]
 
   '@rollup/rollup-linux-riscv64-musl@4.55.1':
     resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==}
     cpu: [riscv64]
     os: [linux]
-    libc: [musl]
 
   '@rollup/rollup-linux-s390x-gnu@4.55.1':
     resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==}
     cpu: [s390x]
     os: [linux]
-    libc: [glibc]
 
   '@rollup/rollup-linux-x64-gnu@4.55.1':
     resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==}
     cpu: [x64]
     os: [linux]
-    libc: [glibc]
 
   '@rollup/rollup-linux-x64-musl@4.55.1':
     resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==}
     cpu: [x64]
     os: [linux]
-    libc: [musl]
 
   '@rollup/rollup-openbsd-x64@4.55.1':
     resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==}
@@ -1429,6 +1397,10 @@ packages:
     engines: {node: '>=0.4.0'}
     hasBin: true
 
+  adler-32@1.3.1:
+    resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
+    engines: {node: '>=0.8'}
+
   agent-base@6.0.2:
     resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
     engines: {node: '>= 6.0.0'}
@@ -1652,6 +1624,10 @@ packages:
     resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
     engines: {node: '>=6'}
 
+  cfb@1.2.2:
+    resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
+    engines: {node: '>=0.8'}
+
   chalk@4.1.2:
     resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
     engines: {node: '>=10'}
@@ -1690,6 +1666,10 @@ packages:
     resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
     engines: {node: '>=0.10.0'}
 
+  codepage@1.15.0:
+    resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
+    engines: {node: '>=0.8'}
+
   color-convert@2.0.1:
     resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
     engines: {node: '>=7.0.0'}
@@ -2192,6 +2172,10 @@ packages:
     resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
     engines: {node: '>= 0.6'}
 
+  frac@1.1.2:
+    resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
+    engines: {node: '>=0.8'}
+
   fresh@0.5.2:
     resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
     engines: {node: '>= 0.6'}
@@ -3286,6 +3270,10 @@ packages:
     resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
     engines: {node: '>= 0.6'}
 
+  ssf@0.11.2:
+    resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
+    engines: {node: '>=0.8'}
+
   stack-trace@0.0.10:
     resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==}
 
@@ -3727,10 +3715,18 @@ packages:
     resolution: {integrity: sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==}
     engines: {node: '>= 12.0.0'}
 
+  wmf@1.0.2:
+    resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
+    engines: {node: '>=0.8'}
+
   word-wrap@1.2.5:
     resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
     engines: {node: '>=0.10.0'}
 
+  word@0.3.0:
+    resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
+    engines: {node: '>=0.8'}
+
   wrap-ansi@7.0.0:
     resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
     engines: {node: '>=10'}
@@ -3754,6 +3750,11 @@ packages:
       utf-8-validate:
         optional: true
 
+  xlsx@0.18.5:
+    resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
+    engines: {node: '>=0.8'}
+    hasBin: true
+
   xml-name-validator@4.0.0:
     resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
     engines: {node: '>=12'}
@@ -4802,6 +4803,8 @@ snapshots:
 
   acorn@8.15.0: {}
 
+  adler-32@1.3.1: {}
+
   agent-base@6.0.2:
     dependencies:
       debug: 4.4.3
@@ -5106,6 +5109,11 @@ snapshots:
 
   callsites@3.1.0: {}
 
+  cfb@1.2.2:
+    dependencies:
+      adler-32: 1.3.1
+      crc-32: 1.2.2
+
   chalk@4.1.2:
     dependencies:
       ansi-styles: 4.3.0
@@ -5151,6 +5159,8 @@ snapshots:
 
   cluster-key-slot@1.1.2: {}
 
+  codepage@1.15.0: {}
+
   color-convert@2.0.1:
     dependencies:
       color-name: 1.1.4
@@ -5806,6 +5816,8 @@ snapshots:
 
   forwarded@0.2.0: {}
 
+  frac@1.1.2: {}
+
   fresh@0.5.2: {}
 
   fs-constants@1.0.0: {}
@@ -6975,6 +6987,10 @@ snapshots:
 
   sqlstring@2.3.3: {}
 
+  ssf@0.11.2:
+    dependencies:
+      frac: 1.1.2
+
   stack-trace@0.0.10: {}
 
   standard-as-callback@2.1.0: {}
@@ -7373,8 +7389,12 @@ snapshots:
       triple-beam: 1.4.1
       winston-transport: 4.9.0
 
+  wmf@1.0.2: {}
+
   word-wrap@1.2.5: {}
 
+  word@0.3.0: {}
+
   wrap-ansi@7.0.0:
     dependencies:
       ansi-styles: 4.3.0
@@ -7391,6 +7411,16 @@ snapshots:
 
   ws@8.19.0: {}
 
+  xlsx@0.18.5:
+    dependencies:
+      adler-32: 1.3.1
+      cfb: 1.2.2
+      codepage: 1.15.0
+      crc-32: 1.2.2
+      ssf: 0.11.2
+      wmf: 1.0.2
+      word: 0.3.0
+
   xml-name-validator@4.0.0: {}
 
   xmlbuilder@15.1.1: {}

+ 4 - 1
server/package.json

@@ -6,6 +6,8 @@
   "main": "./dist/app.js",
   "scripts": {
     "dev": "tsx watch src/app.ts",
+    "xhs:import": "tsx src/scripts/run-xhs-import.ts",
+    "xhs:auth": "set XHS_IMPORT_HEADLESS=0&& set XHS_STORAGE_STATE_BOOTSTRAP=1&& tsx src/scripts/run-xhs-import.ts",
     "build": "tsc",
     "start": "node dist/app.js",
     "clean": "rimraf dist",
@@ -33,7 +35,8 @@
     "typeorm": "^0.3.19",
     "uuid": "^9.0.1",
     "winston": "^3.11.0",
-    "ws": "^8.16.0"
+    "ws": "^8.16.0",
+    "xlsx": "^0.18.5"
   },
   "devDependencies": {
     "@types/bcryptjs": "^2.4.6",

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


+ 24 - 0
server/src/models/entities/UserDayStatistics.ts

@@ -18,6 +18,30 @@ export class UserDayStatistics {
   @Column({ name: 'works_count', type: 'int', default: 0, comment: '作品数' })
   worksCount!: number;
 
+  @Column({ name: 'play_count', type: 'int', default: 0, comment: '播放数' })
+  playCount!: number;
+
+  @Column({ name: 'comment_count', type: 'int', default: 0, comment: '评论数' })
+  commentCount!: number;
+
+  @Column({ name: 'fans_increase', type: 'int', default: 0, comment: '涨粉数' })
+  fansIncrease!: number;
+
+  @Column({ name: 'like_count', type: 'int', default: 0, comment: '点赞数' })
+  likeCount!: number;
+
+  @Column({ name: 'cover_click_rate', type: 'varchar', length: 50, default: '0', comment: '封面点击率' })
+  coverClickRate!: string;
+
+  @Column({ name: 'avg_watch_duration', type: 'varchar', length: 50, default: '0', comment: '平均观看时长(秒)' })
+  avgWatchDuration!: string;
+
+  @Column({ name: 'total_watch_duration', type: 'varchar', length: 50, default: '0', comment: '观看总时长(秒)' })
+  totalWatchDuration!: string;
+
+  @Column({ name: 'completion_rate', type: 'varchar', length: 50, default: '0', comment: '视频完播率' })
+  completionRate!: string;
+
   @CreateDateColumn({ name: 'created_at' })
   createdAt!: Date;
 

+ 82 - 27
server/src/routes/analytics.ts

@@ -31,9 +31,43 @@ async function callPythonAnalyticsApi(pathname: string, params: Record<string, s
   });
   url.search = search.toString();
 
-  const resp = await fetch(url.toString(), { method: 'GET' });
-  const json = await resp.json();
-  return json;
+  try {
+    const resp = await fetch(url.toString(), { method: 'GET' });
+    
+    // 检查响应状态
+    if (!resp.ok) {
+      // 尝试解析 JSON 错误响应
+      let errorData: any;
+      try {
+        errorData = await resp.json();
+      } catch {
+        // 如果不是 JSON,读取文本内容
+        const text = await resp.text();
+        errorData = {
+          success: false,
+          error: `Python API 返回错误 (${resp.status}): ${text.substring(0, 500)}`,
+        };
+      }
+      throw new Error(errorData.error || `Python API 返回错误: ${resp.status} ${resp.statusText}`);
+    }
+
+    // 检查 Content-Type
+    const contentType = resp.headers.get('content-type') || '';
+    if (!contentType.includes('application/json')) {
+      const text = await resp.text();
+      throw new Error(`Python API 返回非 JSON 响应: ${text.substring(0, 500)}`);
+    }
+
+    const json = await resp.json();
+    return json;
+  } catch (error: any) {
+    // 如果是网络错误(连接失败、超时等)
+    if (error.name === 'TypeError' && error.message.includes('fetch')) {
+      throw new Error(`无法连接 Python API (${base}): ${error.message}`);
+    }
+    // 其他错误直接抛出
+    throw error;
+  }
 }
 
 // 获取汇总统计(直接走 Node 本地统计)
@@ -71,23 +105,33 @@ router.get(
   asyncHandler(async (req, res) => {
     const { startDate, endDate } = req.query;
 
-    const pythonResult = await callPythonAnalyticsApi('/work_day_statistics/trend', {
-      user_id: req.user!.userId,
-      start_date: String(startDate),
-      end_date: String(endDate),
-    });
+    try {
+      const pythonResult = await callPythonAnalyticsApi('/work_day_statistics/trend', {
+        user_id: req.user!.userId,
+        start_date: String(startDate),
+        end_date: String(endDate),
+      });
+
+      if (!pythonResult || pythonResult.success === false) {
+        return res.status(500).json({
+          success: false,
+          error: pythonResult?.error || '获取数据趋势失败',
+          message: pythonResult?.error || '获取数据趋势失败',
+        });
+      }
 
-    if (!pythonResult || pythonResult.success === false) {
       return res.json({
+        success: true,
+        data: pythonResult.data,
+      });
+    } catch (error: any) {
+      console.error('[trend-from-python] 调用 Python API 失败:', error);
+      return res.status(500).json({
         success: false,
-        message: pythonResult?.error || '获取数据趋势失败',
+        error: error.message || '调用 Python API 失败',
+        message: error.message || '调用 Python API 失败',
       });
     }
-
-    return res.json({
-      success: true,
-      data: pythonResult.data,
-    });
   })
 );
 
@@ -126,23 +170,34 @@ router.get(
   asyncHandler(async (req, res) => {
     const { startDate, endDate } = req.query;
 
-    const pythonResult = await callPythonAnalyticsApi('/work_day_statistics/platforms', {
-      user_id: req.user!.userId,
-      start_date: String(startDate),
-      end_date: String(endDate),
-    });
+    try {
+      const pythonResult = await callPythonAnalyticsApi('/work_day_statistics/platforms', {
+        user_id: req.user!.userId,
+        start_date: String(startDate),
+        end_date: String(endDate),
+      });
+
+      if (!pythonResult || pythonResult.success === false) {
+        return res.status(500).json({
+          success: false,
+          error: pythonResult?.error || '获取平台数据失败',
+          message: pythonResult?.error || '获取平台数据失败',
+        });
+      }
 
-    if (!pythonResult || pythonResult.success === false) {
       return res.json({
+        success: true,
+        data: pythonResult.data,
+      });
+    } catch (error: any) {
+      // 捕获并返回详细的错误信息
+      console.error('[platforms-from-python] 调用 Python API 失败:', error);
+      return res.status(500).json({
         success: false,
-        message: pythonResult?.error || '获取平台数据失败',
+        error: error.message || '调用 Python API 失败',
+        message: error.message || '调用 Python API 失败',
       });
     }
-
-    return res.json({
-      success: true,
-      data: pythonResult.data,
-    });
   })
 );
 

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

@@ -240,5 +240,32 @@ router.get(
   })
 );
 
+/**
+ * GET /api/work-day-statistics/platform-detail
+ * 获取平台详情数据(汇总统计、每日汇总、账号列表)
+ *
+ * 查询参数:
+ * - platform: 平台类型(必填)
+ * - startDate: 开始日期(必填)
+ * - endDate: 结束日期(必填)
+ */
+router.get(
+  '/platform-detail',
+  [
+    query('platform').notEmpty().withMessage('platform 不能为空'),
+    query('startDate').notEmpty().withMessage('startDate 不能为空'),
+    query('endDate').notEmpty().withMessage('endDate 不能为空'),
+    validateRequest,
+  ],
+  asyncHandler(async (req, res) => {
+    const { platform, startDate, endDate } = req.query;
+    const data = await workDayStatisticsService.getPlatformDetail(req.user!.userId, platform as string, {
+      startDate: startDate as string,
+      endDate: endDate as string,
+    });
+    res.json({ success: true, data });
+  })
+);
+
 export default router;
 

+ 25 - 0
server/src/scheduler/index.ts

@@ -6,6 +6,7 @@ import { WS_EVENTS } from '@media-manager/shared';
 import { getAdapter, isPlatformSupported } from '../automation/platforms/index.js';
 import { LessThanOrEqual, In } from 'typeorm';
 import { taskQueueService } from '../services/TaskQueueService.js';
+import { XiaohongshuAccountOverviewImportService } from '../services/XiaohongshuAccountOverviewImportService.js';
 
 /**
  * 定时任务调度器
@@ -13,6 +14,7 @@ import { taskQueueService } from '../services/TaskQueueService.js';
 export class TaskScheduler {
   private jobs: Map<string, schedule.Job> = new Map();
   private isRefreshingAccounts = false; // 账号刷新锁,防止任务重叠执行
+  private isXhsImportRunning = false; // 小红书导入锁,防止任务重叠执行
   
   /**
    * 启动调度器
@@ -26,6 +28,10 @@ export class TaskScheduler {
     
     // 每分钟检查定时发布任务(只处理到期的定时发布任务)
     this.scheduleJob('check-publish-tasks', '* * * * *', this.checkPublishTasks.bind(this));
+
+    // 每天早上 7 点:批量导出小红书“账号概览-笔记数据-观看数据-近30日”,导入 user_day_statistics
+    // 注意:node-schedule 使用服务器本地时区
+    this.scheduleJob('xhs-account-overview-import', '0 7 * * *', this.importXhsAccountOverviewLast30Days.bind(this));
     
     // 注意:账号刷新由客户端定时触发,不在服务端自动执行
     // 这样可以确保只刷新当前登录用户的账号,避免处理其他用户的数据
@@ -35,6 +41,7 @@ export class TaskScheduler {
     
     logger.info('[Scheduler] Scheduled jobs:');
     logger.info('[Scheduler]   - check-publish-tasks: every minute (* * * * *)');
+    logger.info('[Scheduler]   - xhs-account-overview-import: daily at 07:00 (0 7 * * *)');
     logger.info('[Scheduler] Note: Account refresh is triggered by client, not server');
     logger.info('[Scheduler] ========================================');
     
@@ -291,6 +298,24 @@ export class TaskScheduler {
       }
     }
   }
+
+  /**
+   * 小红书:账号概览导出(近30日)→ 导入 user_day_statistics
+   */
+  private async importXhsAccountOverviewLast30Days(): Promise<void> {
+    if (this.isXhsImportRunning) {
+      logger.info('[Scheduler] XHS import is already running, skipping this cycle...');
+      return;
+    }
+
+    this.isXhsImportRunning = true;
+    try {
+      const svc = new XiaohongshuAccountOverviewImportService();
+      await svc.runDailyImportForAllXhsAccounts();
+    } finally {
+      this.isXhsImportRunning = false;
+    }
+  }
 }
 
 export const taskScheduler = new TaskScheduler();

+ 20 - 0
server/src/scripts/run-xhs-import.ts

@@ -0,0 +1,20 @@
+import { initDatabase } from '../models/index.js';
+import { logger } from '../utils/logger.js';
+import { XiaohongshuAccountOverviewImportService } from '../services/XiaohongshuAccountOverviewImportService.js';
+
+async function main() {
+  try {
+    await initDatabase();
+    logger.info('[XHS Import] Manual run start...');
+    const svc = new XiaohongshuAccountOverviewImportService();
+    await svc.runDailyImportForAllXhsAccounts();
+    logger.info('[XHS Import] Manual run done.');
+    process.exit(0);
+  } catch (e) {
+    logger.error('[XHS Import] Manual run failed:', e);
+    process.exit(1);
+  }
+}
+
+void main();
+

+ 15 - 14
server/src/services/AccountService.ts

@@ -491,20 +491,21 @@ export class AccountService {
 
     // 保存账号每日统计数据(粉丝数、作品数)
     // 无论是否更新了粉丝数/作品数,都要保存当前值到统计表,确保每天都有记录
-    if (updated) {
-      try {
-        const userDayStatisticsService = new UserDayStatisticsService();
-        await userDayStatisticsService.saveStatistics({
-          accountId,
-          fansCount: updated.fansCount || 0,
-          worksCount: updated.worksCount || 0,
-        });
-        logger.debug(`[AccountService] Saved account day statistics for account ${accountId} (fans: ${updated.fansCount || 0}, works: ${updated.worksCount || 0})`);
-      } catch (error) {
-        logger.error(`[AccountService] Failed to save account day statistics for account ${accountId}:`, error);
-        // 不抛出错误,不影响主流程
-      }
-    }
+    // TODO: 暂时注释掉,待后续需要时再启用
+    // if (updated) {
+    //   try {
+    //     const userDayStatisticsService = new UserDayStatisticsService();
+    //     await userDayStatisticsService.saveStatistics({
+    //       accountId,
+    //       fansCount: updated.fansCount || 0,
+    //       worksCount: updated.worksCount || 0,
+    //     });
+    //     logger.debug(`[AccountService] Saved account day statistics for account ${accountId} (fans: ${updated.fansCount || 0}, works: ${updated.worksCount || 0})`);
+    //   } catch (error) {
+    //     logger.error(`[AccountService] Failed to save account day statistics for account ${accountId}:`, error);
+    //     // 不抛出错误,不影响主流程
+    //   }
+    // }
 
     // 通知其他客户端
     wsManager.sendToUser(userId, WS_EVENTS.ACCOUNT_UPDATED, { account: this.formatAccount(updated!) });

+ 97 - 2
server/src/services/UserDayStatisticsService.ts

@@ -3,8 +3,16 @@ import { logger } from '../utils/logger.js';
 
 export interface UserDayStatisticsItem {
   accountId: number;
-  fansCount: number;
-  worksCount: number;
+  fansCount?: number;
+  worksCount?: number;
+  playCount?: number;
+  commentCount?: number;
+  fansIncrease?: number;
+  likeCount?: number;
+  coverClickRate?: string;
+  avgWatchDuration?: string;
+  totalWatchDuration?: string;
+  completionRate?: string;
 }
 
 export interface SaveResult {
@@ -39,6 +47,14 @@ export class UserDayStatisticsService {
       await this.statisticsRepository.update(existing.id, {
         fansCount: item.fansCount ?? existing.fansCount,
         worksCount: item.worksCount ?? existing.worksCount,
+        playCount: item.playCount ?? existing.playCount,
+        commentCount: item.commentCount ?? existing.commentCount,
+        fansIncrease: item.fansIncrease ?? existing.fansIncrease,
+        likeCount: item.likeCount ?? existing.likeCount,
+        coverClickRate: item.coverClickRate ?? existing.coverClickRate ?? '0',
+        avgWatchDuration: item.avgWatchDuration ?? existing.avgWatchDuration ?? '0',
+        totalWatchDuration: item.totalWatchDuration ?? existing.totalWatchDuration ?? '0',
+        completionRate: item.completionRate ?? existing.completionRate ?? '0',
       });
       logger.debug(`[UserDayStatistics] Updated record for account ${item.accountId}, date: ${today.toISOString().split('T')[0]}`);
       return { inserted: 0, updated: 1 };
@@ -49,6 +65,14 @@ export class UserDayStatisticsService {
         recordDate: today,
         fansCount: item.fansCount ?? 0,
         worksCount: item.worksCount ?? 0,
+        playCount: item.playCount ?? 0,
+        commentCount: item.commentCount ?? 0,
+        fansIncrease: item.fansIncrease ?? 0,
+        likeCount: item.likeCount ?? 0,
+        coverClickRate: item.coverClickRate ?? '0',
+        avgWatchDuration: item.avgWatchDuration ?? '0',
+        totalWatchDuration: item.totalWatchDuration ?? '0',
+        completionRate: item.completionRate ?? '0',
       });
       await this.statisticsRepository.save(newStat);
       logger.debug(`[UserDayStatistics] Inserted new record for account ${item.accountId}, date: ${today.toISOString().split('T')[0]}`);
@@ -57,6 +81,57 @@ export class UserDayStatisticsService {
   }
 
   /**
+   * 保存指定日期的用户每日统计数据(按 accountId + recordDate 维度 upsert)
+   * 说明:recordDate 会被归零到当天 00:00:00(本地时区),避免重复 key 不一致
+   */
+  async saveStatisticsForDate(
+    accountId: number,
+    recordDate: Date,
+    patch: Omit<UserDayStatisticsItem, 'accountId'>
+  ): Promise<SaveResult> {
+    const d = new Date(recordDate);
+    d.setHours(0, 0, 0, 0);
+
+    const existing = await this.statisticsRepository.findOne({
+      where: { accountId, recordDate: d },
+    });
+
+    if (existing) {
+      await this.statisticsRepository.update(existing.id, {
+        fansCount: patch.fansCount ?? existing.fansCount,
+        worksCount: patch.worksCount ?? existing.worksCount,
+        playCount: patch.playCount ?? existing.playCount,
+        commentCount: patch.commentCount ?? existing.commentCount,
+        fansIncrease: patch.fansIncrease ?? existing.fansIncrease,
+        likeCount: patch.likeCount ?? existing.likeCount,
+        coverClickRate: patch.coverClickRate ?? existing.coverClickRate ?? '0',
+        avgWatchDuration: patch.avgWatchDuration ?? existing.avgWatchDuration ?? '0',
+        totalWatchDuration: patch.totalWatchDuration ?? existing.totalWatchDuration ?? '0',
+        completionRate: patch.completionRate ?? existing.completionRate ?? '0',
+      });
+      return { inserted: 0, updated: 1 };
+    }
+
+    const newStat = this.statisticsRepository.create({
+      accountId,
+      recordDate: d,
+      fansCount: patch.fansCount ?? 0,
+      worksCount: patch.worksCount ?? 0,
+      playCount: patch.playCount ?? 0,
+      commentCount: patch.commentCount ?? 0,
+      fansIncrease: patch.fansIncrease ?? 0,
+      likeCount: patch.likeCount ?? 0,
+      coverClickRate: patch.coverClickRate ?? '0',
+      avgWatchDuration: patch.avgWatchDuration ?? '0',
+      totalWatchDuration: patch.totalWatchDuration ?? '0',
+      completionRate: patch.completionRate ?? '0',
+    });
+
+    await this.statisticsRepository.save(newStat);
+    return { inserted: 1, updated: 0 };
+  }
+
+  /**
    * 批量保存用户每日统计数据
    */
   async saveStatisticsBatch(items: UserDayStatisticsItem[]): Promise<SaveResult> {
@@ -74,6 +149,26 @@ export class UserDayStatisticsService {
   }
 
   /**
+   * 批量保存指定日期范围的统计数据(每条记录自带日期)
+   */
+  async saveStatisticsForDateBatch(
+    items: Array<{ accountId: number; recordDate: Date } & Omit<UserDayStatisticsItem, 'accountId'>>
+  ): Promise<SaveResult> {
+    let insertedCount = 0;
+    let updatedCount = 0;
+
+    for (const it of items) {
+      const { accountId, recordDate, ...patch } = it;
+      const result = await this.saveStatisticsForDate(accountId, recordDate, patch);
+      insertedCount += result.inserted;
+      updatedCount += result.updated;
+    }
+
+    logger.info(`[UserDayStatistics] Date-batch save completed: inserted=${insertedCount}, updated=${updatedCount}`);
+    return { inserted: insertedCount, updated: updatedCount };
+  }
+
+  /**
    * 获取账号指定日期的统计数据
    */
   async getStatisticsByDate(

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

@@ -898,4 +898,268 @@ export class WorkDayStatisticsService {
       },
     };
   }
+
+  /**
+   * 获取平台详情数据
+   * 包括汇总统计、每日汇总数据和账号列表
+   */
+  async getPlatformDetail(
+    userId: number,
+    platform: string,
+    options: {
+      startDate: string;
+      endDate: string;
+    }
+  ): Promise<{
+    summary: {
+      totalAccounts: number;
+      totalIncome: number;
+      viewsCount: number;
+      commentsCount: number;
+      likesCount: number;
+      fansIncrease: number;
+      recommendationCount: number | null; // 推荐量(部分平台支持)
+    };
+    dailyData: Array<{
+      date: string;
+      income: number;
+      recommendationCount: number | null;
+      viewsCount: number;
+      commentsCount: number;
+      likesCount: number;
+      fansIncrease: number;
+    }>;
+    accounts: Array<{
+      id: number;
+      nickname: string;
+      username: string;
+      avatarUrl: string | null;
+      platform: string;
+      income: number | null;
+      recommendationCount: number | null;
+      viewsCount: number | null;
+      commentsCount: number;
+      likesCount: number;
+      fansIncrease: number;
+      updateTime: string;
+    }>;
+  }> {
+    const { startDate, endDate } = options;
+    const startDateStr = startDate;
+    const endDateStr = endDate;
+
+    // 获取该平台的所有账号
+    const accounts = await this.accountRepository.find({
+      where: {
+        userId,
+        platform: platform as any,
+      },
+      relations: ['group'],
+    });
+
+    if (accounts.length === 0) {
+      return {
+        summary: {
+          totalAccounts: 0,
+          totalIncome: 0,
+          viewsCount: 0,
+          commentsCount: 0,
+          likesCount: 0,
+          fansIncrease: 0,
+          recommendationCount: null,
+        },
+        dailyData: [],
+        accounts: [],
+      };
+    }
+
+    // 计算汇总统计
+    let totalAccounts = 0;
+    let totalViews = 0;
+    let totalComments = 0;
+    let totalLikes = 0;
+    let totalFansIncrease = 0;
+
+    // 按日期汇总数据
+    const dailyMap = new Map<string, {
+      views: number;
+      comments: number;
+      likes: number;
+      fansIncrease: number;
+    }>();
+
+    // 账号详细列表
+    const accountList: Array<{
+      id: number;
+      nickname: string;
+      username: string;
+      avatarUrl: string | null;
+      platform: string;
+      income: number | null;
+      recommendationCount: number | null;
+      viewsCount: number | null;
+      commentsCount: number;
+      likesCount: number;
+      fansIncrease: number;
+      updateTime: string;
+    }> = [];
+
+    for (const account of accounts) {
+      const works = await this.workRepository.find({
+        where: { accountId: account.id },
+        select: ['id'],
+      });
+      const workIds = works.map(w => w.id);
+
+      // 获取该账号在日期范围内的每日数据
+      const dateStart = new Date(startDateStr);
+      const dateEnd = new Date(endDateStr);
+      const currentDate = new Date(dateStart);
+
+      while (currentDate <= dateEnd) {
+        const dateStr = this.formatDate(currentDate);
+        
+        // 获取该日期的数据
+        const [daySums, prevDaySums] = await Promise.all([
+          this.getWorkSumsAtDate(workIds, dateStr),
+          this.getWorkSumsAtDate(workIds, this.formatDate(new Date(currentDate.getTime() - 24 * 60 * 60 * 1000))),
+        ]);
+
+        const dayViews = daySums.views - prevDaySums.views;
+        const dayComments = daySums.comments - prevDaySums.comments;
+        const dayLikes = daySums.likes - prevDaySums.likes;
+
+        // 获取粉丝数据
+        const dayUserStat = await this.userDayStatisticsRepository
+          .createQueryBuilder('uds')
+          .where('uds.account_id = :accountId', { accountId: account.id })
+          .andWhere('DATE(uds.record_date) = :d', { d: dateStr })
+          .getOne();
+
+        const prevDayUserStat = await this.userDayStatisticsRepository
+          .createQueryBuilder('uds')
+          .where('uds.account_id = :accountId', { accountId: account.id })
+          .andWhere('DATE(uds.record_date) = :d', { d: this.formatDate(new Date(currentDate.getTime() - 24 * 60 * 60 * 1000)) })
+          .getOne();
+
+        const dayFans = (dayUserStat?.fansCount || 0) - (prevDayUserStat?.fansCount || 0);
+
+        if (!dailyMap.has(dateStr)) {
+          dailyMap.set(dateStr, {
+            views: 0,
+            comments: 0,
+            likes: 0,
+            fansIncrease: 0,
+          });
+        }
+
+        const daily = dailyMap.get(dateStr)!;
+        daily.views += Math.max(0, dayViews);
+        daily.comments += Math.max(0, dayComments);
+        daily.likes += Math.max(0, dayLikes);
+        daily.fansIncrease += Math.max(0, dayFans);
+
+        currentDate.setDate(currentDate.getDate() + 1);
+      }
+
+      // 计算账号的总数据(使用 endDate 的数据)
+      const [endSums, startSums] = await Promise.all([
+        this.getWorkSumsAtDate(workIds, endDateStr),
+        this.getWorkSumsAtDate(workIds, startDateStr),
+      ]);
+
+      const accountViews = endSums.views - startSums.views;
+      const accountComments = endSums.comments - startSums.comments;
+      const accountLikes = endSums.likes - startSums.likes;
+
+      // 获取粉丝数据
+      const endUserStat = await this.userDayStatisticsRepository
+        .createQueryBuilder('uds')
+        .where('uds.account_id = :accountId', { accountId: account.id })
+        .andWhere('DATE(uds.record_date) <= :d', { d: endDateStr })
+        .orderBy('uds.record_date', 'DESC')
+        .getOne();
+
+      const startUserStat = await this.userDayStatisticsRepository
+        .createQueryBuilder('uds')
+        .where('uds.account_id = :accountId', { accountId: account.id })
+        .andWhere('DATE(uds.record_date) <= :d', { d: startDateStr })
+        .orderBy('uds.record_date', 'DESC')
+        .getOne();
+
+      const accountFansIncrease = (endUserStat?.fansCount || 0) - (startUserStat?.fansCount || 0);
+
+      // 获取更新时间
+      const latestUserStat = await this.userDayStatisticsRepository
+        .createQueryBuilder('uds')
+        .where('uds.account_id = :accountId', { accountId: account.id })
+        .andWhere('DATE(uds.record_date) >= :startDate', { startDate: startDateStr })
+        .andWhere('DATE(uds.record_date) <= :endDate', { endDate: endDateStr })
+        .orderBy('uds.updated_at', 'DESC')
+        .getOne();
+
+      const updateTime = latestUserStat?.updatedAt 
+        ? this.formatUpdateTime(latestUserStat.updatedAt)
+        : '';
+
+      accountList.push({
+        id: account.id,
+        nickname: account.accountName || '',
+        username: account.accountId || '',
+        avatarUrl: account.avatarUrl,
+        platform: account.platform,
+        income: null, // 收益数据需要从其他表获取
+        recommendationCount: null, // 推荐量(部分平台支持)
+        viewsCount: accountViews > 0 ? accountViews : null,
+        commentsCount: Math.max(0, accountComments),
+        likesCount: Math.max(0, accountLikes),
+        fansIncrease: Math.max(0, accountFansIncrease),
+        updateTime,
+      });
+
+      totalAccounts++;
+      totalViews += Math.max(0, accountViews);
+      totalComments += Math.max(0, accountComments);
+      totalLikes += Math.max(0, accountLikes);
+      totalFansIncrease += Math.max(0, accountFansIncrease);
+    }
+
+    // 转换为每日数据数组
+    const dailyData = Array.from(dailyMap.entries())
+      .map(([date, data]) => ({
+        date,
+        income: 0, // 收益数据需要从其他表获取
+        recommendationCount: null, // 推荐量(部分平台支持)
+        viewsCount: data.views,
+        commentsCount: data.comments,
+        likesCount: data.likes,
+        fansIncrease: data.fansIncrease,
+      }))
+      .sort((a, b) => a.date.localeCompare(b.date));
+
+    return {
+      summary: {
+        totalAccounts,
+        totalIncome: 0, // 收益数据需要从其他表获取
+        viewsCount: totalViews,
+        commentsCount: totalComments,
+        likesCount: totalLikes,
+        fansIncrease: totalFansIncrease,
+        recommendationCount: null, // 推荐量(部分平台支持)
+      },
+      dailyData,
+      accounts: accountList,
+    };
+  }
+
+  /**
+   * 格式化更新时间为 "MM-DD HH:mm" 格式
+   */
+  private formatUpdateTime(date: Date): string {
+    const month = String(date.getMonth() + 1).padStart(2, '0');
+    const day = String(date.getDate()).padStart(2, '0');
+    const hours = String(date.getHours()).padStart(2, '0');
+    const minutes = String(date.getMinutes()).padStart(2, '0');
+    return `${month}-${day} ${hours}:${minutes}`;
+  }
 }

+ 415 - 0
server/src/services/XiaohongshuAccountOverviewImportService.ts

@@ -0,0 +1,415 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { chromium, type Browser } from 'playwright';
+import * as XLSXNS from 'xlsx';
+import { AppDataSource, PlatformAccount } from '../models/index.js';
+import { BrowserManager } from '../automation/browser.js';
+import { logger } from '../utils/logger.js';
+import { UserDayStatisticsService } from './UserDayStatisticsService.js';
+import type { ProxyConfig } from '@media-manager/shared';
+import { WS_EVENTS } from '@media-manager/shared';
+import { wsManager } from '../websocket/index.js';
+
+// xlsx 在 ESM 下可能挂在 default 上;这里做一次兼容兜底
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const XLSX: any = (XLSXNS as any).default ?? (XLSXNS as any);
+
+type PlaywrightCookie = {
+  name: string;
+  value: string;
+  domain?: string;
+  path?: string;
+  url?: string;
+  expires?: number;
+  httpOnly?: boolean;
+  secure?: boolean;
+  sameSite?: 'Lax' | 'None' | 'Strict';
+};
+
+type MetricKind =
+  | 'playCount'
+  | 'coverClickRate'
+  | 'avgWatchDuration'
+  | 'totalWatchDuration'
+  | 'completionRate';
+
+function ensureDir(p: string) {
+  return fs.mkdir(p, { recursive: true });
+}
+
+function normalizeDateText(input: unknown): Date | null {
+  if (!input) return null;
+  if (input instanceof Date && !Number.isNaN(input.getTime())) {
+    const d = new Date(input);
+    d.setHours(0, 0, 0, 0);
+    return d;
+  }
+  const s = String(input).trim();
+  // 2026年01月27日
+  const m1 = s.match(/(\d{4})\D(\d{1,2})\D(\d{1,2})\D?/);
+  if (m1) {
+    const yyyy = Number(m1[1]);
+    const mm = Number(m1[2]);
+    const dd = Number(m1[3]);
+    if (!yyyy || !mm || !dd) return null;
+    const d = new Date(yyyy, mm - 1, dd);
+    d.setHours(0, 0, 0, 0);
+    return d;
+  }
+  // 01-27(兜底:用当前年份)
+  const m2 = s.match(/^(\d{1,2})[-/](\d{1,2})$/);
+  if (m2) {
+    const yyyy = new Date().getFullYear();
+    const mm = Number(m2[1]);
+    const dd = Number(m2[2]);
+    const d = new Date(yyyy, mm - 1, dd);
+    d.setHours(0, 0, 0, 0);
+    return d;
+  }
+  return null;
+}
+
+function parseChineseNumberLike(input: unknown): number | null {
+  if (input === null || input === undefined) return null;
+  const s = String(input).trim();
+  if (!s) return null;
+  // 8,077
+  const plain = s.replace(/,/g, '');
+  // 4.8万
+  const wan = plain.match(/^(\d+(\.\d+)?)\s*万$/);
+  if (wan) return Math.round(Number(wan[1]) * 10000);
+  const yi = plain.match(/^(\d+(\.\d+)?)\s*亿$/);
+  if (yi) return Math.round(Number(yi[1]) * 100000000);
+  const n = Number(plain.replace(/[^\d.-]/g, ''));
+  if (Number.isFinite(n)) return Math.round(n);
+  return null;
+}
+
+function detectMetricKind(sheetName: string): MetricKind | null {
+  const n = sheetName.trim();
+  // 小红书导出的子表命名可能是「观看趋势」或「观看数趋势」
+  if (n.includes('观看趋势') || n.includes('观看数')) return 'playCount';
+  if (n.includes('封面点击率')) return 'coverClickRate';
+  if (n.includes('平均观看时长')) return 'avgWatchDuration';
+  if (n.includes('观看总时长')) return 'totalWatchDuration';
+  if (n.includes('完播率')) return 'completionRate';
+  return null;
+}
+
+function parseCookiesFromAccount(cookieData: string | null): PlaywrightCookie[] {
+  if (!cookieData) return [];
+  const raw = cookieData.trim();
+  if (!raw) return [];
+
+  // 1) JSON array(最常见:浏览器插件导出/前端保存)
+  if (raw.startsWith('[') || raw.startsWith('{')) {
+    try {
+      const parsed = JSON.parse(raw);
+      const arr = Array.isArray(parsed) ? parsed : (parsed?.cookies ? parsed.cookies : []);
+      if (!Array.isArray(arr)) return [];
+      return arr
+        .map((c: any) => {
+          const name = String(c?.name ?? '').trim();
+          const value = String(c?.value ?? '').trim();
+          if (!name) return null;
+          const domain = c?.domain ? String(c.domain) : undefined;
+          const pathVal = c?.path ? String(c.path) : '/';
+          const url = !domain ? 'https://creator.xiaohongshu.com' : undefined;
+          const sameSiteRaw = c?.sameSite;
+          const sameSite =
+            sameSiteRaw === 'Lax' || sameSiteRaw === 'None' || sameSiteRaw === 'Strict'
+              ? sameSiteRaw
+              : undefined;
+
+          return {
+            name,
+            value,
+            domain,
+            path: pathVal,
+            url,
+            expires: typeof c?.expires === 'number' ? c.expires : undefined,
+            httpOnly: typeof c?.httpOnly === 'boolean' ? c.httpOnly : undefined,
+            secure: typeof c?.secure === 'boolean' ? c.secure : undefined,
+            sameSite,
+          } satisfies PlaywrightCookie;
+        })
+        .filter(Boolean) as PlaywrightCookie[];
+    } catch {
+      // fallthrough
+    }
+  }
+
+  // 2) "a=b; c=d" 拼接格式
+  const pairs = raw.split(';').map((p) => p.trim()).filter(Boolean);
+  const cookies: PlaywrightCookie[] = [];
+  for (const p of pairs) {
+    const idx = p.indexOf('=');
+    if (idx <= 0) continue;
+    const name = p.slice(0, idx).trim();
+    const value = p.slice(idx + 1).trim();
+    if (!name) continue;
+    cookies.push({ name, value, url: 'https://creator.xiaohongshu.com' });
+  }
+  return cookies;
+}
+
+async function createBrowserForAccount(proxy: ProxyConfig | null): Promise<{ browser: Browser; shouldClose: boolean }> {
+  const forceHeadful = process.env.XHS_IMPORT_HEADLESS === '0';
+  if (proxy?.enabled) {
+    const server = `${proxy.type}://${proxy.host}:${proxy.port}`;
+    const browser = await chromium.launch({
+      headless: !forceHeadful,
+      proxy: {
+        server,
+        username: proxy.username,
+        password: proxy.password,
+      },
+      args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--window-size=1920,1080'],
+    });
+    return { browser, shouldClose: true };
+  }
+  const browser = await BrowserManager.getBrowser({ headless: !forceHeadful });
+  return { browser, shouldClose: false };
+}
+
+function parseXhsExcel(filePath: string): Map<string, { recordDate: Date } & Record<string, any>> {
+  const wb = XLSX.readFile(filePath);
+  const result = new Map<string, { recordDate: Date } & Record<string, any>>();
+
+  logger.info(`[XHS Import] Excel loaded. file=${path.basename(filePath)} sheets=${wb.SheetNames.join(' | ')}`);
+
+  for (const sheetName of wb.SheetNames) {
+    const kind = detectMetricKind(sheetName);
+    if (!kind) continue;
+    const sheet = wb.Sheets[sheetName];
+    const rows = XLSX.utils.sheet_to_json<Record<string, any>>(sheet, { defval: '' });
+
+    if (rows.length) {
+      const keys = Object.keys(rows[0] || {});
+      logger.info(`[XHS Import] Sheet parsed. name=${sheetName} kind=${kind} rows=${rows.length} keys=${keys.join(',')}`);
+    } else {
+      logger.warn(`[XHS Import] Sheet empty. name=${sheetName} kind=${kind}`);
+    }
+
+    for (const row of rows) {
+      const dateVal = row['日期'] ?? row['date'] ?? row['Date'] ?? row[Object.keys(row)[0] ?? ''];
+      const valueVal = row['数值'] ?? row['value'] ?? row['Value'] ?? row[Object.keys(row)[1] ?? ''];
+      const d = normalizeDateText(dateVal);
+      if (!d) continue;
+      const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
+      if (!result.has(key)) result.set(key, { recordDate: d });
+      const obj = result.get(key)!;
+
+      if (kind === 'playCount') {
+        const n = parseChineseNumberLike(valueVal);
+        if (typeof n === 'number') obj.playCount = n;
+      } else {
+        const s = String(valueVal ?? '').trim();
+        if (kind === 'coverClickRate') obj.coverClickRate = s || '0';
+        if (kind === 'avgWatchDuration') obj.avgWatchDuration = s || '0';
+        if (kind === 'totalWatchDuration') obj.totalWatchDuration = s || '0';
+        if (kind === 'completionRate') obj.completionRate = s || '0';
+      }
+    }
+  }
+
+  return result;
+}
+
+export class XiaohongshuAccountOverviewImportService {
+  private accountRepository = AppDataSource.getRepository(PlatformAccount);
+  private userDayStatisticsService = new UserDayStatisticsService();
+
+  private downloadDir = path.resolve(process.cwd(), 'tmp', 'xhs-account-overview');
+  private stateDir = path.resolve(process.cwd(), 'tmp', 'xhs-storage-state');
+
+  private getStatePath(accountId: number) {
+    return path.join(this.stateDir, `${accountId}.json`);
+  }
+
+  private async ensureStorageState(account: PlatformAccount, cookies: PlaywrightCookie[]): Promise<string | null> {
+    const statePath = this.getStatePath(account.id);
+    try {
+      await fs.access(statePath);
+      return statePath;
+    } catch {
+      // no state
+    }
+
+    // 需要你在弹出的浏览器里完成一次登录/验证,然后脚本会自动保存 storageState
+    // 启用方式:XHS_IMPORT_HEADLESS=0 且 XHS_STORAGE_STATE_BOOTSTRAP=1
+    if (!(process.env.XHS_IMPORT_HEADLESS === '0' && process.env.XHS_STORAGE_STATE_BOOTSTRAP === '1')) {
+      return null;
+    }
+
+    await ensureDir(this.stateDir);
+    logger.warn(`[XHS Import] No storageState for accountId=${account.id}. Bootstrapping... 请在弹出的浏览器中完成登录/验证。`);
+
+    const { browser, shouldClose } = await createBrowserForAccount(account.proxyConfig);
+    try {
+      const context = await browser.newContext({
+        viewport: { width: 1920, height: 1080 },
+        locale: 'zh-CN',
+        timezoneId: 'Asia/Shanghai',
+      });
+      await context.addCookies(cookies as any);
+      const page = await context.newPage();
+      await page.goto('https://creator.xiaohongshu.com/statistics/account/v2', { waitUntil: 'domcontentloaded' });
+
+      // 最长等 5 分钟:让你手动完成登录/滑块/短信等
+      await page
+        .waitForFunction(() => {
+          const t = document.body?.innerText || '';
+          return t.includes('账号概览') || t.includes('数据总览') || t.includes('观看数据');
+        }, { timeout: 5 * 60_000 })
+        .catch(() => undefined);
+
+      await context.storageState({ path: statePath });
+      logger.info(`[XHS Import] storageState saved: ${statePath}`);
+      await context.close();
+      return statePath;
+    } finally {
+      if (shouldClose) await browser.close().catch(() => undefined);
+    }
+  }
+
+  /**
+   * 为所有小红书账号导出“观看数据-近30日”并导入 user_day_statistics
+   */
+  async runDailyImportForAllXhsAccounts(): Promise<void> {
+    await ensureDir(this.downloadDir);
+
+    const accounts = await this.accountRepository.find({
+      where: { platform: 'xiaohongshu' as any },
+    });
+
+    logger.info(`[XHS Import] Start. total_accounts=${accounts.length}`);
+
+    for (const account of accounts) {
+      try {
+        await this.importAccountLast30Days(account);
+      } catch (e) {
+        logger.error(`[XHS Import] Account failed. accountId=${account.id} name=${account.accountName || ''}`, e);
+      }
+    }
+
+    logger.info('[XHS Import] Done.');
+  }
+
+  /**
+   * 单账号:导出 Excel → 解析 → 入库 → 删除文件
+   */
+  async importAccountLast30Days(account: PlatformAccount): Promise<void> {
+    const cookies = parseCookiesFromAccount(account.cookieData);
+    if (!cookies.length) {
+      throw new Error('cookieData 为空或无法解析');
+    }
+
+    const { browser, shouldClose } = await createBrowserForAccount(account.proxyConfig);
+    try {
+      const statePath = await this.ensureStorageState(account, cookies);
+      const context = await browser.newContext({
+        acceptDownloads: true,
+        viewport: { width: 1920, height: 1080 },
+        locale: 'zh-CN',
+        timezoneId: 'Asia/Shanghai',
+        userAgent:
+          'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
+        ...(statePath ? { storageState: statePath } : {}),
+      });
+      context.setDefaultTimeout(60_000);
+      // 如果没 state,就退回 cookie-only(可能导出为 0)
+      if (!statePath) {
+        await context.addCookies(cookies as any);
+      }
+
+      const page = await context.newPage();
+      await page.goto('https://creator.xiaohongshu.com/statistics/account/v2', { waitUntil: 'domcontentloaded' });
+      await page.waitForTimeout(1500);
+
+      if (page.url().includes('login')) {
+        throw new Error('未登录/需要重新登录(跳转到 login)');
+      }
+
+      // 检测“暂无访问权限 / 权限申请中”提示:标记账号 expired + 推送提示
+      const bodyText = (await page.textContent('body').catch(() => '')) || '';
+      if (bodyText.includes('暂无访问权限') || bodyText.includes('数据权限申请中') || bodyText.includes('次日再来查看')) {
+        await this.accountRepository.update(account.id, { status: 'expired' as any });
+        wsManager.sendToUser(account.userId, WS_EVENTS.ACCOUNT_UPDATED, {
+          account: { id: account.id, status: 'expired', platform: 'xiaohongshu' },
+        });
+        wsManager.sendToUser(account.userId, WS_EVENTS.SYSTEM_MESSAGE, {
+          level: 'warning',
+          message: `小红书账号「${account.accountName || account.accountId || account.id}」暂无数据看板访问权限,请到小红书创作服务平台申请数据权限(通过后一般次日生效)。`,
+          platform: 'xiaohongshu',
+          accountId: account.id,
+        });
+        throw new Error('小红书数据看板暂无访问权限/申请中,已标记 expired 并通知用户');
+      }
+
+      // 尽量按用户描述进入:数据看板 -> 账号概览 -> 笔记数据 -> 观看数据 -> 近30日
+      // 页面结构可能会变,这里用“文本定位 + 容错”策略
+      await page.getByText('账号概览', { exact: true }).first().click().catch(() => undefined);
+      await page.getByText('笔记数据', { exact: true }).first().click();
+      await page.getByText('观看数据', { exact: true }).first().click();
+
+      // 选择近30日:先点开时间范围,再点“近30日”
+      await page.getByText(/近\d+日/).first().click().catch(() => undefined);
+      await page.getByText('近30日', { exact: true }).click();
+
+      // 等待数据刷新完成(避免导出到全 0)
+      // 以页面上“观看数”卡片出现非 0 数字作为信号(页面文本会包含类似 8,077 / 4.8万)
+      await page
+        .waitForFunction(() => {
+          const t = document.body?.innerText || '';
+          if (!t.includes('观看数')) return false;
+          // 匹配“观看数”后出现非 0 的数值(允许逗号/万/亿)
+          return /观看数[\s\S]{0,50}([1-9]\d{0,2}(,\d{3})+|[1-9]\d*|[1-9]\d*(\.\d+)?\s*[万亿])/.test(t);
+        }, { timeout: 30_000 })
+        .catch(() => {
+          logger.warn('[XHS Import] Wait for non-zero watch count timed out. Continue export anyway.');
+        });
+
+      // 导出数据
+      const [download] = await Promise.all([
+        page.waitForEvent('download', { timeout: 60_000 }),
+        page.getByText('导出数据', { exact: true }).first().click(),
+      ]);
+
+      const filename = `${account.id}_${Date.now()}_${download.suggestedFilename()}`;
+      const filePath = path.join(this.downloadDir, filename);
+      await download.saveAs(filePath);
+
+      // 解析并入库
+      const perDay = parseXhsExcel(filePath);
+      let inserted = 0;
+      let updated = 0;
+
+      // 每天一条:accountId + date
+      for (const v of perDay.values()) {
+        const { recordDate, ...patch } = v;
+        const r = await this.userDayStatisticsService.saveStatisticsForDate(account.id, recordDate, patch);
+        inserted += r.inserted;
+        updated += r.updated;
+      }
+
+      // 删除 Excel(默认删除;设置 KEEP_XHS_XLSX=1 可保留用于排查)
+      if (process.env.KEEP_XHS_XLSX === '1') {
+        logger.warn(`[XHS Import] KEEP_XHS_XLSX=1, keep file: ${filePath}`);
+      } else {
+        await fs.unlink(filePath).catch(() => undefined);
+      }
+
+      logger.info(
+        `[XHS Import] Account done. accountId=${account.id} days=${perDay.size} inserted=${inserted} updated=${updated}`
+      );
+
+      await context.close();
+    } finally {
+      if (shouldClose) {
+        await browser.close().catch(() => undefined);
+      }
+    }
+  }
+}
+

BIN
server/tmp/xhs-account-overview/35_1769581578665_近30日观看数据.xlsx


BIN
server/tmp/xhs-account-overview/35_1769582575166_近30日观看数据.xlsx


BIN
server/tmp/xhs-account-overview/35_1769582795460_近30日观看数据.xlsx


BIN
server/tmp/xhs-account-overview/35_1769582877553_近30日观看数据.xlsx


BIN
server/tmp/xhs-account-overview/8_1769581512725_近30日观看数据.xlsx


BIN
server/tmp/xhs-account-overview/8_1769582508941_近30日观看数据.xlsx


BIN
server/tmp/xhs-account-overview/8_1769582729004_近30日观看数据.xlsx


BIN
server/tmp/xhs-account-overview/8_1769582810145_近30日观看数据.xlsx


+ 217 - 0
server/tmp/xhs-storage-state/27.json

@@ -0,0 +1,217 @@
+{
+  "cookies": [
+    {
+      "name": "acw_tc",
+      "value": "0a0d0f5817691661344898475eaee510b7f57c7a50bb06c4df45f671680961",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "xsecappid",
+      "value": "ugc",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "a1",
+      "value": "19bea84dff27bsps7dyz5rjxw97292u6338m3lp1t50000235090",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "webId",
+      "value": "3c869eff8b341c1e43cda187047be879",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "websectiga",
+      "value": "2a3d3ea002e7d92b5c9743590ebd24010cf3710ff3af8029153751e41a6af4a3",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "sec_poison_id",
+      "value": "7d4ede13-d1c7-40cd-b29a-07204c3287d4",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "gid",
+      "value": "yjDd0Y4dS0qKyjDd0Y4fiq1UiJWDMUMWfvu2Yy9FVCEjW628JjJ7Kq888Jq28j88i02YdYYK",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "customer-sso-sid",
+      "value": "68c517598510721245413377elpyhkeg23gqhrb1",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "x-user-id-creator.xiaohongshu.com",
+      "value": "696f0507000000003700b981",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "customerClientId",
+      "value": "290717865130903",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "access-token-creator.xiaohongshu.com",
+      "value": "customer.creator.AT-68c517598510721245446144fbga1rt98vtvt2g3",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "galaxy_creator_session_id",
+      "value": "ideack4kksRYV933LnZ8JyOfVQCZBSZoRHss",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "galaxy.creator.beaker.session.id",
+      "value": "1769166142124080314919",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "loadts",
+      "value": "1769166142431",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "xsecappid",
+      "value": "ugc",
+      "domain": ".xiaohongshu.com",
+      "path": "/",
+      "expires": 1801119730,
+      "httpOnly": false,
+      "secure": false,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "loadts",
+      "value": "1769583730022",
+      "domain": ".xiaohongshu.com",
+      "path": "/",
+      "expires": 1801119730,
+      "httpOnly": false,
+      "secure": false,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "acw_tc",
+      "value": "0a0bb3d817695837287657777ed1eeb1e39c770ea362c254f6566e49ff994d",
+      "domain": "edith.xiaohongshu.com",
+      "path": "/",
+      "expires": 1769585530.153933,
+      "httpOnly": true,
+      "secure": false,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "websectiga",
+      "value": "9730ffafd99f2d09dc024760e253af6ab1feb0002827740b95a255ddf6847fc8",
+      "domain": ".xiaohongshu.com",
+      "path": "/",
+      "expires": 1769842930,
+      "httpOnly": false,
+      "secure": false,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "sec_poison_id",
+      "value": "8a3ce85c-3aa2-41bc-a5fa-1db6eae9e61f",
+      "domain": ".xiaohongshu.com",
+      "path": "/",
+      "expires": 1769584335,
+      "httpOnly": false,
+      "secure": false,
+      "sameSite": "Lax"
+    }
+  ],
+  "origins": [
+    {
+      "origin": "https://creator.xiaohongshu.com",
+      "localStorage": [
+        {
+          "name": "USER_INFO_FOR_BIZ",
+          "value": "{\"userId\":\"696f0507000000003700b981\",\"userName\":\"AAA珊珊\",\"userAvatar\":\"https://sns-avatar-qc.xhscdn.com/avatar/1040g2jo31ri3smm27o5g5qbf0k3tpec13vcptr0?imageView2/2/w/80/format/jpg\",\"redId\":\"63535021536\",\"role\":\"creator\",\"permissions\":[\"creatorCollege\",\"creatorWiki\",\"noteInspiration\",\"creatorHome\",\"creatorData\",\"USER_GROUP\",\"creatorActivityCenter\",\"ORIGINAL_STATEMENT\"],\"zone\":\"86\",\"phone\":\"13291570902\",\"relatedUserId\":null,\"relatedUserName\":null,\"kolCoOrder\":false}"
+        },
+        {
+          "name": "sdt_source_storage_key",
+          "value": "{\"reportUrl\":\"/api/sec/v1/shield/webprofile\",\"validate\":false,\"xhsTokenUrl\":\"https://fe-static.xhscdn.com/as/v1/3e44/public/bf7d4e32677698655a5cadc581fd09b3.js\",\"url\":\"https://fe-static.xhscdn.com/as/v2/fp/962356ead351e7f2422eb57edff6982d.js\",\"desVersion\":\"2\",\"commonPatch\":[\"/fe_api/burdock/v2/note/post\",\"/api/sns/web/v1/comment/post\",\"/api/sns/web/v1/note/like\",\"/api/sns/web/v1/note/collect\",\"/api/sns/web/v1/user/follow\",\"/api/sns/web/v1/feed\",\"/api/sns/web/v1/login/activate\",\"/api/sns/web/v1/note/metrics_report\",\"/api/redcaptcha\",\"/api/store/jpd/main\",\"/phoenix/api/strategy/getAppStrategy\",\"/web_api/sns/v2/note\"],\"signUrl\":\"https://fe-static.xhscdn.com/as/v1/f218/a15/public/04b29480233f4def5c875875b6bdc3b1.js\",\"signVersion\":\"1\",\"extraInfo\":{}}"
+        },
+        {
+          "name": "last_tiga_update_time",
+          "value": "1769583730268"
+        },
+        {
+          "name": "USER_INFO",
+          "value": "{\"user\":{\"type\":\"User\",\"value\":{\"userId\":\"696f0507000000003700b981\",\"loginUserType\":\"creator\"}}}"
+        }
+      ]
+    }
+  ]
+}

+ 217 - 0
server/tmp/xhs-storage-state/35.json

@@ -0,0 +1,217 @@
+{
+  "cookies": [
+    {
+      "name": "acw_tc",
+      "value": "0a0d0f5817695646807333478e72be7316a4ca83c7273a7b134b389347de9c",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "xsecappid",
+      "value": "ugc",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "a1",
+      "value": "19c02463a16q8eko3iw1upblp4a13975orts9w7pq50000309711",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "webId",
+      "value": "ab5986fdd6530d8910e1282113eefb29",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "websectiga",
+      "value": "3633fe24d49c7dd0eb923edc8205740f10fdb18b25d424d2a2322c6196d2a4ad",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "sec_poison_id",
+      "value": "3c829439-7646-4ad1-b27d-55b34f5cad9e",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "gid",
+      "value": "yjS8J4K4JJljyjS8J4Kq0FEWyKAYdTlq3Ey79UAUD1U40M28yqjW2j888q8jWyy8fJ4j4qY8",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "customer-sso-sid",
+      "value": "68c5176002225019360706639secsficwc3ppzy1",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "x-user-id-creator.xiaohongshu.com",
+      "value": "589bd3a382ec39358dfb620d",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "customerClientId",
+      "value": "917980021132864",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "access-token-creator.xiaohongshu.com",
+      "value": "customer.creator.AT-68c517600222501936054280zi9ztquscvh1khh9",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "galaxy_creator_session_id",
+      "value": "qEiTTI7u6zkUQedQw9Fxk93NK22SKqrvaWvu",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "galaxy.creator.beaker.session.id",
+      "value": "1769564697861001435163",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "loadts",
+      "value": "1769564699931",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "xsecappid",
+      "value": "ugc",
+      "domain": ".xiaohongshu.com",
+      "path": "/",
+      "expires": 1801119794,
+      "httpOnly": false,
+      "secure": false,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "loadts",
+      "value": "1769583794720",
+      "domain": ".xiaohongshu.com",
+      "path": "/",
+      "expires": 1801119794,
+      "httpOnly": false,
+      "secure": false,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "acw_tc",
+      "value": "0a4a90e117695837934832919e63fc0b47b022c15cab7a2fcb552ed7529978",
+      "domain": "edith.xiaohongshu.com",
+      "path": "/",
+      "expires": 1769585594.871039,
+      "httpOnly": true,
+      "secure": false,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "websectiga",
+      "value": "a9bdcaed0af874f3a1431e94fbea410e8f738542fbb02df4e8e30c29ef3d91ac",
+      "domain": ".xiaohongshu.com",
+      "path": "/",
+      "expires": 1769842995,
+      "httpOnly": false,
+      "secure": false,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "sec_poison_id",
+      "value": "e04f9cdc-a552-412d-aee3-adfd97fa37b9",
+      "domain": ".xiaohongshu.com",
+      "path": "/",
+      "expires": 1769584399,
+      "httpOnly": false,
+      "secure": false,
+      "sameSite": "Lax"
+    }
+  ],
+  "origins": [
+    {
+      "origin": "https://creator.xiaohongshu.com",
+      "localStorage": [
+        {
+          "name": "USER_INFO_FOR_BIZ",
+          "value": "{\"userId\":\"589bd3a382ec39358dfb620d\",\"userName\":\"O_O点心时间到了\",\"userAvatar\":\"https://sns-avatar-qc.xhscdn.com/avatar/1040g2jo31p181vi62g3g4949q09q6ogd8algagg?imageView2/2/w/80/format/jpg\",\"redId\":\"495513171\",\"role\":\"creator\",\"permissions\":[\"creatorCollege\",\"creatorWiki\",\"noteInspiration\",\"creatorHome\",\"creatorData\",\"USER_GROUP\",\"creatorActivityCenter\",\"ORIGINAL_STATEMENT\"],\"zone\":\"86\",\"phone\":\"17816869332\",\"relatedUserId\":null,\"relatedUserName\":null,\"kolCoOrder\":false}"
+        },
+        {
+          "name": "sdt_source_storage_key",
+          "value": "{\"xhsTokenUrl\":\"https://fe-static.xhscdn.com/as/v1/3e44/public/bf7d4e32677698655a5cadc581fd09b3.js\",\"url\":\"https://fe-static.xhscdn.com/as/v2/fp/962356ead351e7f2422eb57edff6982d.js\",\"reportUrl\":\"/api/sec/v1/shield/webprofile\",\"validate\":false,\"signUrl\":\"https://fe-static.xhscdn.com/as/v1/f218/a15/public/04b29480233f4def5c875875b6bdc3b1.js\",\"signVersion\":\"1\",\"desVersion\":\"2\",\"commonPatch\":[\"/fe_api/burdock/v2/note/post\",\"/api/sns/web/v1/comment/post\",\"/api/sns/web/v1/note/like\",\"/api/sns/web/v1/note/collect\",\"/api/sns/web/v1/user/follow\",\"/api/sns/web/v1/feed\",\"/api/sns/web/v1/login/activate\",\"/api/sns/web/v1/note/metrics_report\",\"/api/redcaptcha\",\"/api/store/jpd/main\",\"/phoenix/api/strategy/getAppStrategy\",\"/web_api/sns/v2/note\"],\"extraInfo\":{}}"
+        },
+        {
+          "name": "last_tiga_update_time",
+          "value": "1769583795021"
+        },
+        {
+          "name": "USER_INFO",
+          "value": "{\"user\":{\"type\":\"User\",\"value\":{\"userId\":\"589bd3a382ec39358dfb620d\",\"loginUserType\":\"creator\"}}}"
+        }
+      ]
+    }
+  ]
+}

+ 217 - 0
server/tmp/xhs-storage-state/8.json

@@ -0,0 +1,217 @@
+{
+  "cookies": [
+    {
+      "name": "acw_tc",
+      "value": "0a0d0eb817691471764448368e0e506848cad6aec5bfc6d3a66e13d29f32bb",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "xsecappid",
+      "value": "ugc",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "a1",
+      "value": "19be9639d548dhr416h17hxqpcc8yyakxj72hfm1j50000262447",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "webId",
+      "value": "182621f552172c438385e752076aed97",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "websectiga",
+      "value": "2845367ec3848418062e761c09db7caf0e8b79d132ccdd1a4f8e64a11d0cac0d",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "sec_poison_id",
+      "value": "aa55ccd1-86cc-4a6c-846a-5056684b7af7",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "gid",
+      "value": "yjDdjKq0WJC2yjDdjKqjfVi624YfxF4yKxyWWyVxCAUSSJ28Yvv0Tx888JKJ44W8WD4SJD4K",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "customer-sso-sid",
+      "value": "68c517598429314435284995vtxm6kocj8uqdejh",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "x-user-id-creator.xiaohongshu.com",
+      "value": "5b449eed11be1020088631cb",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "customerClientId",
+      "value": "358692757759470",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "access-token-creator.xiaohongshu.com",
+      "value": "customer.creator.AT-68c517598429314435284996uba3cs21hipegivz",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "galaxy_creator_session_id",
+      "value": "Fal8ifXYxpdgwynFYU35ZL49QiWQIOnJvPNd",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "galaxy.creator.beaker.session.id",
+      "value": "1769147188280030693439",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "loadts",
+      "value": "1769147190381",
+      "domain": "creator.xiaohongshu.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "xsecappid",
+      "value": "ugc",
+      "domain": ".xiaohongshu.com",
+      "path": "/",
+      "expires": 1801119723,
+      "httpOnly": false,
+      "secure": false,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "loadts",
+      "value": "1769583723435",
+      "domain": ".xiaohongshu.com",
+      "path": "/",
+      "expires": 1801119723,
+      "httpOnly": false,
+      "secure": false,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "acw_tc",
+      "value": "0a8f07a017695837221766935e8dc375f4ca2bf877835c4c075349ef60fdef",
+      "domain": "edith.xiaohongshu.com",
+      "path": "/",
+      "expires": 1769585523.571444,
+      "httpOnly": true,
+      "secure": false,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "websectiga",
+      "value": "7750c37de43b7be9de8ed9ff8ea0e576519e8cd2157322eb:72ecb429a7735d4",
+      "domain": ".xiaohongshu.com",
+      "path": "/",
+      "expires": 1769842923,
+      "httpOnly": false,
+      "secure": false,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "sec_poison_id",
+      "value": "6b63845f-b53f-451a-8d88-5f7e97f4056f",
+      "domain": ".xiaohongshu.com",
+      "path": "/",
+      "expires": 1769584328,
+      "httpOnly": false,
+      "secure": false,
+      "sameSite": "Lax"
+    }
+  ],
+  "origins": [
+    {
+      "origin": "https://creator.xiaohongshu.com",
+      "localStorage": [
+        {
+          "name": "USER_INFO_FOR_BIZ",
+          "value": "{\"userId\":\"5b449eed11be1020088631cb\",\"userName\":\"姬若水\",\"userAvatar\":\"https://sns-avatar-qc.xhscdn.com/avatar/62859f27751189cd8f0ac186.jpg?imageView2/2/w/80/format/jpg\",\"redId\":\"188071523\",\"role\":\"creator\",\"permissions\":[\"creatorCollege\",\"creatorWiki\",\"noteInspiration\",\"creatorHome\",\"creatorData\",\"USER_GROUP\",\"creatorActivityCenter\",\"ORIGINAL_STATEMENT\"],\"zone\":\"86\",\"phone\":\"15968358021\",\"relatedUserId\":null,\"relatedUserName\":null,\"kolCoOrder\":false}"
+        },
+        {
+          "name": "sdt_source_storage_key",
+          "value": "{\"desVersion\":\"2\",\"signVersion\":\"1\",\"xhsTokenUrl\":\"https://fe-static.xhscdn.com/as/v1/3e44/public/bf7d4e32677698655a5cadc581fd09b3.js\",\"commonPatch\":[\"/fe_api/burdock/v2/note/post\",\"/api/sns/web/v1/comment/post\",\"/api/sns/web/v1/note/like\",\"/api/sns/web/v1/note/collect\",\"/api/sns/web/v1/user/follow\",\"/api/sns/web/v1/feed\",\"/api/sns/web/v1/login/activate\",\"/api/sns/web/v1/note/metrics_report\",\"/api/redcaptcha\",\"/api/store/jpd/main\",\"/phoenix/api/strategy/getAppStrategy\",\"/web_api/sns/v2/note\"],\"signUrl\":\"https://fe-static.xhscdn.com/as/v1/f218/a15/public/04b29480233f4def5c875875b6bdc3b1.js\",\"extraInfo\":{},\"url\":\"https://fe-static.xhscdn.com/as/v2/fp/962356ead351e7f2422eb57edff6982d.js\",\"reportUrl\":\"/api/sec/v1/shield/webprofile\",\"validate\":false}"
+        },
+        {
+          "name": "last_tiga_update_time",
+          "value": "1769583723773"
+        },
+        {
+          "name": "USER_INFO",
+          "value": "{\"user\":{\"type\":\"User\",\"value\":{\"userId\":\"5b449eed11be1020088631cb\",\"loginUserType\":\"creator\"}}}"
+        }
+      ]
+    }
+  ]
+}

+ 2 - 0
shared/src/constants/api.ts

@@ -126,6 +126,8 @@ export const WS_EVENTS = {
   COMMENT_SYNC_FAILED: 'comment:sync_failed',
   // 数据
   ANALYTICS_UPDATED: 'analytics:updated',
+  // 系统通知(toast / 提示)
+  SYSTEM_MESSAGE: 'system:message',
   // 心跳
   PING: 'ping',
   PONG: 'pong',