Przeglądaj źródła

Merge branch 'main' of http://gitlab.pubdata.cn/hlm/multi-platform-media-manage

Ethanfly 17 godzin temu
rodzic
commit
09002cf45b

+ 17 - 2
.gitignore

@@ -1,4 +1,4 @@
-# Dependencies
+# Dependencies
 node_modules/
 .pnpm-store/
 
@@ -47,4 +47,19 @@ playwright-report/
 test-results/
 
 # Matrix
-matrix/
+matrix/
+
+# Python 虚拟环境与缓存(仅本地,不上传)
+server/python/venv/
+server/python/.venv/
+**/venv/
+**/.venv/
+server/python/__pycache__/
+server/python/**/__pycache__/
+*.py[cod]
+*$py.class
+*.pyo
+.pytest_cache/
+.mypy_cache/
+*.egg-info/
+*.egg

+ 20 - 24
client/src/api/dashboard.ts

@@ -14,24 +14,27 @@ export interface CommentsStats {
   todayCount: number;
 }
 
+// 单个平台的趋势数据
+export interface PlatformTrendItem {
+  platform: string;       // 平台标识
+  platformName: string;   // 平台中文名
+  fansIncrease: number[]; // 涨粉数
+  views: number[];        // 播放数
+  likes: number[];        // 点赞数
+  comments: number[];     // 评论数
+}
+
+// 趋势数据(按平台分组)
 export interface TrendData {
-  dates: string[];
-  fans: number[];
-  views: number[];
-  likes: number[];
-  comments: number[];
-  shares: number[];
-  collects: number[];
+  dates: string[];                  // 日期数组
+  platforms: PlatformTrendItem[];   // 各平台数据
 }
 
 export interface TrendParams {
-  userId: number;
   days?: number;
   accountId?: number;
 }
 
-const PYTHON_API_URL = 'http://localhost:5005';
-
 export const dashboardApi = {
   // 获取作品统计
   getWorksStats(): Promise<WorksStats> {
@@ -43,23 +46,16 @@ export const dashboardApi = {
     return request.get('/api/comments/stats');
   },
 
-  // 获取数据趋势(调用 Python API)
+  // 获取数据趋势(通过 Node 转发调用 Python API)
   async getTrend(params: TrendParams): Promise<TrendData> {
-    const queryParams = new URLSearchParams({
-      user_id: params.userId.toString(),
-      days: (params.days || 7).toString(),
-    });
+    const queryParams: Record<string, string> = {
+      days: (params.days || 30).toString(),
+    };
     if (params.accountId) {
-      queryParams.append('account_id', params.accountId.toString());
-    }
-    
-    const response = await fetch(`${PYTHON_API_URL}/work_day_statistics/trend?${queryParams}`);
-    const result = await response.json();
-    
-    if (!result.success) {
-      throw new Error(result.error || '获取数据趋势失败');
+      queryParams.account_id = params.accountId.toString();
     }
     
-    return result.data;
+    const result = await request.get('/api/dashboard/trend', { params: queryParams });
+    return result as TrendData;
   },
 };

+ 113 - 88
client/src/views/Analytics/index.vue

@@ -35,7 +35,7 @@
       <div class="card-header">
         <h3>数据趋势</h3>
         <el-radio-group v-model="trendMetric" size="small" @change="updateTrendChart">
-          <el-radio-button label="fans">粉</el-radio-button>
+          <el-radio-button label="fansIncrease">粉</el-radio-button>
           <el-radio-button label="views">播放</el-radio-button>
           <el-radio-button label="likes">点赞</el-radio-button>
           <el-radio-button label="comments">评论</el-radio-button>
@@ -82,15 +82,20 @@ import { useAuthStore } from '@/stores/auth';
 import dayjs from 'dayjs';
 import request from '@/api/request';
 
-// 趋势数据类型
-interface TrendData {
-  dates: string[];
-  fans: number[];
+// 单个平台的趋势数据
+interface PlatformTrendItem {
+  platform: string;
+  platformName: string;
+  fansIncrease: number[];
   views: number[];
   likes: number[];
   comments: number[];
-  shares: number[];
-  collects: number[];
+}
+
+// 趋势数据类型
+interface TrendData {
+  dates: string[];
+  platforms: PlatformTrendItem[];
 }
 
 const authStore = useAuthStore();
@@ -117,56 +122,41 @@ const dateRange = ref<[Date, Date]>([
   dayjs().toDate(),
 ]);
 
-const trendMetric = ref<'fans' | 'views' | 'likes' | 'comments'>('fans');
+const trendMetric = ref<'fansIncrease' | 'views' | 'likes' | 'comments'>('fansIncrease');
 const trendData = ref<TrendData | null>(null);
 const platformComparison = ref<PlatformComparison[]>([]);
 
 // 从趋势数据计算统计摘要
-// 注意:数据库存储的是累积值,所以区间增量 = 最后一天 - 第一天
+// 口径变更:user_day_statistics 存储的是每日单独值,区间汇总需要直接 SUM
 const summaryItems = computed(() => {
   const data = trendData.value;
-  if (!data || data.dates.length === 0) {
+  if (!data || data.platforms.length === 0) {
     return [
-      { label: '总粉丝', value: 0 },
-      { label: '粉丝增量', value: 0 },
-      { label: '播放增量', value: 0 },
-      { label: '点赞增量', value: 0 },
-      { label: '评论增量', value: 0 },
-      { label: '收藏增量', value: 0 },
+      { label: '涨粉总计', value: 0 },
+      { label: '播放总计', value: 0 },
+      { label: '点赞总计', value: 0 },
+      { label: '评论总计', value: 0 },
     ];
   }
   
-  const len = data.fans.length;
-  
-  // 取最后一天的粉丝数作为当前粉丝数
-  const latestFans = data.fans[len - 1] || 0;
-  // 取第一天的值
-  const firstFans = data.fans[0] || 0;
-  const firstViews = data.views[0] || 0;
-  const firstLikes = data.likes[0] || 0;
-  const firstComments = data.comments[0] || 0;
-  const firstCollects = data.collects[0] || 0;
+  // 计算所有平台的汇总
+  let totalFansIncrease = 0;
+  let totalViews = 0;
+  let totalLikes = 0;
+  let totalComments = 0;
   
-  // 取最后一天的值
-  const lastViews = data.views[len - 1] || 0;
-  const lastLikes = data.likes[len - 1] || 0;
-  const lastComments = data.comments[len - 1] || 0;
-  const lastCollects = data.collects[len - 1] || 0;
-  
-  // 计算区间增量(累积值:最后一天 - 第一天)
-  const fansIncrease = latestFans - firstFans;
-  const viewsIncrease = lastViews - firstViews;
-  const likesIncrease = lastLikes - firstLikes;
-  const commentsIncrease = lastComments - firstComments;
-  const collectsIncrease = lastCollects - firstCollects;
+  for (const platform of data.platforms) {
+    totalFansIncrease += platform.fansIncrease.reduce((sum, v) => sum + v, 0);
+    totalViews += platform.views.reduce((sum, v) => sum + v, 0);
+    totalLikes += platform.likes.reduce((sum, v) => sum + v, 0);
+    totalComments += platform.comments.reduce((sum, v) => sum + v, 0);
+  }
   
   return [
-    { label: '总粉丝', value: latestFans },
-    { label: '粉丝增量', value: fansIncrease },
-    { label: '播放增量', value: viewsIncrease },
-    { label: '点赞增量', value: likesIncrease },
-    { label: '评论增量', value: commentsIncrease },
-    { label: '收藏增量', value: collectsIncrease },
+    { label: '涨粉总计', value: totalFansIncrease },
+    { label: '播放总计', value: totalViews },
+    { label: '点赞总计', value: totalLikes },
+    { label: '评论总计', value: totalComments },
   ];
 });
 
@@ -262,21 +252,21 @@ async function loadData() {
   }
 }
 
-// 获取图表颜色配置
-function getChartColor(type: 'fans' | 'views' | 'likes' | 'comments') {
-  const colors: Record<string, { main: string; gradient: string[] }> = {
-    fans: { main: '#4facfe', gradient: ['#4facfe', 'rgba(79, 172, 254, 0)'] },
-    views: { main: '#11998e', gradient: ['#11998e', 'rgba(17, 153, 142, 0)'] },
-    likes: { main: '#f5576c', gradient: ['#f5576c', 'rgba(245, 87, 108, 0)'] },
-    comments: { main: '#fa709a', gradient: ['#fa709a', 'rgba(250, 112, 154, 0)'] },
-  };
-  return colors[type] || colors.fans;
-}
+// 平台颜色配置(柔和协调的配色)
+const platformColors: Record<string, string> = {
+  xiaohongshu: '#E91E63',
+  douyin: '#374151',
+  kuaishou: '#F59E0B',
+  weixin: '#10B981',
+  weixin_video: '#10B981',
+  shipinhao: '#10B981',
+  baijiahao: '#3B82F6',
+};
 
 // 获取图表标题
-function getChartTitle(type: 'fans' | 'views' | 'likes' | 'comments') {
+function getChartTitle(type: 'fansIncrease' | 'views' | 'likes' | 'comments') {
   const titles: Record<string, string> = {
-    fans: '粉数',
+    fansIncrease: '粉数',
     views: '播放量',
     likes: '点赞数',
     comments: '评论数',
@@ -291,67 +281,102 @@ function updateTrendChart() {
     trendChart = echarts.init(trendChartRef.value);
   }
   
-  // 使用 Python API 返回的数据
   const dates = trendData.value?.dates || [];
-  const data = trendData.value?.[trendMetric.value] || [];
-  const colorConfig = getChartColor(trendMetric.value);
+  const platforms = trendData.value?.platforms || [];
+  
+  // 生成每个平台的 series
+  const series: echarts.SeriesOption[] = platforms.map((p) => {
+    const data = p[trendMetric.value] || [];
+    const color = platformColors[p.platform] || '#6B7280';
+    
+    return {
+      name: p.platformName,
+      data: data,
+      type: 'line',
+      smooth: 0.3,
+      showSymbol: false,
+      symbol: 'circle',
+      symbolSize: 4,
+      lineStyle: { width: 2.5, color: color },
+      itemStyle: { color: color, borderWidth: 2, borderColor: '#fff' },
+      emphasis: {
+        focus: 'series',
+        showSymbol: true,
+        symbolSize: 6,
+        lineStyle: { width: 3 },
+        itemStyle: { borderWidth: 2, borderColor: '#fff' },
+      },
+    };
+  });
+  
+  const legendData = platforms.map(p => p.platformName);
   
   trendChart.setOption({
     tooltip: {
       trigger: 'axis',
-      backgroundColor: 'rgba(255, 255, 255, 0.95)',
+      backgroundColor: 'rgba(255, 255, 255, 0.98)',
       borderColor: '#e5e7eb',
       borderWidth: 1,
-      textStyle: { color: '#374151' },
+      padding: [10, 14],
+      textStyle: { color: '#374151', fontSize: 13 },
       formatter: (params: unknown) => {
-        const p = params as { name: string; value: number }[];
-        if (Array.isArray(p) && p.length > 0) {
-          return `${p[0].name}<br/>${getChartTitle(trendMetric.value)}: <b>${p[0].value.toLocaleString()}</b>`;
+        const p = params as { seriesName: string; name: string; value: number; color: string }[];
+        if (!Array.isArray(p) || p.length === 0) return '';
+        let html = `<div style="font-weight: 600; margin-bottom: 8px;">${p[0].name}</div>`;
+        for (const item of p) {
+          html += `<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 4px;">
+            <span style="display: inline-block; width: 8px; height: 8px; background: ${item.color}; border-radius: 2px;"></span>
+            <span style="color: #6b7280;">${item.seriesName}</span>
+            <span style="font-weight: 600; margin-left: auto;">${item.value.toLocaleString()}</span>
+          </div>`;
         }
-        return '';
+        return html;
       },
+      axisPointer: {
+        type: 'line',
+        lineStyle: { color: '#9ca3af', type: 'dashed', width: 1 },
+      },
+    },
+    legend: {
+      data: legendData,
+      bottom: 4,
+      type: 'scroll',
+      itemWidth: 14,
+      itemHeight: 10,
+      itemGap: 20,
+      textStyle: { color: '#6b7280', fontSize: 12 },
+      icon: 'roundRect',
     },
     grid: {
-      left: '3%',
-      right: '4%',
-      bottom: '3%',
+      left: '2%',
+      right: '2%',
+      top: '8%',
+      bottom: '36px',
       containLabel: true,
     },
     xAxis: {
       type: 'category',
       data: dates,
+      boundaryGap: false,
       axisLine: { lineStyle: { color: '#e5e7eb' } },
-      axisLabel: { color: '#6b7280' },
+      axisTick: { show: false },
+      axisLabel: { color: '#9ca3af', fontSize: 11 },
     },
     yAxis: {
       type: 'value',
       axisLine: { show: false },
       axisTick: { show: false },
-      splitLine: { lineStyle: { color: '#f3f4f6' } },
+      splitLine: { lineStyle: { color: '#f3f4f6', type: 'dashed' } },
       axisLabel: {
-        color: '#6b7280',
+        color: '#9ca3af',
+        fontSize: 11,
         formatter: (value: number) => {
-          if (value >= 10000) return (value / 10000).toFixed(1) + '';
+          if (value >= 10000) return (value / 10000).toFixed(1) + 'w';
           return value.toString();
         },
       },
     },
-    series: [{
-      data: data,
-      type: 'line',
-      smooth: true,
-      symbol: 'circle',
-      symbolSize: 6,
-      lineStyle: { width: 3 },
-      areaStyle: {
-        opacity: 0.1,
-        color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
-          { offset: 0, color: colorConfig.gradient[0] },
-          { offset: 1, color: colorConfig.gradient[1] },
-        ]),
-      },
-    }],
-    color: [colorConfig.main],
+    series: series,
   }, true);
 }
 

+ 109 - 67
client/src/views/Dashboard/index.vue

@@ -101,11 +101,12 @@
     <!-- 数据趋势图 -->
     <div class="content-card chart-card">
       <div class="card-header">
-        <h3>数据趋势</h3>
+        <h3>数据趋势(近30天)</h3>
         <el-radio-group v-model="trendType" size="small">
-          <el-radio-button label="fans">粉</el-radio-button>
+          <el-radio-button label="fansIncrease">粉</el-radio-button>
           <el-radio-button label="views">播放</el-radio-button>
           <el-radio-button label="likes">点赞</el-radio-button>
+          <el-radio-button label="comments">评论</el-radio-button>
         </el-radio-group>
       </div>
       <div class="chart-container">
@@ -117,7 +118,7 @@
 
 <script setup lang="ts">
 import { ref, onMounted, onUnmounted, onActivated, watch, markRaw, nextTick } from 'vue';
-import { User, VideoPlay, ChatDotRound, TrendCharts, Refresh } from '@element-plus/icons-vue';
+import { User, VideoPlay, UserFilled, TrendCharts, Refresh } from '@element-plus/icons-vue';
 import * as echarts from 'echarts';
 import { accountsApi } from '@/api/accounts';
 import { dashboardApi, type TrendData } from '@/api/dashboard';
@@ -131,7 +132,7 @@ const tabsStore = useTabsStore();
 const authStore = useAuthStore();
 const accounts = ref<PlatformAccount[]>([]);
 const tasks = ref<PublishTask[]>([]);
-const trendType = ref<'fans' | 'views' | 'likes'>('fans');
+const trendType = ref<'fansIncrease' | 'views' | 'likes' | 'comments'>('fansIncrease');
 const chartRef = ref<HTMLElement>();
 const trendData = ref<TrendData | null>(null);
 const refreshing = ref(false);
@@ -141,7 +142,7 @@ let resizeObserver: ResizeObserver | null = null;
 const stats = ref([
   { label: '平台账号', value: 0, icon: markRaw(User), iconClass: 'blue' },
   { label: '发布视频', value: 0, icon: markRaw(VideoPlay), iconClass: 'green' },
-  { label: '新增评论', value: 0, icon: markRaw(ChatDotRound), iconClass: 'orange' },
+  { label: '总粉丝数', value: '0', icon: markRaw(UserFilled), iconClass: 'orange' },
   { label: '总播放量', value: '0', icon: markRaw(TrendCharts), iconClass: 'pink' },
 ]);
 
@@ -189,28 +190,24 @@ function formatDate(date: string) {
   return dayjs(date).format('YYYY-MM-DD HH:mm');
 }
 
-// 获取图表颜色配置
-function getChartColor(type: 'fans' | 'views' | 'likes') {
-  const colors: Record<string, { main: string; gradient: string[] }> = {
-    fans: { main: '#4facfe', gradient: ['#4facfe', 'rgba(79, 172, 254, 0)'] },
-    views: { main: '#11998e', gradient: ['#11998e', 'rgba(17, 153, 142, 0)'] },
-    likes: { main: '#f5576c', gradient: ['#f5576c', 'rgba(245, 87, 108, 0)'] },
-  };
-  return colors[type] || colors.fans;
-}
-
-// 获取图表数据
-function getChartData(type: 'fans' | 'views' | 'likes') {
-  if (!trendData.value) return [];
-  return trendData.value[type] || [];
-}
+// 平台颜色配置(柔和协调的配色)
+const platformColors: Record<string, string> = {
+  xiaohongshu: '#E91E63',
+  douyin: '#374151',
+  kuaishou: '#F59E0B',
+  weixin: '#10B981',
+  weixin_video: '#10B981',
+  shipinhao: '#10B981',
+  baijiahao: '#3B82F6',
+};
 
 // 获取图表标题
-function getChartTitle(type: 'fans' | 'views' | 'likes') {
+function getChartTitle(type: 'fansIncrease' | 'views' | 'likes' | 'comments') {
   const titles: Record<string, string> = {
-    fans: '粉数',
+    fansIncrease: '涨粉数',
     views: '播放量',
     likes: '点赞数',
+    comments: '评论数',
   };
   return titles[type] || '';
 }
@@ -225,47 +222,108 @@ function initChart() {
 function updateChart() {
   if (!chartInstance) return;
   
-  const colorConfig = getChartColor(trendType.value);
-  const data = getChartData(trendType.value);
   const dates = trendData.value?.dates || [];
+  const platforms = trendData.value?.platforms || [];
+  
+  // 生成每个平台的 series
+  const series: echarts.SeriesOption[] = platforms.map((p) => {
+    const data = p[trendType.value] || [];
+    const color = platformColors[p.platform] || '#6B7280';
+    
+    return {
+      name: p.platformName,
+      data: data,
+      type: 'line',
+      smooth: 0.3,
+      showSymbol: false,
+      symbol: 'circle',
+      symbolSize: 4,
+      lineStyle: {
+        width: 2.5,
+        color: color,
+      },
+      itemStyle: {
+        color: color,
+        borderWidth: 2,
+        borderColor: '#fff',
+      },
+      emphasis: {
+        focus: 'series',
+        showSymbol: true,
+        symbolSize: 6,
+        lineStyle: { width: 3 },
+        itemStyle: { borderWidth: 2, borderColor: '#fff' },
+      },
+    };
+  });
+  
+  const legendData = platforms.map(p => p.platformName);
   
   const option: echarts.EChartsOption = {
     tooltip: { 
       trigger: 'axis',
-      backgroundColor: 'rgba(255, 255, 255, 0.95)',
+      backgroundColor: 'rgba(255, 255, 255, 0.98)',
       borderColor: '#e5e7eb',
       borderWidth: 1,
+      padding: [10, 14],
       textStyle: {
         color: '#374151',
+        fontSize: 13,
       },
       formatter: (params: unknown) => {
-        const p = params as { name: string; value: number }[];
-        if (Array.isArray(p) && p.length > 0) {
-          return `${p[0].name}<br/>${getChartTitle(trendType.value)}: <b>${p[0].value.toLocaleString()}</b>`;
+        const p = params as { seriesName: string; name: string; value: number; color: string }[];
+        if (!Array.isArray(p) || p.length === 0) return '';
+        
+        let html = `<div style="font-weight: 600; margin-bottom: 8px; font-size: 13px;">${p[0].name}</div>`;
+        for (const item of p) {
+          html += `<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 4px;">
+            <span style="display: inline-block; width: 8px; height: 8px; background: ${item.color}; border-radius: 2px;"></span>
+            <span style="color: #6b7280;">${item.seriesName}</span>
+            <span style="font-weight: 600; margin-left: auto;">${item.value.toLocaleString()}</span>
+          </div>`;
         }
-        return '';
+        return html;
       },
       axisPointer: {
-        type: 'cross',
-        crossStyle: {
+        type: 'line',
+        lineStyle: {
           color: '#9ca3af',
+          type: 'dashed',
+          width: 1,
         },
       },
     },
+    legend: {
+      data: legendData,
+      bottom: 4,
+      type: 'scroll',
+      itemWidth: 14,
+      itemHeight: 10,
+      itemGap: 20,
+      textStyle: {
+        color: '#6b7280',
+        fontSize: 12,
+      },
+      icon: 'roundRect',
+    },
     grid: {
-      left: '3%',
-      right: '4%',
-      bottom: '3%',
+      left: '2%',
+      right: '2%',
+      top: '8%',
+      bottom: '36px',
       containLabel: true,
     },
     xAxis: {
       type: 'category',
       data: dates,
+      boundaryGap: false,
       axisLine: {
         lineStyle: { color: '#e5e7eb' },
       },
+      axisTick: { show: false },
       axisLabel: {
-        color: '#6b7280',
+        color: '#9ca3af',
+        fontSize: 11,
       },
     },
     yAxis: { 
@@ -273,34 +331,21 @@ function updateChart() {
       axisLine: { show: false },
       axisTick: { show: false },
       splitLine: {
-        lineStyle: { color: '#f3f4f6' },
+        lineStyle: { 
+          color: '#f3f4f6',
+          type: 'dashed',
+        },
       },
       axisLabel: {
-        color: '#6b7280',
+        color: '#9ca3af',
+        fontSize: 11,
         formatter: (value: number) => {
-          if (value >= 10000) return (value / 10000).toFixed(1) + '';
+          if (value >= 10000) return (value / 10000).toFixed(1) + 'w';
           return value.toString();
         },
       },
     },
-    series: [{
-      data: data,
-      type: 'line',
-      smooth: true,
-      symbol: 'circle',
-      symbolSize: 6,
-      lineStyle: {
-        width: 3,
-      },
-      areaStyle: { 
-        opacity: 0.1,
-        color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
-          { offset: 0, color: colorConfig.gradient[0] },
-          { offset: 1, color: colorConfig.gradient[1] },
-        ]),
-      },
-    }],
-    color: [colorConfig.main],
+    series: series,
   };
   
   chartInstance.setOption(option, true);
@@ -309,12 +354,8 @@ function updateChart() {
 // 加载数据趋势
 async function loadTrendData() {
   try {
-    const userId = authStore.user?.id;
-    if (!userId) return;
-    
     trendData.value = await dashboardApi.getTrend({
-      userId,
-      days: 14,  // 获取最近14天的数据
+      days: 30,  // 获取最近30天的数据
     });
     
     // 更新图表
@@ -327,10 +368,9 @@ async function loadTrendData() {
 async function loadData() {
   try {
     // 并行获取所有数据
-    const [accountsData, worksStats, commentsStats] = await Promise.all([
+    const [accountsData, worksStats] = await Promise.all([
       accountsApi.getAccounts(),
       dashboardApi.getWorksStats().catch(() => null),
-      dashboardApi.getCommentsStats().catch(() => null),
     ]);
     
     accounts.value = accountsData;
@@ -338,6 +378,12 @@ async function loadData() {
     // 更新统计数据
     stats.value[0].value = accounts.value.length;
     
+    // 总粉丝数:当前用户所有平台账号的 fans_count 总和
+    const totalFans = accounts.value.reduce((sum, a) => sum + (a.fansCount || 0), 0);
+    stats.value[2].value = totalFans >= 10000 
+      ? (totalFans / 10000).toFixed(1) + '万' 
+      : totalFans.toString();
+    
     if (worksStats) {
       stats.value[1].value = worksStats.totalCount || 0;
       // 格式化播放量
@@ -347,10 +393,6 @@ async function loadData() {
         : playCount.toString();
     }
     
-    if (commentsStats) {
-      stats.value[2].value = commentsStats.todayCount || 0;
-    }
-    
     // 加载数据趋势
     await loadTrendData();
   } catch {

+ 1 - 0
server/package.json

@@ -7,6 +7,7 @@
   "scripts": {
     "dev": "tsx watch src/app.ts",
     "xhs:import": "tsx src/scripts/run-xhs-import.ts",
+    "check:trend": "tsx src/scripts/check-trend-data.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",

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


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


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


+ 131 - 0
server/src/routes/dashboard.ts

@@ -0,0 +1,131 @@
+import { Router } from 'express';
+import { query } from 'express-validator';
+import { authenticate } from '../middleware/auth.js';
+import { asyncHandler } from '../middleware/error.js';
+import { validateRequest } from '../middleware/validate.js';
+
+const router = Router();
+
+router.use(authenticate);
+
+// 在 Node 中声明全局 fetch
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+declare const fetch: any;
+
+/**
+ * 调用本地 Python 统计服务的工具函数
+ * 默认地址: http://localhost:5005
+ */
+async function callPythonApi(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) {
+      let errorData: any;
+      try {
+        errorData = await resp.json();
+      } catch {
+        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}`);
+    }
+
+    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;
+  }
+}
+
+/**
+ * GET /api/dashboard/trend
+ * 获取数据趋势(通过 Node 转发到 Python API)
+ * 按平台分组返回,每个平台一条曲线
+ * 
+ * 查询参数:
+ *   - days: 天数(默认30天)
+ *   - account_id: 账号ID(可选,不填则查询所有平台)
+ * 
+ * 返回:
+ * {
+ *   success: true,
+ *   data: {
+ *     dates: ["01-01", "01-02", ...],
+ *     platforms: [
+ *       {
+ *         platform: "xiaohongshu",
+ *         platformName: "小红书",
+ *         fansIncrease: [10, 20, ...],
+ *         views: [100, 200, ...],
+ *         likes: [50, 60, ...],
+ *         comments: [5, 6, ...]
+ *       },
+ *       ...
+ *     ]
+ *   }
+ * }
+ */
+router.get(
+  '/trend',
+  [
+    query('days').optional().isInt({ min: 1, max: 30 }).withMessage('days 必须是 1-30 之间的整数'),
+    query('account_id').optional().isInt().withMessage('account_id 必须是整数'),
+    validateRequest,
+  ],
+  asyncHandler(async (req, res) => {
+    const days = req.query.days ? parseInt(req.query.days as string) : 30;
+    const accountId = req.query.account_id ? parseInt(req.query.account_id as string) : undefined;
+
+    try {
+      const pythonResult = await callPythonApi('/work_day_statistics/trend', {
+        user_id: req.user!.userId,
+        days,
+        account_id: accountId,
+      });
+
+      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('[dashboard/trend] 调用 Python API 失败:', error);
+      return res.status(500).json({
+        success: false,
+        error: error.message || '调用 Python API 失败',
+        message: error.message || '调用 Python API 失败',
+      });
+    }
+  })
+);
+
+export default router;

+ 2 - 0
server/src/routes/index.ts

@@ -12,6 +12,7 @@ import worksRoutes from './works.js';
 import tasksRoutes from './tasks.js';
 import internalRoutes from './internal.js';
 import workDayStatisticsRoutes from './workDayStatistics.js';
+import dashboardRoutes from './dashboard.js';
 import { authenticate } from '../middleware/auth.js';
 
 export function setupRoutes(app: Express): void {
@@ -24,6 +25,7 @@ export function setupRoutes(app: Express): void {
   app.use('/api/comments', commentRoutes);
   app.use('/api/analytics', analyticsRoutes);
   app.use('/api/work-day-statistics', workDayStatisticsRoutes);
+  app.use('/api/dashboard', dashboardRoutes);
   app.use('/api/upload', uploadRoutes);
   app.use('/api/system', systemRoutes);
   app.use('/api/ai', aiRoutes);

+ 81 - 0
server/src/scripts/check-trend-data.ts

@@ -0,0 +1,81 @@
+#!/usr/bin/env tsx
+/**
+ * 排查数据趋势无数据问题
+ * 检查 user_day_statistics 表、platform_accounts 表的数据情况
+ *
+ * 运行: cd server && pnpm exec tsx src/scripts/check-trend-data.ts
+ */
+import { initDatabase, AppDataSource } from '../models/index.js';
+
+async function main() {
+  console.log('========== 数据趋势排查 ==========\n');
+
+  try {
+    await initDatabase();
+    console.log('✓ 数据库连接成功\n');
+
+    // 1. 检查 user_day_statistics 表
+    const udsCount = await AppDataSource.query(
+      'SELECT COUNT(*) as cnt FROM user_day_statistics'
+    );
+    console.log('1. user_day_statistics 表:');
+    console.log(`   总记录数: ${udsCount[0].cnt}`);
+
+    if (Number(udsCount[0].cnt) === 0) {
+      console.log('\n   ⚠ 表为空!数据来自定时任务导入,需要执行以下操作之一:');
+      console.log('   - 等待每天 12:00 定时任务自动导入');
+      console.log('   - 在「发布」页面为小红书/抖音等账号执行「导入近30天数据」');
+      console.log('   - 或运行: pnpm xhs:import (小红书)');
+    } else {
+      const byAccount = await AppDataSource.query(`
+        SELECT account_id, COUNT(*) as cnt, 
+               MIN(record_date) as min_date, MAX(record_date) as max_date
+        FROM user_day_statistics 
+        GROUP BY account_id
+      `);
+      console.log(`   按账号分布: ${JSON.stringify(byAccount, null, 2)}`);
+    }
+
+    // 2. 检查 platform_accounts 表
+    const accounts = await AppDataSource.query(`
+      SELECT pa.id, pa.user_id, pa.platform, pa.account_name 
+      FROM platform_accounts pa
+      ORDER BY pa.user_id, pa.platform
+    `);
+    console.log('\n2. platform_accounts 表:');
+    console.log(`   总账号数: ${accounts.length}`);
+    if (accounts.length > 0) {
+      accounts.forEach((a: any) => {
+        console.log(`   - id=${a.id} userId=${a.user_id} platform=${a.platform} name=${a.account_name}`);
+      });
+    } else {
+      console.log('   ⚠ 没有账号!请先在「账号管理」添加平台账号');
+    }
+
+    // 3. 检查账号是否有对应的 user_day_statistics
+    if (accounts.length > 0 && Number(udsCount[0].cnt) > 0) {
+      const accountIds = accounts.map((a: any) => a.id);
+      const hasData = await AppDataSource.query(
+        `SELECT account_id FROM user_day_statistics WHERE account_id IN (?) GROUP BY account_id`,
+        [accountIds]
+      );
+      const accountIdsWithData = new Set(hasData.map((r: any) => r.account_id));
+      const missing = accounts.filter((a: any) => !accountIdsWithData.has(a.id));
+      if (missing.length > 0) {
+        console.log('\n3. 以下账号没有 user_day_statistics 数据:');
+        missing.forEach((a: any) => {
+          console.log(`   - id=${a.id} platform=${a.platform} ${a.account_name}`);
+        });
+      }
+    }
+
+    console.log('\n========== 排查完成 ==========');
+  } catch (err) {
+    console.error('错误:', err);
+    process.exit(1);
+  } finally {
+    await AppDataSource.destroy();
+  }
+}
+
+main();

+ 15 - 17
server/src/services/HeadlessBrowserService.ts

@@ -58,9 +58,9 @@ const PLATFORM_API_CONFIG: Record<string, {
       // 必须有用户信息(name 或 app_id)
       const hasUserInfo = !!(resp?.data?.user?.name || resp?.data?.user?.app_id);
 
-      // 用户状态不能是 'banned' 或其他异常状态
+      // 用户状态不能是 'banned' 或其他异常状态(兼容 normal 等常见正常状态)
       const userStatus = resp?.data?.user?.status;
-      const isStatusOk = !userStatus || userStatus === 'audit' || userStatus === 'pass' || userStatus === 'active';
+      const isStatusOk = !userStatus || ['audit', 'pass', 'active', 'normal'].includes(String(userStatus));
 
       const isValid = isErrnoOk && hasUserInfo && isStatusOk;
 
@@ -268,26 +268,24 @@ class HeadlessBrowserService {
         return false;
       }
 
-      // 百家号特殊处理:根据 errno 判断
+      // 百家号特殊处理:API 用 Node fetch 调用时可能因分散认证等返回 errno !== 0,但 Cookie 在浏览器内仍有效
+      // 因此当 API 判为无效时,回退到浏览器检查,避免“能登录后台却显示过期”
       if (platform === 'baijiahao') {
         const errno = (data as { errno?: number })?.errno;
 
-        // errno 为 0 表示请求成功,但可能没有用户信息(已在 isValidResponse 中检查)
-        if (errno === 0) {
-          // 如果 isValid 为 false,说明虽然请求成功但没有用户信息,可能是 Cookie 无效
-          if (!isValid) {
-            logger.warn(`[API] Baijiahao errno=0 but no user info, cookie may be invalid`);
-            return false;
-          }
-          return true;
+        if (errno === 0 && isValid) return true;
+        if (errno === 0 && !isValid) {
+          logger.warn(`[API] Baijiahao errno=0 but no user info, falling back to browser check`);
+          return this.checkCookieValidByBrowser(platform, cookies);
         }
 
-        // errno 非 0 表示请求失败,可能是 Cookie 无效
-        // 常见错误码:
-        // - 110: 未登录
-        // - 其他非 0 值:可能是权限问题或其他错误
-        logger.warn(`[API] Baijiahao errno=${errno}, cookie is invalid`);
-        return false;
+        // errno 110 通常表示未登录,可直接判无效;其他 errno(如 10001402 分散认证)可能只是接口限制,用浏览器再判一次
+        if (errno === 110) {
+          logger.warn(`[API] Baijiahao errno=110 (not logged in), cookie invalid`);
+          return false;
+        }
+        logger.info(`[API] Baijiahao errno=${errno}, falling back to browser check (may be dispersed auth)`);
+        return this.checkCookieValidByBrowser(platform, cookies);
       }
 
       // 不确定的状态(如 status_code=7),回退到浏览器检查

+ 148 - 84
server/src/services/WorkDayStatisticsService.ts

@@ -16,14 +16,20 @@ interface SaveResult {
   updated: number;
 }
 
+// 单个平台的趋势数据
+interface PlatformTrendItem {
+  platform: string;       // 平台标识
+  platformName: string;   // 平台中文名
+  fansIncrease: number[]; // 涨粉数
+  views: number[];        // 播放数
+  likes: number[];        // 点赞数
+  comments: number[];     // 评论数
+}
+
+// 趋势数据(按平台分组)
 interface TrendData {
-  dates: string[];
-  fans: number[];
-  views: number[];
-  likes: number[];
-  comments: number[];
-  shares: number[];
-  collects: number[];
+  dates: string[];                  // 日期数组
+  platforms: PlatformTrendItem[];   // 各平台数据
 }
 
 interface PlatformStatItem {
@@ -152,8 +158,21 @@ export class WorkDayStatisticsService {
     return { inserted: insertedCount, updated: updatedCount };
   }
 
+  // 平台名称映射
+  private platformNameMap: Record<string, string> = {
+    xiaohongshu: '小红书',
+    douyin: '抖音',
+    kuaishou: '快手',
+    weixin: '视频号',
+    weixin_video: '视频号',
+    shipinhao: '视频号',
+    baijiahao: '百家号',
+  };
+
   /**
    * 获取数据趋势
+   * 从 user_day_statistics 表获取近30天的数据
+   * 按平台分组返回,每个平台下所有账号的总和为一条曲线
    */
   async getTrend(
     userId: number,
@@ -164,7 +183,7 @@ export class WorkDayStatisticsService {
       accountId?: number;
     }
   ): Promise<TrendData> {
-    const { days = 7, startDate, endDate, accountId } = options;
+    const { days = 30, startDate, endDate, accountId } = options;
 
     // 计算日期范围
     let dateStart: Date;
@@ -179,103 +198,148 @@ export class WorkDayStatisticsService {
       dateStart.setDate(dateStart.getDate() - Math.min(days, 30) + 1);
     }
 
-    // 构建查询(不再从 work_day_statistics 读取粉丝数,粉丝数从 user_day_statistics 表获取)
-    const queryBuilder = this.statisticsRepository
-      .createQueryBuilder('wds')
-      .innerJoin(Work, 'w', 'wds.work_id = w.id')
-      .select('wds.record_date', 'recordDate')
-      .addSelect('w.accountId', 'accountId')
-      .addSelect('SUM(wds.play_count)', 'accountViews')
-      .addSelect('SUM(wds.like_count)', 'accountLikes')
-      .addSelect('SUM(wds.comment_count)', 'accountComments')
-      .addSelect('SUM(wds.share_count)', 'accountShares')
-      .addSelect('SUM(wds.collect_count)', 'accountCollects')
-      .where('w.userId = :userId', { userId })
-      .andWhere('wds.record_date >= :dateStart', { dateStart })
-      .andWhere('wds.record_date <= :dateEnd', { dateEnd })
-      .groupBy('wds.record_date')
-      .addGroupBy('w.accountId')
-      .orderBy('wds.record_date', 'ASC');
+    // 获取该用户所有的账号(包含平台信息)
+    const userAccounts = await this.accountRepository.find({
+      where: { userId },
+      select: ['id', 'platform'],
+    });
+
+    if (userAccounts.length === 0) {
+      // 用户没有账号,返回空数据
+      return this.generateEmptyTrendData(dateStart, dateEnd);
+    }
 
+    // 按平台分组账号
+    const platformAccountsMap = new Map<string, number[]>();
+    for (const account of userAccounts) {
+      const platform = account.platform;
+      if (!platformAccountsMap.has(platform)) {
+        platformAccountsMap.set(platform, []);
+      }
+      platformAccountsMap.get(platform)!.push(account.id);
+    }
+
+    // 如果指定了特定账号,只查询该账号所属平台
     if (accountId) {
-      queryBuilder.andWhere('w.accountId = :accountId', { accountId });
+      const targetAccount = userAccounts.find(a => a.id === accountId);
+      if (targetAccount) {
+        platformAccountsMap.clear();
+        platformAccountsMap.set(targetAccount.platform, [accountId]);
+      }
+    }
+
+    // 生成完整的日期数组
+    const dates: string[] = [];
+    const dateKeys: string[] = [];
+    const d = new Date(dateStart);
+    while (d <= dateEnd) {
+      const dateKey = this.formatDate(d);
+      const displayDate = `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
+      dates.push(displayDate);
+      dateKeys.push(dateKey);
+      d.setDate(d.getDate() + 1);
     }
 
-    const accountResults = await queryBuilder.getRawMany();
+    // 获取用户拥有的所有平台
+    const userPlatforms = Array.from(platformAccountsMap.keys());
+
+    // 查询数据:按平台和日期分组
+    const allAccountIds = userAccounts.map(a => a.id);
+    const results = await this.userDayStatisticsRepository
+      .createQueryBuilder('uds')
+      .innerJoin(PlatformAccount, 'pa', 'uds.account_id = pa.id')
+      .select('pa.platform', 'platform')
+      .addSelect('uds.record_date', 'recordDate')
+      .addSelect('SUM(uds.fans_increase)', 'totalFansIncrease')
+      .addSelect('SUM(uds.play_count)', 'totalViews')
+      .addSelect('SUM(uds.like_count)', 'totalLikes')
+      .addSelect('SUM(uds.comment_count)', 'totalComments')
+      .where('uds.account_id IN (:...accountIds)', { accountIds: allAccountIds })
+      .andWhere('uds.record_date >= :dateStart', { dateStart })
+      .andWhere('uds.record_date <= :dateEnd', { dateEnd })
+      .groupBy('pa.platform')
+      .addGroupBy('uds.record_date')
+      .orderBy('uds.record_date', 'ASC')
+      .getRawMany();
 
-    // 按日期汇总所有账号的数据
-    const dateMap = new Map<string, {
-      fans: number;
+    // 构建 平台 -> 日期 -> 数据 的映射
+    const platformDateMap = new Map<string, Map<string, {
+      fansIncrease: number;
       views: number;
       likes: number;
       comments: number;
-      shares: number;
-      collects: number;
-    }>();
+    }>>();
 
-    for (const row of accountResults) {
+    for (const row of results) {
+      const platform = row.platform;
       const dateKey = row.recordDate instanceof Date 
         ? row.recordDate.toISOString().split('T')[0]
         : String(row.recordDate).split('T')[0];
+
+      if (!platformDateMap.has(platform)) {
+        platformDateMap.set(platform, new Map());
+      }
+
+      platformDateMap.get(platform)!.set(dateKey, {
+        fansIncrease: parseInt(row.totalFansIncrease) || 0,
+        views: parseInt(row.totalViews) || 0,
+        likes: parseInt(row.totalLikes) || 0,
+        comments: parseInt(row.totalComments) || 0,
+      });
+    }
+
+    // 构建各平台的数据数组
+    const platforms: PlatformTrendItem[] = [];
+
+    for (const platform of userPlatforms) {
+      const dateDataMap = platformDateMap.get(platform) || new Map();
       
-      if (!dateMap.has(dateKey)) {
-        dateMap.set(dateKey, {
-          fans: 0,
-          views: 0,
-          likes: 0,
-          comments: 0,
-          shares: 0,
-          collects: 0,
-        });
+      const fansIncrease: number[] = [];
+      const views: number[] = [];
+      const likes: number[] = [];
+      const comments: number[] = [];
+
+      for (const dateKey of dateKeys) {
+        const data = dateDataMap.get(dateKey);
+        if (data) {
+          fansIncrease.push(data.fansIncrease);
+          views.push(data.views);
+          likes.push(data.likes);
+          comments.push(data.comments);
+        } else {
+          fansIncrease.push(0);
+          views.push(0);
+          likes.push(0);
+          comments.push(0);
+        }
       }
 
-      const current = dateMap.get(dateKey)!;
-      current.fans += parseInt(row.accountFans) || 0;
-      current.views += parseInt(row.accountViews) || 0;
-      current.likes += parseInt(row.accountLikes) || 0;
-      current.comments += parseInt(row.accountComments) || 0;
-      current.shares += parseInt(row.accountShares) || 0;
-      current.collects += parseInt(row.accountCollects) || 0;
+      platforms.push({
+        platform,
+        platformName: this.platformNameMap[platform] || platform,
+        fansIncrease,
+        views,
+        likes,
+        comments,
+      });
     }
 
-    // 构建响应数据
+    return { dates, platforms };
+  }
+
+  /**
+   * 生成空的趋势数据
+   */
+  private generateEmptyTrendData(dateStart: Date, dateEnd: Date): TrendData {
     const dates: string[] = [];
-    const fans: number[] = [];
-    const views: number[] = [];
-    const likes: number[] = [];
-    const comments: number[] = [];
-    const shares: number[] = [];
-    const collects: number[] = [];
-
-    // 按日期排序
-    const sortedDates = Array.from(dateMap.keys()).sort();
-    for (const dateKey of sortedDates) {
-      dates.push(dateKey.slice(5)); // "YYYY-MM-DD" -> "MM-DD"
-      const data = dateMap.get(dateKey)!;
-      fans.push(data.fans);
-      views.push(data.views);
-      likes.push(data.likes);
-      comments.push(data.comments);
-      shares.push(data.shares);
-      collects.push(data.collects);
-    }
 
-    // 如果没有数据,生成空的日期范围
-    if (dates.length === 0) {
-      const d = new Date(dateStart);
-      while (d <= dateEnd) {
-        dates.push(`${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`);
-        fans.push(0);
-        views.push(0);
-        likes.push(0);
-        comments.push(0);
-        shares.push(0);
-        collects.push(0);
-        d.setDate(d.getDate() + 1);
-      }
+    const d = new Date(dateStart);
+    while (d <= dateEnd) {
+      dates.push(`${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`);
+      d.setDate(d.getDate() + 1);
     }
 
-    return { dates, fans, views, likes, comments, shares, collects };
+    return { dates, platforms: [] };
   }
 
   /**