Przeglądaj źródła

数据分析部分

Ethanfly 14 godzin temu
rodzic
commit
e2c184c089

+ 38 - 0
client/src/api/dashboard.ts

@@ -14,6 +14,24 @@ export interface CommentsStats {
   todayCount: number;
 }
 
+export interface TrendData {
+  dates: string[];
+  fans: number[];
+  views: number[];
+  likes: number[];
+  comments: number[];
+  shares: number[];
+  collects: number[];
+}
+
+export interface TrendParams {
+  userId: number;
+  days?: number;
+  accountId?: number;
+}
+
+const PYTHON_API_URL = 'http://localhost:5005';
+
 export const dashboardApi = {
   // 获取作品统计
   getWorksStats(): Promise<WorksStats> {
@@ -24,4 +42,24 @@ export const dashboardApi = {
   getCommentsStats(): Promise<CommentsStats> {
     return request.get('/api/comments/stats');
   },
+
+  // 获取数据趋势(调用 Python API)
+  async getTrend(params: TrendParams): Promise<TrendData> {
+    const queryParams = new URLSearchParams({
+      user_id: params.userId.toString(),
+      days: (params.days || 7).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 || '获取数据趋势失败');
+    }
+    
+    return result.data;
+  },
 };

+ 257 - 43
client/src/views/Analytics/index.vue

@@ -2,7 +2,7 @@
   <div class="analytics-page">
     <div class="page-header">
       <h2>数据分析</h2>
-      <div class="date-range">
+      <div class="header-actions">
         <el-date-picker
           v-model="dateRange"
           type="daterange"
@@ -11,18 +11,24 @@
           end-placeholder="结束日期"
           @change="loadData"
         />
+        <el-button 
+          type="primary" 
+          :icon="Refresh" 
+          :loading="refreshing"
+          @click="handleRefresh"
+        >
+          刷新数据
+        </el-button>
       </div>
     </div>
     
     <!-- 统计摘要 -->
-    <el-row :gutter="20">
-      <el-col :span="4" v-for="(item, index) in summaryItems" :key="index">
-        <div class="stat-card">
-          <div class="stat-value">{{ formatNumber(item.value) }}</div>
-          <div class="stat-label">{{ item.label }}</div>
-        </div>
-      </el-col>
-    </el-row>
+    <div class="stats-grid">
+      <div class="stat-card" v-for="(item, index) in summaryItems" :key="index">
+        <div class="stat-value">{{ formatNumber(item.value) }}</div>
+        <div class="stat-label">{{ item.label }}</div>
+      </div>
+    </div>
     
     <!-- 趋势图 -->
     <div class="page-card" style="margin-top: 20px">
@@ -69,36 +75,87 @@
 <script setup lang="ts">
 import { ref, onMounted, computed } from 'vue';
 import * as echarts from 'echarts';
-import request from '@/api/request';
+import { Refresh } from '@element-plus/icons-vue';
 import { PLATFORMS } from '@media-manager/shared';
-import type { AnalyticsSummary, AnalyticsTrend, PlatformComparison, PlatformType } from '@media-manager/shared';
+import type { PlatformComparison, PlatformType } from '@media-manager/shared';
+import { useAuthStore } from '@/stores/auth';
 import dayjs from 'dayjs';
 
+const PYTHON_API_URL = 'http://localhost:5005';
+
+// 趋势数据类型
+interface TrendData {
+  dates: string[];
+  fans: number[];
+  views: number[];
+  likes: number[];
+  comments: number[];
+  shares: number[];
+  collects: number[];
+}
+
+const authStore = useAuthStore();
 const loading = ref(false);
+const refreshing = ref(false);
 const trendChartRef = ref<HTMLElement>();
 const platformChartRef = ref<HTMLElement>();
 
 let trendChart: echarts.ECharts | null = null;
 let platformChart: echarts.ECharts | null = null;
 
+// 刷新数据
+async function handleRefresh() {
+  refreshing.value = true;
+  try {
+    await loadData();
+  } finally {
+    refreshing.value = false;
+  }
+}
+
 const dateRange = ref<[Date, Date]>([
   dayjs().subtract(30, 'day').toDate(),
   dayjs().toDate(),
 ]);
 
-const trendMetric = ref('fans');
-const summary = ref<AnalyticsSummary | null>(null);
-const trend = ref<AnalyticsTrend | null>(null);
+const trendMetric = ref<'fans' | 'views' | 'likes' | 'comments'>('fans');
+const trendData = ref<TrendData | null>(null);
 const platformComparison = ref<PlatformComparison[]>([]);
 
-const summaryItems = computed(() => [
-  { label: '总粉丝', value: summary.value?.totalFans || 0 },
-  { label: '粉丝增量', value: summary.value?.totalFansIncrease || 0 },
-  { label: '总播放', value: summary.value?.totalViews || 0 },
-  { label: '总点赞', value: summary.value?.totalLikes || 0 },
-  { label: '总评论', value: summary.value?.totalComments || 0 },
-  { label: '总收益', value: summary.value?.totalIncome || 0 },
-]);
+// 从趋势数据计算统计摘要
+const summaryItems = computed(() => {
+  const data = trendData.value;
+  if (!data || data.dates.length === 0) {
+    return [
+      { label: '总粉丝', value: 0 },
+      { label: '粉丝增量', value: 0 },
+      { label: '总播放', value: 0 },
+      { label: '总点赞', value: 0 },
+      { label: '总评论', value: 0 },
+      { label: '总收藏', value: 0 },
+    ];
+  }
+  
+  // 取最后一天的粉丝数作为当前粉丝数
+  const latestFans = data.fans[data.fans.length - 1] || 0;
+  // 计算粉丝增量(最后一天 - 第一天)
+  const firstFans = data.fans[0] || 0;
+  const fansIncrease = latestFans - firstFans;
+  // 计算区间内的总和
+  const totalViews = data.views.reduce((sum, v) => sum + v, 0);
+  const totalLikes = data.likes.reduce((sum, v) => sum + v, 0);
+  const totalComments = data.comments.reduce((sum, v) => sum + v, 0);
+  const totalCollects = data.collects.reduce((sum, v) => sum + v, 0);
+  
+  return [
+    { label: '总粉丝', value: latestFans },
+    { label: '粉丝增量', value: fansIncrease },
+    { label: '总播放', value: totalViews },
+    { label: '总点赞', value: totalLikes },
+    { label: '总评论', value: totalComments },
+    { label: '总收藏', value: totalCollects },
+  ];
+});
 
 function getPlatformName(platform: PlatformType) {
   return PLATFORMS[platform]?.name || platform;
@@ -109,26 +166,91 @@ function formatNumber(num: number) {
   return num.toString();
 }
 
+// 获取趋势数据(调用 Python API)
+async function loadTrendData() {
+  const userId = authStore.user?.id;
+  if (!userId || !dateRange.value) return;
+  
+  // 计算天数
+  const [start, end] = dateRange.value;
+  const days = dayjs(end).diff(dayjs(start), 'day') + 1;
+  
+  const queryParams = new URLSearchParams({
+    user_id: userId.toString(),
+    days: Math.min(days, 30).toString(),  // 最大30天
+  });
+  
+  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 || '获取数据趋势失败');
+  }
+  
+  return result.data as TrendData;
+}
+
+// 平台统计数据类型
+interface PlatformStats {
+  platform: string;
+  fansCount: number;
+  fansIncrease: number;
+  viewsCount: number;
+  likesCount: number;
+  commentsCount: number;
+  collectsCount: number;
+}
+
+// 获取平台统计数据(调用 Python API)
+async function loadPlatformData() {
+  const userId = authStore.user?.id;
+  if (!userId || !dateRange.value) return;
+  
+  // 计算天数
+  const [start, end] = dateRange.value;
+  const days = dayjs(end).diff(dayjs(start), 'day') + 1;
+  
+  const queryParams = new URLSearchParams({
+    user_id: userId.toString(),
+    days: Math.min(days, 30).toString(),
+  });
+  
+  const response = await fetch(`${PYTHON_API_URL}/work_day_statistics/platforms?${queryParams}`);
+  const result = await response.json();
+  
+  if (!result.success) {
+    throw new Error(result.error || '获取平台数据失败');
+  }
+  
+  return result.data as PlatformStats[];
+}
+
 async function loadData() {
   if (!dateRange.value) return;
   
   loading.value = true;
-  const [start, end] = dateRange.value;
-  const params = {
-    startDate: dayjs(start).format('YYYY-MM-DD'),
-    endDate: dayjs(end).format('YYYY-MM-DD'),
-  };
   
   try {
-    const [summaryData, trendData, platformData] = await Promise.all([
-      request.get('/api/analytics/summary', { params }),
-      request.get('/api/analytics/trend', { params }),
-      request.get('/api/analytics/platforms', { params }),
+    // 并行加载数据(全部从 Python API 获取)
+    const [pythonTrendData, pythonPlatformData] = await Promise.all([
+      loadTrendData().catch(() => null),
+      loadPlatformData().catch(() => null),
     ]);
     
-    summary.value = summaryData;
-    trend.value = trendData;
-    platformComparison.value = platformData;
+    if (pythonTrendData) {
+      trendData.value = pythonTrendData;
+    }
+    
+    if (pythonPlatformData) {
+      // 转换为 PlatformComparison 格式
+      platformComparison.value = pythonPlatformData.map(p => ({
+        platform: p.platform as PlatformType,
+        fansCount: p.fansCount,
+        fansIncrease: p.fansIncrease,
+        viewsCount: p.viewsCount,
+        likesCount: p.likesCount,
+      }));
+    }
     
     updateTrendChart();
     updatePlatformChart();
@@ -139,30 +261,97 @@ 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;
+}
+
+// 获取图表标题
+function getChartTitle(type: 'fans' | 'views' | 'likes' | 'comments') {
+  const titles: Record<string, string> = {
+    fans: '粉丝数',
+    views: '播放量',
+    likes: '点赞数',
+    comments: '评论数',
+  };
+  return titles[type] || '';
+}
+
 function updateTrendChart() {
-  if (!trendChartRef.value || !trend.value) return;
+  if (!trendChartRef.value) return;
   
   if (!trendChart) {
     trendChart = echarts.init(trendChartRef.value);
   }
   
-  const data = trend.value[trendMetric.value as keyof AnalyticsTrend] || [];
+  // 使用 Python API 返回的数据
+  const dates = trendData.value?.dates || [];
+  const data = trendData.value?.[trendMetric.value] || [];
+  const colorConfig = getChartColor(trendMetric.value);
   
   trendChart.setOption({
-    tooltip: { trigger: 'axis' },
+    tooltip: {
+      trigger: 'axis',
+      backgroundColor: 'rgba(255, 255, 255, 0.95)',
+      borderColor: '#e5e7eb',
+      borderWidth: 1,
+      textStyle: { color: '#374151' },
+      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>`;
+        }
+        return '';
+      },
+    },
+    grid: {
+      left: '3%',
+      right: '4%',
+      bottom: '3%',
+      containLabel: true,
+    },
     xAxis: {
       type: 'category',
-      data: data.map(d => d.date),
+      data: dates,
+      axisLine: { lineStyle: { color: '#e5e7eb' } },
+      axisLabel: { color: '#6b7280' },
+    },
+    yAxis: {
+      type: 'value',
+      axisLine: { show: false },
+      axisTick: { show: false },
+      splitLine: { lineStyle: { color: '#f3f4f6' } },
+      axisLabel: {
+        color: '#6b7280',
+        formatter: (value: number) => {
+          if (value >= 10000) return (value / 10000).toFixed(1) + '万';
+          return value.toString();
+        },
+      },
     },
-    yAxis: { type: 'value' },
     series: [{
-      data: data.map(d => d.value),
+      data: data,
       type: 'line',
       smooth: true,
-      areaStyle: { opacity: 0.3 },
+      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: ['#409eff'],
-  });
+    color: [colorConfig.main],
+  }, true);
 }
 
 function updatePlatformChart() {
@@ -205,6 +394,19 @@ onMounted(() => {
   margin-bottom: 20px;
   
   h2 { margin: 0; }
+  
+  .header-actions {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+  }
+}
+
+.stats-grid {
+  display: grid;
+  grid-template-columns: repeat(6, 1fr);
+  gap: 20px;
+  margin-bottom: 20px;
 }
 
 .stat-card {
@@ -227,6 +429,18 @@ onMounted(() => {
   }
 }
 
+@media (max-width: 1200px) {
+  .stats-grid {
+    grid-template-columns: repeat(3, 1fr);
+  }
+}
+
+@media (max-width: 768px) {
+  .stats-grid {
+    grid-template-columns: repeat(2, 1fr);
+  }
+}
+
 .card-header {
   display: flex;
   align-items: center;

+ 118 - 10
client/src/views/Dashboard/index.vue

@@ -1,5 +1,18 @@
 <template>
   <div class="dashboard">
+    <!-- 页面标题和刷新按钮 -->
+    <div class="page-header">
+      <h2>数据看板</h2>
+      <el-button 
+        type="primary" 
+        :icon="Refresh" 
+        :loading="refreshing"
+        @click="handleRefresh"
+      >
+        刷新数据
+      </el-button>
+    </div>
+    
     <!-- 统计卡片 -->
     <div class="stats-grid">
       <div class="stat-card" v-for="stat in stats" :key="stat.label">
@@ -104,20 +117,24 @@
 
 <script setup lang="ts">
 import { ref, onMounted, onUnmounted, onActivated, watch, markRaw, nextTick } from 'vue';
-import { User, VideoPlay, ChatDotRound, TrendCharts } from '@element-plus/icons-vue';
+import { User, VideoPlay, ChatDotRound, TrendCharts, Refresh } from '@element-plus/icons-vue';
 import * as echarts from 'echarts';
 import { accountsApi } from '@/api/accounts';
-import { dashboardApi } from '@/api/dashboard';
+import { dashboardApi, type TrendData } from '@/api/dashboard';
 import { PLATFORMS } from '@media-manager/shared';
 import type { PlatformAccount, PublishTask, PlatformType } from '@media-manager/shared';
 import { useTabsStore } from '@/stores/tabs';
+import { useAuthStore } from '@/stores/auth';
 import dayjs from 'dayjs';
 
 const tabsStore = useTabsStore();
+const authStore = useAuthStore();
 const accounts = ref<PlatformAccount[]>([]);
 const tasks = ref<PublishTask[]>([]);
-const trendType = ref('fans');
+const trendType = ref<'fans' | 'views' | 'likes'>('fans');
 const chartRef = ref<HTMLElement>();
+const trendData = ref<TrendData | null>(null);
+const refreshing = ref(false);
 let chartInstance: echarts.ECharts | null = null;
 let resizeObserver: ResizeObserver | null = null;
 
@@ -132,6 +149,16 @@ function handleNavigate(path: string) {
   tabsStore.openPageTab(path);
 }
 
+// 刷新数据
+async function handleRefresh() {
+  refreshing.value = true;
+  try {
+    await loadData();
+  } finally {
+    refreshing.value = false;
+  }
+}
+
 function getPlatformName(platform: PlatformType) {
   return PLATFORMS[platform]?.name || platform;
 }
@@ -162,10 +189,45 @@ 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] || [];
+}
+
+// 获取图表标题
+function getChartTitle(type: 'fans' | 'views' | 'likes') {
+  const titles: Record<string, string> = {
+    fans: '粉丝数',
+    views: '播放量',
+    likes: '点赞数',
+  };
+  return titles[type] || '';
+}
+
 function initChart() {
   if (!chartRef.value) return;
   
   chartInstance = echarts.init(chartRef.value);
+  updateChart();
+}
+
+function updateChart() {
+  if (!chartInstance) return;
+  
+  const colorConfig = getChartColor(trendType.value);
+  const data = getChartData(trendType.value);
+  const dates = trendData.value?.dates || [];
   
   const option: echarts.EChartsOption = {
     tooltip: { 
@@ -176,6 +238,13 @@ function initChart() {
       textStyle: {
         color: '#374151',
       },
+      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>`;
+        }
+        return '';
+      },
       axisPointer: {
         type: 'cross',
         crossStyle: {
@@ -191,7 +260,7 @@ function initChart() {
     },
     xAxis: {
       type: 'category',
-      data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
+      data: dates,
       axisLine: {
         lineStyle: { color: '#e5e7eb' },
       },
@@ -208,10 +277,14 @@ function initChart() {
       },
       axisLabel: {
         color: '#6b7280',
+        formatter: (value: number) => {
+          if (value >= 10000) return (value / 10000).toFixed(1) + '万';
+          return value.toString();
+        },
       },
     },
     series: [{
-      data: [820, 932, 901, 934, 1290, 1330, 1320],
+      data: data,
       type: 'line',
       smooth: true,
       symbol: 'circle',
@@ -222,15 +295,33 @@ function initChart() {
       areaStyle: { 
         opacity: 0.1,
         color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
-          { offset: 0, color: '#4f8cff' },
-          { offset: 1, color: 'rgba(79, 140, 255, 0)' },
+          { offset: 0, color: colorConfig.gradient[0] },
+          { offset: 1, color: colorConfig.gradient[1] },
         ]),
       },
     }],
-    color: ['#4f8cff'],
+    color: [colorConfig.main],
   };
   
-  chartInstance.setOption(option);
+  chartInstance.setOption(option, true);
+}
+
+// 加载数据趋势
+async function loadTrendData() {
+  try {
+    const userId = authStore.user?.id;
+    if (!userId) return;
+    
+    trendData.value = await dashboardApi.getTrend({
+      userId,
+      days: 14,  // 获取最近14天的数据
+    });
+    
+    // 更新图表
+    updateChart();
+  } catch (error) {
+    console.error('加载数据趋势失败:', error);
+  }
 }
 
 async function loadData() {
@@ -259,6 +350,9 @@ async function loadData() {
     if (commentsStats) {
       stats.value[2].value = commentsStats.todayCount || 0;
     }
+    
+    // 加载数据趋势
+    await loadTrendData();
   } catch {
     // 错误已在拦截器中处理
   }
@@ -311,7 +405,7 @@ onUnmounted(() => {
 });
 
 watch(trendType, () => {
-  // TODO: 更新图表数据
+  updateChart();
 });
 </script>
 
@@ -323,6 +417,20 @@ watch(trendType, () => {
   margin: 0 auto;
 }
 
+.page-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 24px;
+  
+  h2 {
+    margin: 0;
+    font-size: 22px;
+    font-weight: 600;
+    color: $text-primary;
+  }
+}
+
 // 统计卡片网格
 .stats-grid {
   display: grid;

+ 262 - 0
server/python/app.py

@@ -113,6 +113,8 @@ CORS(app)
 
 # 全局配置
 HEADLESS_MODE = os.environ.get('HEADLESS', 'true').lower() == 'true'
+print(f"[Config] HEADLESS env value: '{os.environ.get('HEADLESS', 'NOT SET')}'", flush=True)
+print(f"[Config] HEADLESS_MODE: {HEADLESS_MODE}", flush=True)
 
 # 数据库配置
 DB_CONFIG = {
@@ -596,6 +598,266 @@ def save_work_day_statistics():
         return jsonify({"success": False, "error": str(e)}), 500
 
 
+@app.route("/work_day_statistics/trend", methods=["GET"])
+def get_statistics_trend():
+    """
+    获取数据趋势(用于 Dashboard 数据看板)
+    
+    查询参数:
+        user_id: 用户ID (必填)
+        days: 天数 (可选,默认7天,最大30天)
+        account_id: 账号ID (可选,不填则查询所有账号)
+    
+    响应:
+    {
+        "success": true,
+        "data": {
+            "dates": ["01-16", "01-17", "01-18", ...],
+            "fans": [100, 120, 130, ...],
+            "views": [1000, 1200, 1500, ...],
+            "likes": [50, 60, 70, ...],
+            "comments": [10, 12, 15, ...],
+            "shares": [5, 6, 8, ...],
+            "collects": [20, 25, 30, ...]
+        }
+    }
+    """
+    try:
+        user_id = request.args.get("user_id")
+        days = min(int(request.args.get("days", 7)), 30)  # 最大30天
+        account_id = request.args.get("account_id")
+        
+        if not user_id:
+            return jsonify({"success": False, "error": "缺少 user_id 参数"}), 400
+        
+        conn = get_db_connection()
+        try:
+            with conn.cursor() as cursor:
+                # 构建查询:关联 works 表获取用户的作品,然后汇总统计数据
+                # 注意:粉丝数是账号级别的数据,每个账号每天只取一个值(使用 MAX)
+                # 其他指标(播放、点赞等)是作品级别的数据,需要累加
+                sql = """
+                    SELECT 
+                        record_date,
+                        SUM(account_fans) as total_fans,
+                        SUM(account_views) as total_views,
+                        SUM(account_likes) as total_likes,
+                        SUM(account_comments) as total_comments,
+                        SUM(account_shares) as total_shares,
+                        SUM(account_collects) as total_collects
+                    FROM (
+                        SELECT 
+                            wds.record_date,
+                            w.accountId,
+                            MAX(wds.fans_count) as account_fans,
+                            SUM(wds.play_count) as account_views,
+                            SUM(wds.like_count) as account_likes,
+                            SUM(wds.comment_count) as account_comments,
+                            SUM(wds.share_count) as account_shares,
+                            SUM(wds.collect_count) as account_collects
+                        FROM work_day_statistics wds
+                        INNER JOIN works w ON wds.work_id = w.id
+                        WHERE w.userId = %s
+                          AND wds.record_date >= DATE_SUB(CURDATE(), INTERVAL %s DAY)
+                """
+                params = [user_id, days]
+                
+                if account_id:
+                    sql += " AND w.accountId = %s"
+                    params.append(account_id)
+                
+                sql += """
+                        GROUP BY wds.record_date, w.accountId
+                    ) as account_stats
+                    GROUP BY record_date
+                    ORDER BY record_date ASC
+                """
+                
+                cursor.execute(sql, params)
+                results = cursor.fetchall()
+                
+                # 构建响应数据
+                dates = []
+                fans = []
+                views = []
+                likes = []
+                comments = []
+                shares = []
+                collects = []
+                
+                for row in results:
+                    # 格式化日期为 "MM-DD" 格式
+                    record_date = row['record_date']
+                    if isinstance(record_date, str):
+                        dates.append(record_date[5:10])  # "2026-01-16" -> "01-16"
+                    else:
+                        dates.append(record_date.strftime("%m-%d"))
+                    
+                    # 确保返回整数类型
+                    fans.append(int(row['total_fans'] or 0))
+                    views.append(int(row['total_views'] or 0))
+                    likes.append(int(row['total_likes'] or 0))
+                    comments.append(int(row['total_comments'] or 0))
+                    shares.append(int(row['total_shares'] or 0))
+                    collects.append(int(row['total_collects'] or 0))
+                
+                # 如果没有数据,生成空的日期范围
+                if not dates:
+                    from datetime import timedelta
+                    today = date.today()
+                    for i in range(days, 0, -1):
+                        d = today - timedelta(days=i-1)
+                        dates.append(d.strftime("%m-%d"))
+                        fans.append(0)
+                        views.append(0)
+                        likes.append(0)
+                        comments.append(0)
+                        shares.append(0)
+                        collects.append(0)
+                
+                return jsonify({
+                    "success": True,
+                    "data": {
+                        "dates": dates,
+                        "fans": fans,
+                        "views": views,
+                        "likes": likes,
+                        "comments": comments,
+                        "shares": shares,
+                        "collects": collects
+                    }
+                })
+        finally:
+            conn.close()
+            
+    except Exception as e:
+        traceback.print_exc()
+        return jsonify({"success": False, "error": str(e)}), 500
+
+
+@app.route("/work_day_statistics/platforms", methods=["GET"])
+def get_statistics_by_platform():
+    """
+    按平台分组获取统计数据(用于数据分析页面的平台对比)
+    
+    数据来源:
+    - 粉丝数:从 platform_accounts 表获取(账号级别数据)
+    - 播放量/点赞/评论/收藏:从 work_day_statistics 表按平台汇总
+    - 粉丝增量:通过比较区间内最早和最新的粉丝数计算
+    
+    查询参数:
+        user_id: 用户ID (必填)
+        days: 天数 (可选,默认30天,最大30天)
+    
+    响应:
+    {
+        "success": true,
+        "data": [
+            {
+                "platform": "douyin",
+                "fansCount": 1000,
+                "fansIncrease": 50,
+                "viewsCount": 5000,
+                "likesCount": 200,
+                "commentsCount": 30,
+                "collectsCount": 100
+            },
+            ...
+        ]
+    }
+    """
+    try:
+        user_id = request.args.get("user_id")
+        days = min(int(request.args.get("days", 30)), 30)
+        
+        if not user_id:
+            return jsonify({"success": False, "error": "缺少 user_id 参数"}), 400
+        
+        conn = get_db_connection()
+        try:
+            with conn.cursor() as cursor:
+                # 简化查询:按平台分组获取统计数据
+                # 1. 从 platform_accounts 获取当前粉丝数(注意:字段名是下划线命名 fans_count, user_id)
+                # 2. 从 work_day_statistics 获取播放量等累计数据
+                sql = """
+                    SELECT 
+                        pa.platform,
+                        pa.fans_count as current_fans,
+                        COALESCE(stats.total_views, 0) as viewsCount,
+                        COALESCE(stats.total_likes, 0) as likesCount,
+                        COALESCE(stats.total_comments, 0) as commentsCount,
+                        COALESCE(stats.total_collects, 0) as collectsCount,
+                        COALESCE(fans_change.earliest_fans, pa.fans_count) as earliest_fans
+                    FROM platform_accounts pa
+                    LEFT JOIN (
+                        -- 获取区间内的累计数据(按账号汇总)
+                        SELECT 
+                            w.accountId,
+                            SUM(wds.play_count) as total_views,
+                            SUM(wds.like_count) as total_likes,
+                            SUM(wds.comment_count) as total_comments,
+                            SUM(wds.collect_count) as total_collects
+                        FROM work_day_statistics wds
+                        INNER JOIN works w ON wds.work_id = w.id
+                        WHERE w.userId = %s
+                          AND wds.record_date >= DATE_SUB(CURDATE(), INTERVAL %s DAY)
+                        GROUP BY w.accountId
+                    ) stats ON pa.id = stats.accountId
+                    LEFT JOIN (
+                        -- 获取区间内最早一天的粉丝数
+                        SELECT 
+                            w.accountId,
+                            MAX(wds.fans_count) as earliest_fans
+                        FROM work_day_statistics wds
+                        INNER JOIN works w ON wds.work_id = w.id
+                        WHERE w.userId = %s
+                          AND wds.record_date = (
+                              SELECT MIN(wds2.record_date) 
+                              FROM work_day_statistics wds2 
+                              INNER JOIN works w2 ON wds2.work_id = w2.id 
+                              WHERE w2.userId = %s
+                                AND wds2.record_date >= DATE_SUB(CURDATE(), INTERVAL %s DAY)
+                          )
+                        GROUP BY w.accountId
+                    ) fans_change ON pa.id = fans_change.accountId
+                    WHERE pa.user_id = %s
+                    ORDER BY current_fans DESC
+                """
+                
+                cursor.execute(sql, [user_id, days, user_id, user_id, days, user_id])
+                results = cursor.fetchall()
+                
+                # 构建响应数据
+                platform_data = []
+                for row in results:
+                    current_fans = int(row['current_fans'] or 0)
+                    earliest_fans = int(row['earliest_fans'] or current_fans)
+                    fans_increase = current_fans - earliest_fans
+                    
+                    platform_data.append({
+                        "platform": row['platform'],
+                        "fansCount": current_fans,
+                        "fansIncrease": fans_increase,
+                        "viewsCount": int(row['viewsCount'] or 0),
+                        "likesCount": int(row['likesCount'] or 0),
+                        "commentsCount": int(row['commentsCount'] or 0),
+                        "collectsCount": int(row['collectsCount'] or 0),
+                    })
+                
+                print(f"[PlatformStats] 返回 {len(platform_data)} 个平台的数据")
+                
+                return jsonify({
+                    "success": True,
+                    "data": platform_data
+                })
+        finally:
+            conn.close()
+            
+    except Exception as e:
+        traceback.print_exc()
+        return jsonify({"success": False, "error": str(e)}), 500
+
+
 @app.route("/work_day_statistics/batch", methods=["POST"])
 def get_work_statistics_history():
     """

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


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


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


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

@@ -210,6 +210,7 @@ class BasePublisher(ABC):
     
     async def init_browser(self, storage_state: str = None):
         """初始化浏览器"""
+        print(f"[{self.platform_name}] init_browser: headless={self.headless}", flush=True)
         playwright = await async_playwright().start()
         self.browser = await playwright.chromium.launch(headless=self.headless)