浏览代码

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

Ethanfly 1 月之前
父节点
当前提交
5fe9e02ba9

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

@@ -32,6 +32,7 @@ declare module 'vue' {
     ElForm: typeof import('element-plus/es')['ElForm']
     ElFormItem: typeof import('element-plus/es')['ElFormItem']
     ElIcon: typeof import('element-plus/es')['ElIcon']
+    ElImage: typeof import('element-plus/es')['ElImage']
     ElInput: typeof import('element-plus/es')['ElInput']
     ElMain: typeof import('element-plus/es')['ElMain']
     ElMenu: typeof import('element-plus/es')['ElMenu']

+ 386 - 92
client/src/views/Analytics/Account/index.vue

@@ -107,6 +107,7 @@
             </div>
           </template>
         </el-table-column>
+        <!-- 收益与推荐量暂未接入,先隐藏
         <el-table-column prop="income" label="收益" width="100" align="center">
           <template #default="{ row }">
             <span>{{ row.income ?? '未支持' }}</span>
@@ -117,6 +118,7 @@
             <span>{{ row.recommendCount ?? '未支持' }}</span>
           </template>
         </el-table-column>
+        -->
         <el-table-column prop="viewsCount" label="阅读(播放)量" width="130" align="center">
           <template #default="{ row }">
             <span>{{ row.viewsCount ?? 0 }}</span>
@@ -162,7 +164,7 @@
     </div>
     
     <!-- 账号详情抽屉 -->
-    <el-drawer v-model="drawerVisible" :title="drawerTitle" size="50%">
+    <el-drawer v-model="drawerVisible" :title="drawerTitle" size="70%">
       <div v-if="selectedAccount" class="account-detail">
         <!-- 账号基本信息 -->
         <div class="detail-header">
@@ -177,32 +179,142 @@
             </div>
           </div>
         </div>
-        
-        <!-- 数据统计 -->
-        <div class="detail-stats">
-          <div class="stat-item">
-            <div class="stat-value">{{ formatNumber(selectedAccount.fansCount || 0) }}</div>
-            <div class="stat-label">粉丝数</div>
-          </div>
-          <div class="stat-item">
-            <div class="stat-value">{{ formatNumber(selectedAccount.viewsCount || 0) }}</div>
-            <div class="stat-label">播放量</div>
-          </div>
-          <div class="stat-item">
-            <div class="stat-value">{{ formatNumber(selectedAccount.likesCount || 0) }}</div>
-            <div class="stat-label">点赞数</div>
-          </div>
-          <div class="stat-item">
-            <div class="stat-value">{{ formatNumber(selectedAccount.commentsCount || 0) }}</div>
-            <div class="stat-label">评论数</div>
+
+        <!-- 顶部日期筛选 -->
+        <div class="detail-filter-bar">
+          <span class="filter-label">开始时间</span>
+          <el-date-picker
+            v-model="detailStartDate"
+            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="detailEndDate"
+            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="detailActiveQuickBtn === btn.value ? 'primary' : 'default'"
+              size="small"
+              @click="handleDetailQuickDate(btn.value)"
+            >
+              {{ btn.label }}
+            </el-button>
           </div>
+          <el-button type="primary" @click="loadAccountDetailData">查询</el-button>
         </div>
-        
-        <!-- 趋势图 -->
-        <div class="detail-chart">
-          <h4>数据趋势</h4>
-          <div ref="accountChartRef" style="height: 300px" v-loading="chartLoading"></div>
-        </div>
+
+        <!-- 详情 Tab -->
+        <el-tabs v-model="detailActiveTab">
+          <el-tab-pane label="数据" name="data">
+            <!-- 汇总统计 -->
+            <div class="detail-summary-cards">
+              <div class="stat-item">
+                <div class="stat-value">{{ detailSummary.viewsCount || 0 }}</div>
+                <div class="stat-label">播放(阅读)量</div>
+              </div>
+              <div class="stat-item">
+                <div class="stat-value">{{ detailSummary.commentsCount || 0 }}</div>
+                <div class="stat-label">评论量</div>
+              </div>
+              <div class="stat-item">
+                <div class="stat-value">{{ detailSummary.likesCount || 0 }}</div>
+                <div class="stat-label">点赞量</div>
+              </div>
+              <div class="stat-item">
+                <div class="stat-value">{{ detailSummary.fansIncrease || 0 }}</div>
+                <div class="stat-label">涨粉量</div>
+              </div>
+            </div>
+
+            <!-- 每日数据表格 -->
+            <el-table :data="detailDailyData" v-loading="detailLoading" stripe>
+              <el-table-column prop="date" label="时间" width="120" align="center" />
+              <el-table-column prop="income" label="收益" width="90" align="center">
+                <template #default="{ row }">
+                  <span>{{ row.income ?? 0 }}</span>
+                </template>
+              </el-table-column>
+              <el-table-column prop="recommendationCount" label="推荐量" width="90" align="center">
+                <template #default="{ row }">
+                  <span>{{ row.recommendationCount ?? 0 }}</span>
+                </template>
+              </el-table-column>
+              <el-table-column prop="viewsCount" label="播放(阅读)量" width="130" align="center">
+                <template #default="{ row }">
+                  <span>{{ row.viewsCount ?? 0 }}</span>
+                </template>
+              </el-table-column>
+              <el-table-column prop="commentsCount" label="评论量" width="90" align="center">
+                <template #default="{ row }">
+                  <span>{{ row.commentsCount ?? 0 }}</span>
+                </template>
+              </el-table-column>
+              <el-table-column prop="likesCount" label="点赞量" width="90" align="center">
+                <template #default="{ row }">
+                  <span>{{ row.likesCount ?? 0 }}</span>
+                </template>
+              </el-table-column>
+              <el-table-column prop="fansIncrease" label="涨粉量" width="90" align="center">
+                <template #default="{ row }">
+                  <span>{{ row.fansIncrease ?? 0 }}</span>
+                </template>
+              </el-table-column>
+            </el-table>
+          </el-tab-pane>
+
+          <el-tab-pane label="作品" name="works">
+            <el-table :data="detailWorks" v-loading="detailLoading" stripe>
+              <el-table-column label="标题" min-width="260">
+                <template #default="{ row }">
+                  <div class="work-title-cell">
+                    <div class="work-title-text">{{ row.title }}</div>
+                  </div>
+                </template>
+              </el-table-column>
+              <el-table-column prop="viewsCount" label="播放(阅读)量" width="130" align="center">
+                <template #default="{ row }">
+                  <span>{{ row.viewsCount ?? 0 }}</span>
+                </template>
+              </el-table-column>
+              <el-table-column prop="commentsCount" label="评论量" width="90" align="center">
+                <template #default="{ row }">
+                  <span>{{ row.commentsCount ?? 0 }}</span>
+                </template>
+              </el-table-column>
+              <el-table-column prop="likesCount" label="点赞量" width="90" align="center">
+                <template #default="{ row }">
+                  <span>{{ row.likesCount ?? 0 }}</span>
+                </template>
+              </el-table-column>
+              <el-table-column prop="sharesCount" label="分享量" width="90" align="center">
+                <template #default="{ row }">
+                  <span>{{ row.sharesCount ?? 0 }}</span>
+                </template>
+              </el-table-column>
+              <el-table-column prop="collectsCount" label="收藏量" width="90" align="center">
+                <template #default="{ row }">
+                  <span>{{ row.collectsCount ?? 0 }}</span>
+                </template>
+              </el-table-column>
+              <el-table-column prop="publishTime" label="发布时间" width="160" align="center">
+                <template #default="{ row }">
+                  <span class="publish-time">{{ formatTime(row.publishTime) }}</span>
+                </template>
+              </el-table-column>
+            </el-table>
+          </el-tab-pane>
+        </el-tabs>
       </div>
     </el-drawer>
   </div>
@@ -210,18 +322,22 @@
 
 <script setup lang="ts">
 import { ref, computed, onMounted, watch, nextTick } from 'vue';
+import { useRoute } from 'vue-router';
 import * as echarts from 'echarts';
-import { Search, User, Coin, Pointer, View, ChatDotRound, Star, TrendCharts } from '@element-plus/icons-vue';
+import { Search, User, View, ChatDotRound, Star, TrendCharts } from '@element-plus/icons-vue';
 import { PLATFORMS } from '@media-manager/shared';
 import type { PlatformType } from '@media-manager/shared';
-import { useAuthStore } from '@/stores/auth';
 import { ElMessage } from 'element-plus';
 import dayjs from 'dayjs';
 import request from '@/api/request';
-
-const PYTHON_API_URL = 'http://localhost:5005';
-
-const authStore = useAuthStore();
+import iconDefaultUrl from '@/assets/platforms/default.svg?url';
+import douyinIconUrl from '@/assets/platforms/douyin.svg?url';
+import xhsIconUrl from '@/assets/platforms/xiaohongshu.svg?url';
+import bilibiliIconUrl from '@/assets/platforms/bilibili.svg?url';
+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 route = useRoute();
 const loading = ref(false);
 const chartLoading = ref(false);
 
@@ -259,13 +375,16 @@ const availablePlatforms = computed(() => {
   }));
 });
 
-// 平台图标映射
+// 平台图标映射(与平台数据页保持一致,使用本地 SVG,避免外链被拦截)
+const iconDefault = iconDefaultUrl;
 const platformIcons: Record<string, string> = {
-  douyin: 'https://lf1-cdn-tos.bytescm.com/obj/static/ies/douyin_web/public/favicon.ico',
-  xiaohongshu: 'https://fe-video-qc.xhscdn.com/fe-platform/ed8fe603ce7e10bff8eb0d7c0a7bdf70cedf7f92.ico',
-  bilibili: 'https://www.bilibili.com/favicon.ico',
-  kuaishou: 'https://www.kuaishou.com/favicon.ico',
-  weixin: 'https://res.wx.qq.com/a/wx_fed/assets/res/NTI4MWU5.ico',
+  douyin: douyinIconUrl,
+  xiaohongshu: xhsIconUrl,
+  bilibili: bilibiliIconUrl,
+  kuaishou: kuaishouIconUrl,
+  weixin: weixinVideoIconUrl,
+  weixin_video: weixinVideoIconUrl,
+  baijiahao: baijiahaoIconUrl,
 };
 
 // 汇总统计
@@ -282,8 +401,10 @@ const summaryData = ref({
 // 统计卡片数据
 const summaryStats = computed(() => [
   { label: '账号总数', value: summaryData.value.totalAccounts, icon: User },
-  { label: '收益', value: summaryData.value.income, icon: Coin },
-  { label: '推荐量', value: summaryData.value.recommendCount, icon: Pointer },
+  // 后端暂未支持收益统计,先隐藏
+  // { label: '收益', value: summaryData.value.income, icon: Coin },
+  // 后端暂未支持推荐量统计,先隐藏
+  // { label: '推荐量', value: summaryData.value.recommendCount, icon: Pointer },
   { label: '播放(阅读)量', value: summaryData.value.viewsCount, icon: View },
   { label: '评论量', value: summaryData.value.commentsCount, icon: ChatDotRound },
   { label: '点赞量', value: summaryData.value.likesCount, icon: Star },
@@ -345,12 +466,52 @@ const drawerTitle = computed(() => {
   return `${selectedAccount.value.nickname} - 数据详情`;
 });
 
+// 详情 Tab 与数据
+const detailActiveTab = ref<'data' | 'works'>('data');
+const detailLoading = ref(false);
+const detailStartDate = ref(startDate.value);
+const detailEndDate = ref(endDate.value);
+const detailActiveQuickBtn = ref(activeQuickBtn.value);
+
+const detailSummary = ref({
+  income: 0,
+  recommendationCount: 0,
+  viewsCount: 0,
+  commentsCount: 0,
+  likesCount: 0,
+  fansIncrease: 0,
+});
+
+const detailDailyData = ref<Array<{
+  date: string;
+  income: number;
+  recommendationCount: number;
+  viewsCount: number;
+  commentsCount: number;
+  likesCount: number;
+  fansIncrease: number;
+}>>([]);
+
+const detailWorks = ref<Array<{
+  id: number;
+  title: string;
+  coverUrl: string;
+  platform: string;
+  publishTime: string | null;
+  recommendCount: number;
+  viewsCount: number;
+  commentsCount: number;
+  sharesCount: number;
+  collectsCount: number;
+  likesCount: number;
+}>>([]);
+
 function getPlatformName(platform: PlatformType) {
   return PLATFORMS[platform]?.name || platform;
 }
 
 function getPlatformIcon(platform: PlatformType) {
-  return platformIcons[platform] || '';
+  return platformIcons[platform] || iconDefault;
 }
 
 function formatNumber(num: number) {
@@ -360,7 +521,10 @@ function formatNumber(num: number) {
 
 function formatTime(time: string) {
   if (!time) return '-';
-  return dayjs(time).format('MM-DD HH:mm');
+  const d = dayjs(time);
+  if (!d.isValid()) return time;
+  const nowYear = dayjs().year();
+  return d.year() === nowYear ? d.format('MM-DD HH:mm') : d.format('YYYY-MM-DD HH:mm');
 }
 
 // 快捷日期选择
@@ -390,6 +554,9 @@ function handleQuickDate(type: string) {
       endDate.value = today.subtract(1, 'day').format('YYYY-MM-DD');
       break;
   }
+
+  // 选择快捷日期后,直接刷新数据列表,无需再点击“查询”
+  loadData();
 }
 
 // 查询
@@ -401,8 +568,11 @@ function handleQuery() {
 async function loadGroups() {
   try {
     const res = await request.get('/api/accounts/groups');
-    if (res.data.success) {
-      accountGroups.value = res.data.data || [];
+    if ((res as any).success && (res as any).data) {
+      accountGroups.value = (res as any).data || [];
+    } else if ((res as any).data?.success) {
+      // 兼容旧返回格式 { data: { success, data } }
+      accountGroups.value = (res as any).data.data || [];
     }
   } catch (error) {
     console.error('加载分组失败:', error);
@@ -411,34 +581,43 @@ async function loadGroups() {
 
 // 加载数据
 async function loadData() {
-  const userId = authStore.user?.id;
-  if (!userId) return;
-  
   loading.value = true;
   
   try {
-    const queryParams = new URLSearchParams({
-      user_id: userId.toString(),
-      start_date: startDate.value,
-      end_date: endDate.value,
-    });
-    
+    const params: Record<string, any> = {
+      startDate: startDate.value,
+      endDate: endDate.value,
+    };
+
     if (selectedPlatform.value) {
-      queryParams.append('platform', selectedPlatform.value);
+      params.platform = selectedPlatform.value;
     }
-    
-    const response = await fetch(`${PYTHON_API_URL}/work_day_statistics/accounts?${queryParams}`);
-    const result = await response.json();
-    
-    if (result.success && result.data) {
-      accounts.value = result.data.accounts || [];
-      
-      if (result.data.summary) {
-        summaryData.value = result.data.summary;
+    if (selectedGroup.value) {
+      params.groupId = selectedGroup.value;
+    }
+
+    const data = await request.get('/api/work-day-statistics/accounts', {
+      params,
+    });
+
+    if (data) {
+      accounts.value = (data.accounts || []) as AccountData[];
+
+      if (data.summary) {
+        summaryData.value = {
+          totalAccounts: data.summary.totalAccounts || 0,
+          income: 0,
+          recommendCount: null,
+          viewsCount: data.summary.viewsCount || 0,
+          commentsCount: data.summary.commentsCount || 0,
+          likesCount: data.summary.likesCount || 0,
+          fansIncrease: data.summary.fansIncrease || 0,
+        };
       }
     }
   } catch (error) {
     console.error('加载账号数据失败:', error);
+    ElMessage.error('加载账号数据失败,请稍后重试');
   } finally {
     loading.value = false;
   }
@@ -448,8 +627,17 @@ async function loadData() {
 async function handleDetail(row: AccountData) {
   selectedAccount.value = row;
   drawerVisible.value = true;
-  
+
+  // 初始化详情的时间范围与快捷按钮,与主筛选保持一致
+  detailStartDate.value = startDate.value;
+  detailEndDate.value = endDate.value;
+  detailActiveQuickBtn.value = activeQuickBtn.value;
+  detailActiveTab.value = 'data';
+
   await nextTick();
+  // 加载账号详情(数据 + 作品)
+  loadAccountDetailData();
+  // 同时加载趋势图
   loadAccountTrend(row.id);
 }
 
@@ -460,21 +648,23 @@ async function loadAccountTrend(accountId: number) {
   chartLoading.value = true;
   
   try {
-    const userId = authStore.user?.id;
-    if (!userId) return;
-    
-    const queryParams = new URLSearchParams({
-      user_id: userId.toString(),
-      account_id: accountId.toString(),
-      start_date: startDate.value,
-      end_date: endDate.value,
+    const trend = await request.get('/api/analytics/trend', {
+      params: {
+        startDate: startDate.value,
+        endDate: endDate.value,
+        accountId,
+      },
     });
-    
-    const response = await fetch(`${PYTHON_API_URL}/work_day_statistics/account_trend?${queryParams}`);
-    const result = await response.json();
-    
-    if (result.success && result.data) {
-      updateAccountChart(result.data);
+
+    if (trend) {
+      const dates = (trend.views || []).map((p: { date: string; value: number }) =>
+        (p.date || '').slice(5)
+      );
+      const fans = (trend.fans || []).map((p: { date: string; value: number }) => p.value || 0);
+      const views = (trend.views || []).map((p: { date: string; value: number }) => p.value || 0);
+      const likes = (trend.likes || []).map((p: { date: string; value: number }) => p.value || 0);
+
+      updateAccountChart({ dates, fans, views, likes });
     }
   } catch (error) {
     console.error('加载账号趋势失败:', error);
@@ -483,6 +673,72 @@ async function loadAccountTrend(accountId: number) {
   }
 }
 
+// 账号详情:快捷日期选择
+function handleDetailQuickDate(type: string) {
+  detailActiveQuickBtn.value = type;
+  const today = dayjs();
+
+  switch (type) {
+    case 'yesterday':
+      detailStartDate.value = today.subtract(1, 'day').format('YYYY-MM-DD');
+      detailEndDate.value = today.subtract(1, 'day').format('YYYY-MM-DD');
+      break;
+    case 'beforeYesterday':
+      detailStartDate.value = today.subtract(2, 'day').format('YYYY-MM-DD');
+      detailEndDate.value = today.subtract(2, 'day').format('YYYY-MM-DD');
+      break;
+    case 'last3days':
+      detailStartDate.value = today.subtract(3, 'day').format('YYYY-MM-DD');
+      detailEndDate.value = today.subtract(1, 'day').format('YYYY-MM-DD');
+      break;
+    case 'last7days':
+      detailStartDate.value = today.subtract(7, 'day').format('YYYY-MM-DD');
+      detailEndDate.value = today.subtract(1, 'day').format('YYYY-MM-DD');
+      break;
+    case 'lastMonth':
+      detailStartDate.value = today.subtract(30, 'day').format('YYYY-MM-DD');
+      detailEndDate.value = today.subtract(1, 'day').format('YYYY-MM-DD');
+      break;
+  }
+
+  loadAccountDetailData();
+}
+
+// 加载账号详情(汇总 + 每日 + 作品)
+async function loadAccountDetailData() {
+  if (!selectedAccount.value) return;
+
+  detailLoading.value = true;
+  try {
+    const data = await request.get('/api/work-day-statistics/account-detail', {
+      params: {
+        accountId: selectedAccount.value.id,
+        startDate: detailStartDate.value,
+        endDate: detailEndDate.value,
+      },
+    });
+
+    if (data) {
+      detailSummary.value = {
+        income: data.summary?.income ?? 0,
+        recommendationCount: data.summary?.recommendationCount ?? 0,
+        viewsCount: data.summary?.viewsCount ?? 0,
+        commentsCount: data.summary?.commentsCount ?? 0,
+        likesCount: data.summary?.likesCount ?? 0,
+        fansIncrease: data.summary?.fansIncrease ?? 0,
+      };
+
+      detailDailyData.value = Array.isArray(data.dailyData) ? data.dailyData : [];
+      detailWorks.value = Array.isArray(data.works) ? data.works : [];
+    }
+  } catch (error) {
+    console.error('加载账号详情失败:', error);
+    ElMessage.error('加载账号详情失败,请稍后重试');
+  } finally {
+    detailLoading.value = false;
+  }
+}
+
 // 更新账号趋势图
 function updateAccountChart(trendData: { dates: string[]; fans: number[]; views: number[]; likes: number[] }) {
   if (!accountChartRef.value) return;
@@ -529,10 +785,31 @@ watch(drawerVisible, (visible) => {
 });
 
 onMounted(() => {
-  // 默认选择昨天
-  handleQuickDate('yesterday');
+  // 如果从平台详情页跳转过来,优先使用路由上的时间范围
+  if (route.query.startDate) {
+    startDate.value = String(route.query.startDate);
+  }
+  if (route.query.endDate) {
+    endDate.value = String(route.query.endDate);
+  }
+
+  // 默认选择昨天(如果路由未指定时间)
+  if (!route.query.startDate && !route.query.endDate) {
+    handleQuickDate('yesterday');
+  }
+
   loadGroups();
-  loadData();
+  // 加载列表后,如有 accountId 参数,则自动打开对应账号详情
+  loadData().then(() => {
+    const accountIdParam = route.query.accountId;
+    if (accountIdParam) {
+      const targetId = Number(accountIdParam);
+      const target = accounts.value.find(a => a.id === targetId);
+      if (target) {
+        handleDetail(target);
+      }
+    }
+  });
 });
 </script>
 
@@ -710,25 +987,43 @@ onMounted(() => {
       }
     }
   }
-  
-  .detail-stats {
+
+  .detail-filter-bar {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    margin: 16px 0 12px 0;
+
+    .filter-label {
+      font-size: 14px;
+      color: $text-regular;
+    }
+
+    .quick-btns {
+      display: flex;
+      gap: 8px;
+      margin-left: 8px;
+    }
+  }
+
+  .detail-summary-cards {
     display: grid;
     grid-template-columns: repeat(4, 1fr);
     gap: 16px;
-    margin-bottom: 24px;
-    
+    margin-bottom: 16px;
+
     .stat-item {
       background: #f8fafc;
       border-radius: 12px;
       padding: 16px;
       text-align: center;
-      
+
       .stat-value {
-        font-size: 24px;
+        font-size: 20px;
         font-weight: 600;
         color: $primary-color;
       }
-      
+
       .stat-label {
         font-size: 13px;
         color: $text-secondary;
@@ -736,11 +1031,10 @@ onMounted(() => {
       }
     }
   }
-  
-  .detail-chart {
-    h4 {
-      margin: 0 0 16px 0;
-      font-size: 15px;
+
+  .work-title-cell {
+    .work-title-text {
+      font-weight: 500;
       color: $text-primary;
     }
   }

+ 4 - 1
client/src/views/Analytics/Overview/index.vue

@@ -292,7 +292,10 @@ function formatNumber(num: number) {
 
 function formatTime(time: string) {
   if (!time) return '-';
-  return dayjs(time).format('MM-DD HH:mm');
+  const d = dayjs(time);
+  if (!d.isValid()) return time;
+  const nowYear = dayjs().year();
+  return d.year() === nowYear ? d.format('MM-DD HH:mm') : d.format('YYYY-MM-DD HH:mm');
 }
 
 // 加载分组列表

+ 4 - 1
client/src/views/Analytics/Platform/index.vue

@@ -239,7 +239,10 @@ function formatNumber(num: number) {
 
 function formatTime(time: string) {
   if (!time) return '-';
-  return dayjs(time).format('MM-DD HH:mm');
+  const d = dayjs(time);
+  if (!d.isValid()) return time;
+  const nowYear = dayjs().year();
+  return d.year() === nowYear ? d.format('MM-DD HH:mm') : d.format('YYYY-MM-DD HH:mm');
 }
 
 // 快捷日期选择

+ 403 - 9
client/src/views/Analytics/PlatformDetail/index.vue

@@ -161,6 +161,160 @@
         </el-table-column>
       </el-table>
     </div>
+
+    <!-- 账号详情抽屉(直接在平台详情页弹出) -->
+    <el-drawer v-model="accountDetailVisible" :title="accountDetailTitle" size="70%">
+      <div v-if="selectedAccountDetail" class="account-detail">
+        <!-- 账号基本信息 -->
+        <div class="detail-header">
+          <el-avatar :size="64" :src="selectedAccountDetail.avatarUrl">
+            {{ selectedAccountDetail.nickname?.[0] || selectedAccountDetail.username?.[0] }}
+          </el-avatar>
+          <div class="header-info">
+            <h3>{{ selectedAccountDetail.nickname || selectedAccountDetail.username }}</h3>
+            <div class="platform-info">
+              <span>{{ getPlatformName(selectedAccountDetail.platform) }}</span>
+            </div>
+          </div>
+        </div>
+
+        <!-- 顶部日期筛选 -->
+        <div class="detail-filter-bar">
+          <span class="filter-label">开始时间</span>
+          <el-date-picker
+            v-model="detailStartDate"
+            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="detailEndDate"
+            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="detailActiveQuickBtn === btn.value ? 'primary' : 'default'"
+              size="small"
+              @click="handleDetailQuickDate(btn.value)"
+            >
+              {{ btn.label }}
+            </el-button>
+          </div>
+          <el-button type="primary" @click="loadAccountDetailData">查询</el-button>
+        </div>
+
+        <!-- 详情 Tab -->
+        <el-tabs v-model="detailActiveTab">
+          <el-tab-pane label="数据" name="data">
+            <!-- 汇总统计 -->
+            <div class="detail-summary-cards">
+              <div class="stat-item">
+                <div class="stat-value">{{ detailSummary.viewsCount || 0 }}</div>
+                <div class="stat-label">播放(阅读)量</div>
+              </div>
+              <div class="stat-item">
+                <div class="stat-value">{{ detailSummary.commentsCount || 0 }}</div>
+                <div class="stat-label">评论量</div>
+              </div>
+              <div class="stat-item">
+                <div class="stat-value">{{ detailSummary.likesCount || 0 }}</div>
+                <div class="stat-label">点赞量</div>
+              </div>
+              <div class="stat-item">
+                <div class="stat-value">{{ detailSummary.fansIncrease || 0 }}</div>
+                <div class="stat-label">涨粉量</div>
+              </div>
+            </div>
+
+            <!-- 每日数据表格 -->
+            <el-table :data="detailDailyData" v-loading="detailLoading" stripe>
+              <el-table-column prop="date" label="时间" width="120" align="center" />
+              <el-table-column prop="income" label="收益" width="90" align="center">
+                <template #default="{ row }">
+                  <span>{{ row.income ?? 0 }}</span>
+                </template>
+              </el-table-column>
+              <el-table-column prop="recommendationCount" label="推荐量" width="90" align="center">
+                <template #default="{ row }">
+                  <span>{{ row.recommendationCount ?? 0 }}</span>
+                </template>
+              </el-table-column>
+              <el-table-column prop="viewsCount" label="播放(阅读)量" width="130" align="center">
+                <template #default="{ row }">
+                  <span>{{ row.viewsCount ?? 0 }}</span>
+                </template>
+              </el-table-column>
+              <el-table-column prop="commentsCount" label="评论量" width="90" align="center">
+                <template #default="{ row }">
+                  <span>{{ row.commentsCount ?? 0 }}</span>
+                </template>
+              </el-table-column>
+              <el-table-column prop="likesCount" label="点赞量" width="90" align="center">
+                <template #default="{ row }">
+                  <span>{{ row.likesCount ?? 0 }}</span>
+                </template>
+              </el-table-column>
+              <el-table-column prop="fansIncrease" label="涨粉量" width="90" align="center">
+                <template #default="{ row }">
+                  <span>{{ row.fansIncrease ?? 0 }}</span>
+                </template>
+              </el-table-column>
+            </el-table>
+          </el-tab-pane>
+
+          <el-tab-pane label="作品" name="works">
+            <el-table :data="detailWorks" v-loading="detailLoading" stripe>
+              <el-table-column label="标题" min-width="260">
+                <template #default="{ row }">
+                  <div class="work-title-cell">
+                    <div class="work-title-text">{{ row.title }}</div>
+                  </div>
+                </template>
+              </el-table-column>
+              <el-table-column prop="viewsCount" label="播放(阅读)量" width="130" align="center">
+                <template #default="{ row }">
+                  <span>{{ row.viewsCount ?? 0 }}</span>
+                </template>
+              </el-table-column>
+              <el-table-column prop="commentsCount" label="评论量" width="90" align="center">
+                <template #default="{ row }">
+                  <span>{{ row.commentsCount ?? 0 }}</span>
+                </template>
+              </el-table-column>
+              <el-table-column prop="likesCount" label="点赞量" width="90" align="center">
+                <template #default="{ row }">
+                  <span>{{ row.likesCount ?? 0 }}</span>
+                </template>
+              </el-table-column>
+              <el-table-column prop="sharesCount" label="分享量" width="90" align="center">
+                <template #default="{ row }">
+                  <span>{{ row.sharesCount ?? 0 }}</span>
+                </template>
+              </el-table-column>
+              <el-table-column prop="collectsCount" label="收藏量" width="90" align="center">
+                <template #default="{ row }">
+                  <span>{{ row.collectsCount ?? 0 }}</span>
+                </template>
+              </el-table-column>
+              <el-table-column prop="publishTime" label="发布时间" width="160" align="center">
+                <template #default="{ row }">
+                  <span class="publish-time">{{ formatTime(row.publishTime) }}</span>
+                </template>
+              </el-table-column>
+            </el-table>
+          </el-tab-pane>
+        </el-tabs>
+      </div>
+    </el-drawer>
   </div>
 </template>
 
@@ -248,6 +402,88 @@ const accountList = ref<Array<{
   updateTime: string;
 }>>([]);
 
+// 单个账号详情(在本页抽屉中展示)
+const accountDetailVisible = ref(false);
+const selectedAccountDetail = ref<{
+  id: number;
+  nickname: string;
+  username: string;
+  avatarUrl: string | null;
+  platform: string;
+} | null>(null);
+
+const accountDetailTitle = computed(() => {
+  if (!selectedAccountDetail.value) return '账号详情';
+  return `${selectedAccountDetail.value.nickname || selectedAccountDetail.value.username} - 数据详情`;
+});
+
+const detailActiveTab = ref<'data' | 'works'>('data');
+const detailLoading = ref(false);
+const detailStartDate = ref(startDate.value);
+const detailEndDate = ref(endDate.value);
+const detailActiveQuickBtn = ref(activeQuickBtn.value);
+
+const detailSummary = ref({
+  income: 0,
+  recommendationCount: 0,
+  viewsCount: 0,
+  commentsCount: 0,
+  likesCount: 0,
+  fansIncrease: 0,
+});
+
+const detailDailyData = ref<Array<{
+  date: string;
+  income: number;
+  recommendationCount: number;
+  viewsCount: number;
+  commentsCount: number;
+  likesCount: number;
+  fansIncrease: number;
+}>>([]);
+
+const detailWorks = ref<Array<{
+  id: number;
+  title: string;
+  coverUrl: string;
+  platform: string;
+  publishTime: string | null;
+  recommendCount: number;
+  viewsCount: number;
+  commentsCount: number;
+  sharesCount: number;
+  collectsCount: number;
+  likesCount: number;
+}>>([]);
+
+// 根据当前 startDate / endDate 自动同步快捷按钮高亮状态
+function syncQuickBtnByRange() {
+  const today = dayjs();
+  const s = dayjs(startDate.value);
+  const e = dayjs(endDate.value);
+
+  // 只处理“单日”场景,区间场景不高亮任何快捷按钮
+  if (!s.isValid() || !e.isValid() || !s.isSame(e, 'day')) {
+    activeQuickBtn.value = '';
+    return;
+  }
+
+  const diff = today.startOf('day').diff(s.startOf('day'), 'day');
+  if (diff === 1) {
+    // 昨天
+    activeQuickBtn.value = 'yesterday';
+  } else if (diff === 2) {
+    // 前天
+    activeQuickBtn.value = 'beforeYesterday';
+  } else if (diff === 2 && today.isSame(e.add(2, 'day'), 'day')) {
+    // 近三天(含今天):start = today - 2, end = today
+    activeQuickBtn.value = 'last3days';
+  } else {
+    // 其他情况不自动匹配
+    activeQuickBtn.value = '';
+  }
+}
+
 function getPlatformName(platform: string) {
   return PLATFORMS[platform as PlatformType]?.name || platform;
 }
@@ -257,6 +493,14 @@ function formatNumber(num: number) {
   return num.toString();
 }
 
+function formatTime(time: string) {
+  if (!time) return '-';
+  const d = dayjs(time);
+  if (!d.isValid()) return time;
+  const nowYear = dayjs().year();
+  return d.year() === nowYear ? d.format('MM-DD HH:mm') : d.format('YYYY-MM-DD HH:mm');
+}
+
 // 快捷日期选择
 function handleQuickDate(type: string) {
   activeQuickBtn.value = type;
@@ -382,14 +626,88 @@ function handleExport() {
 
 // 账号详情
 function handleAccountDetail(row: any) {
-  router.push({
-    name: 'AnalyticsAccount',
-    query: {
-      accountId: row.id,
-      startDate: startDate.value,
-      endDate: endDate.value,
-    },
-  });
+  selectedAccountDetail.value = {
+    id: row.id,
+    nickname: row.nickname,
+    username: row.username,
+    avatarUrl: row.avatarUrl,
+    platform: row.platform,
+  };
+  accountDetailVisible.value = true;
+
+  // 初始化详情时间范围与快捷按钮
+  detailStartDate.value = startDate.value;
+  detailEndDate.value = endDate.value;
+  detailActiveQuickBtn.value = activeQuickBtn.value;
+  detailActiveTab.value = 'data';
+
+  loadAccountDetailData();
+}
+
+// 账号详情:快捷日期选择
+function handleDetailQuickDate(type: string) {
+  detailActiveQuickBtn.value = type;
+  const today = dayjs();
+
+  switch (type) {
+    case 'yesterday':
+      detailStartDate.value = today.subtract(1, 'day').format('YYYY-MM-DD');
+      detailEndDate.value = today.subtract(1, 'day').format('YYYY-MM-DD');
+      break;
+    case 'beforeYesterday':
+      detailStartDate.value = today.subtract(2, 'day').format('YYYY-MM-DD');
+      detailEndDate.value = today.subtract(2, 'day').format('YYYY-MM-DD');
+      break;
+    case 'last3days':
+      detailStartDate.value = today.subtract(3, 'day').format('YYYY-MM-DD');
+      detailEndDate.value = today.subtract(1, 'day').format('YYYY-MM-DD');
+      break;
+    case 'last7days':
+      detailStartDate.value = today.subtract(7, 'day').format('YYYY-MM-DD');
+      detailEndDate.value = today.subtract(1, 'day').format('YYYY-MM-DD');
+      break;
+    case 'lastMonth':
+      detailStartDate.value = today.subtract(30, 'day').format('YYYY-MM-DD');
+      detailEndDate.value = today.subtract(1, 'day').format('YYYY-MM-DD');
+      break;
+  }
+
+  loadAccountDetailData();
+}
+
+// 加载账号详情(汇总 + 每日 + 作品)
+async function loadAccountDetailData() {
+  if (!selectedAccountDetail.value) return;
+
+  detailLoading.value = true;
+  try {
+    const data = await request.get('/api/work-day-statistics/account-detail', {
+      params: {
+        accountId: selectedAccountDetail.value.id,
+        startDate: detailStartDate.value,
+        endDate: detailEndDate.value,
+      },
+    });
+
+    if (data) {
+      detailSummary.value = {
+        income: data.summary?.income ?? 0,
+        recommendationCount: data.summary?.recommendationCount ?? 0,
+        viewsCount: data.summary?.viewsCount ?? 0,
+        commentsCount: data.summary?.commentsCount ?? 0,
+        likesCount: data.summary?.likesCount ?? 0,
+        fansIncrease: data.summary?.fansIncrease ?? 0,
+      };
+
+      detailDailyData.value = Array.isArray(data.dailyData) ? data.dailyData : [];
+      detailWorks.value = Array.isArray(data.works) ? data.works : [];
+    }
+  } catch (error) {
+    console.error('[PlatformDetail] 加载账号详情失败:', error);
+    ElMessage.error('加载账号详情失败,请稍后重试');
+  } finally {
+    detailLoading.value = false;
+  }
 }
 
 // 初始化数据加载
@@ -409,7 +727,10 @@ function initDataLoad() {
     if (route.query.endDate) {
       endDate.value = route.query.endDate as string;
     }
-    
+
+    // 同步一次快捷日期按钮的高亮状态,避免始终显示“昨天”
+    syncQuickBtnByRange();
+
     console.log('[PlatformDetail] 准备加载数据, selectedPlatform:', selectedPlatform.value, 'startDate:', startDate.value, 'endDate:', endDate.value);
     
     // 使用 nextTick 确保响应式更新完成
@@ -558,5 +879,78 @@ onMounted(() => {
       color: $text-secondary;
     }
   }
+
+  .account-detail {
+    .detail-header {
+      display: flex;
+      align-items: center;
+      gap: 16px;
+      margin-bottom: 24px;
+
+      .header-info {
+        h3 {
+          margin: 0 0 8px 0;
+          font-size: 18px;
+        }
+
+        .platform-info {
+          margin-top: 4px;
+          font-size: 14px;
+          color: $text-secondary;
+        }
+      }
+    }
+
+    .detail-filter-bar {
+      display: flex;
+      align-items: center;
+      gap: 12px;
+      margin: 16px 0 12px 0;
+
+      .filter-label {
+        font-size: 14px;
+        color: $text-regular;
+      }
+
+      .quick-btns {
+        display: flex;
+        gap: 8px;
+        margin-left: 8px;
+      }
+    }
+
+    .detail-summary-cards {
+      display: grid;
+      grid-template-columns: repeat(4, 1fr);
+      gap: 16px;
+      margin-bottom: 16px;
+
+      .stat-item {
+        background: #f8fafc;
+        border-radius: 12px;
+        padding: 16px;
+        text-align: center;
+
+        .stat-value {
+          font-size: 20px;
+          font-weight: 600;
+          color: $primary-color;
+        }
+
+        .stat-label {
+          font-size: 13px;
+          color: $text-secondary;
+          margin-top: 4px;
+        }
+      }
+    }
+
+    .work-title-cell {
+      .work-title-text {
+        font-weight: 500;
+        color: $text-primary;
+      }
+    }
+  }
 }
 </style>

+ 56 - 35
client/src/views/Analytics/Work/index.vue

@@ -88,12 +88,9 @@
             :value="platform.value"
           />
         </el-select>
+        <!-- 排序统一按发布时间倒序,这里仅保留一个选项以避免误解 -->
         <el-select v-model="sortBy" style="width: 160px">
-          <el-option label="按发布时间顺序排列" value="publish_desc" />
-          <el-option label="按发布时间倒序排列" value="publish_asc" />
-          <el-option label="按阅读量排序" value="views_desc" />
-          <el-option label="按点赞量排序" value="likes_desc" />
-          <el-option label="按评论量排序" value="comments_desc" />
+          <el-option label="按发布时间倒序排列" value="publish_desc" />
         </el-select>
         <el-input 
           v-model="searchKeyword" 
@@ -145,11 +142,13 @@
             </div>
           </template>
         </el-table-column>
+        <!-- 类型列暂时不展示
         <el-table-column prop="workType" label="类型" width="80" align="center">
           <template #default="{ row }">
             <span>{{ row.workType || '动态' }}</span>
           </template>
         </el-table-column>
+        -->
         <el-table-column prop="publishTime" label="发布时间" width="160" align="center">
           <template #default="{ row }">
             <span class="publish-time">{{ formatTime(row.publishTime) }}</span>
@@ -247,8 +246,6 @@ import { ElMessage } from 'element-plus';
 import dayjs from 'dayjs';
 import request from '@/api/request';
 
-const PYTHON_API_URL = 'http://localhost:5005';
-
 const authStore = useAuthStore();
 const loading = ref(false);
 
@@ -359,7 +356,11 @@ function getPlatformTagType(platform: PlatformType) {
 
 function formatTime(time: string) {
   if (!time) return '-';
-  return dayjs(time).format('YYYY-MM-DD HH:mm:ss');
+  const d = dayjs(time);
+  if (!d.isValid()) return time;
+  const nowYear = dayjs().year();
+  // 今年:01-22 10:22,往年:2025-12-22 10:22
+  return d.year() === nowYear ? d.format('MM-DD HH:mm') : d.format('YYYY-MM-DD HH:mm');
 }
 
 // 快捷日期选择
@@ -392,10 +393,17 @@ function handleQuery() {
 async function loadAccountList() {
   try {
     const res = await request.get('/api/accounts');
-    if (res.data.success) {
-      accountList.value = (res.data.data || []).map((a: any) => ({
+    // 新接口:request 已经解包,res 就是账号数组
+    if (Array.isArray(res)) {
+      accountList.value = res.map((a: any) => ({
         id: a.id,
-        nickname: a.nickname || a.username,
+        nickname: a.accountName || a.nickname || a.username,
+      }));
+    } else if ((res as any)?.data?.data) {
+      // 兼容旧格式 { data: { data: [] } }
+      accountList.value = (res as any).data.data.map((a: any) => ({
+        id: a.id,
+        nickname: a.accountName || a.nickname || a.username,
       }));
     }
   } catch (error) {
@@ -423,40 +431,53 @@ async function loadData() {
   loading.value = true;
   
   try {
-    const queryParams = new URLSearchParams({
-      user_id: userId.toString(),
-      start_date: startDate.value,
-      end_date: endDate.value,
-      page: currentPage.value.toString(),
-      page_size: pageSize.value.toString(),
-      sort_by: sortBy.value,
-    });
-    
+    const params: Record<string, any> = {
+      startDate: startDate.value,
+      endDate: endDate.value,
+      page: currentPage.value,
+      pageSize: pageSize.value,
+      sortBy: sortBy.value,
+    };
+
     if (selectedPlatform.value) {
-      queryParams.append('platform', selectedPlatform.value);
+      params.platform = selectedPlatform.value;
     }
-    
+
     if (selectedAccounts.value.length > 0) {
-      queryParams.append('account_ids', selectedAccounts.value.join(','));
+      params.accountIds = selectedAccounts.value.join(',');
     }
-    
+
     if (searchKeyword.value) {
-      queryParams.append('keyword', searchKeyword.value);
+      params.keyword = searchKeyword.value;
     }
-    
-    const response = await fetch(`${PYTHON_API_URL}/work_day_statistics/works?${queryParams}`);
-    const result = await response.json();
-    
-    if (result.success && result.data) {
-      workList.value = result.data.works || [];
-      totalWorks.value = result.data.total || 0;
-      
-      if (result.data.summary) {
-        summaryData.value = result.data.summary;
+
+    if (selectedGroup.value) {
+      params.groupId = selectedGroup.value;
+    }
+
+    const data = await request.get('/api/work-day-statistics/works', {
+      params,
+    });
+
+    if (data) {
+      workList.value = (data.works || []) as WorkData[];
+      totalWorks.value = data.total || 0;
+
+      if (data.summary) {
+        summaryData.value = {
+          totalWorks: data.summary.totalWorks || 0,
+          recommendCount: data.summary.recommendCount || 0,
+          viewsCount: data.summary.viewsCount || 0,
+          commentsCount: data.summary.commentsCount || 0,
+          sharesCount: data.summary.sharesCount || 0,
+          collectsCount: data.summary.collectsCount || 0,
+          likesCount: data.summary.likesCount || 0,
+        };
       }
     }
   } catch (error) {
     console.error('加载作品数据失败:', error);
+    ElMessage.error('加载作品数据失败,请稍后重试');
   } finally {
     loading.value = false;
   }

+ 25 - 25
server/python/export_platform_statistics_xlsx.py

@@ -78,53 +78,53 @@ def _safe_str(v) -> str:
 
 
 def _format_update_time(value: str) -> str:
-  """
-  格式化更新时间为 "MM-DD HH:mm" 格式。
-  如果已经是该格式,直接返回;否则尝试解析并转换。
+  """\
+  格式化更新时间为统一的人类可读格式:
+  - 如果是今年:MM-DD HH:mm
+  - 如果是往年:YYYY-MM-DD HH:mm\
   """
   if not value:
     return ""
 
   s = str(value).strip()
-  
-  # 如果已经是 "MM-DD HH:mm" 格式,直接返回
-  if len(s) == 14 and s[2] == '-' and s[5] == ' ' and s[8] == ':':
-    return s
-  
-  # 尝试解析 ISO 格式或其他常见格式
+
+  # 尝试解析 ISO 或常见时间格式
   try:
-    # 处理 ISO 格式(如 "2026-01-28T12:22:00Z")
     if s.endswith("Z"):
       s_clean = s[:-1]
     else:
       s_clean = s
     s_clean = s_clean.replace(" ", "T")
     dt = datetime.fromisoformat(s_clean)
-    return dt.strftime("%m-%d %H:%M")
+    now_year = datetime.now().year
+    if dt.year == now_year:
+      return dt.strftime("%m-%d %H:%M")
+    return dt.strftime("%Y-%m-%d %H:%M")
   except Exception:
     pass
-  
-  # 如果无法解析,尝试提取日期和时间部分
-  # 格式可能是 "YYYY-MM-DD HH:mm:ss" 或类似
+
+  # 尝试从 "YYYY-MM-DD HH:mm:ss" 中拆分
   try:
-    # 尝试匹配 "YYYY-MM-DD HH:mm:ss" 格式
     if len(s) >= 16:
-      parts = s.split(' ')
+      parts = s.split(" ")
       if len(parts) >= 2:
-        date_part = parts[0]  # "YYYY-MM-DD"
-        time_part = parts[1]  # "HH:mm:ss" 或 "HH:mm"
-        date_parts = date_part.split('-')
-        time_parts = time_part.split(':')
+        date_part = parts[0]
+        time_part = parts[1]
+        date_parts = date_part.split("-")
+        time_parts = time_part.split(":")
         if len(date_parts) >= 3 and len(time_parts) >= 2:
-          month = date_parts[1]
-          day = date_parts[2]
+          year = int(date_parts[0])
+          month = date_parts[1].zfill(2)
+          day = date_parts[2].zfill(2)
           hour = time_parts[0].zfill(2)
           minute = time_parts[1].zfill(2)
-          return f"{month}-{day} {hour}:{minute}"
+          now_year = datetime.now().year
+          if year == now_year:
+            return f"{month}-{day} {hour}:{minute}"
+          return f"{year}-{month}-{day} {hour}:{minute}"
   except Exception:
     pass
-  
-  # 如果都失败了,返回原字符串
+
   return s
 
 

+ 34 - 12
server/python/export_work_day_overview_xlsx.py

@@ -97,32 +97,54 @@ def _safe_str(v) -> str:
   return "".join(ch for ch in s if not (0xD800 <= ord(ch) <= 0xDFFF))
 
 
-def _format_date_only(value: str) -> str:
-  """
-  将各种时间字符串格式化为 YYYY-MM-DD,仅保留日期部分。
-  如果无法解析,则尽量取前 10 位作为日期返回。
+def _format_datetime_pretty(value: str) -> str:
+  """\
+  将时间字符串格式化为统一的人类可读格式:
+  - 如果是今年:MM-DD HH:mm
+  - 如果是往年:YYYY-MM-DD HH:mm
+  解析失败时,尽量返回原始字符串。\
   """
   if not value:
     return ""
 
   s = str(value).strip()
-  # 先尝试 ISO 格式解析
+
+  # 尝试按 ISO 格式解析("2026-01-28T12:22:00Z" / "2026-01-28 12:22:00" 等)
   try:
-    # 兼容 "...Z" 结尾
     if s.endswith("Z"):
       s_clean = s[:-1]
     else:
       s_clean = s
-    # 如果中间是 ' ',替换成 'T'
     s_clean = s_clean.replace(" ", "T")
     dt = datetime.fromisoformat(s_clean)
-    return dt.strftime("%Y-%m-%d")
+    now_year = datetime.now().year
+    if dt.year == now_year:
+      return dt.strftime("%m-%d %H:%M")
+    return dt.strftime("%Y-%m-%d %H:%M")
   except Exception:
     pass
 
-  # 退化处理:如果长度>=10,优先取前 10 位
-  if len(s) >= 10:
-    return s[:10]
+  # 退化处理:尝试从类似 "YYYY-MM-DD HH:mm:ss" 的字符串中拆分
+  try:
+    if len(s) >= 16:
+      parts = s.split(" ")
+      if len(parts) >= 2:
+        date_part = parts[0]
+        time_part = parts[1]
+        date_parts = date_part.split("-")
+        time_parts = time_part.split(":")
+        if len(date_parts) >= 3 and len(time_parts) >= 2:
+          year = int(date_parts[0])
+          month = date_parts[1].zfill(2)
+          day = date_parts[2].zfill(2)
+          hour = time_parts[0].zfill(2)
+          minute = time_parts[1].zfill(2)
+          now_year = datetime.now().year
+          if year == now_year:
+            return f"{month}-{day} {hour}:{minute}"
+          return f"{year}-{month}-{day} {hour}:{minute}"
+  except Exception:
+    pass
 
   return s
 
@@ -175,7 +197,7 @@ def build_xlsx(accounts):
       _safe_int(a.get("yesterdayComments")) or 0,
       _safe_int(a.get("yesterdayLikes")) or 0,
       _safe_int(a.get("yesterdayFansIncrease")) or 0,
-      _safe_str(_format_date_only(a.get("updateTime"))),
+      _safe_str(_format_datetime_pretty(a.get("updateTime"))),
     ])
 
   int_cols = {"C", "D", "E", "F", "G", "H"}

二进制
server/python/platforms/__pycache__/baijiahao.cpython-311.pyc


二进制
server/python/platforms/__pycache__/weixin.cpython-311.pyc


二进制
server/python/platforms/__pycache__/xiaohongshu.cpython-311.pyc


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

@@ -284,5 +284,135 @@ router.get(
   })
 );
 
+/**
+ * GET /api/work-day-statistics/accounts
+ * 获取账号维度的区间统计数据(供「账号数据」页使用)
+ *
+ * 查询参数:
+ * - startDate: 开始日期(必填)
+ * - endDate: 结束日期(必填)
+ * - platform: 平台(可选)
+ * - groupId: 分组ID(可选)
+ */
+router.get(
+  '/accounts',
+  [
+    query('startDate').notEmpty().withMessage('startDate 不能为空'),
+    query('endDate').notEmpty().withMessage('endDate 不能为空'),
+    query('platform').optional().isString().withMessage('platform 必须是字符串'),
+    query('groupId').optional().isInt().withMessage('groupId 必须是整数'),
+    validateRequest,
+  ],
+  asyncHandler(async (req, res) => {
+    const { startDate, endDate, platform } = req.query;
+    const groupId = req.query.groupId ? Number(req.query.groupId) : undefined;
+
+    const data = await workDayStatisticsService.getAccountsAnalytics(req.user!.userId, {
+      startDate: startDate as string,
+      endDate: endDate as string,
+      platform: (platform as string) || undefined,
+      groupId,
+    });
+
+    res.json({ success: true, data });
+  })
+);
+
+/**
+ * GET /api/work-day-statistics/account-detail
+ * 获取单个账号的详情数据(账号维度)
+ *
+ * 查询参数:
+ * - accountId: 账号 ID(必填)
+ * - startDate: 开始日期(必填)
+ * - endDate: 结束日期(必填)
+ */
+router.get(
+  '/account-detail',
+  [
+    query('accountId').notEmpty().withMessage('accountId 不能为空'),
+    query('startDate').notEmpty().withMessage('startDate 不能为空'),
+    query('endDate').notEmpty().withMessage('endDate 不能为空'),
+    validateRequest,
+  ],
+  asyncHandler(async (req, res) => {
+    const { accountId, startDate, endDate } = req.query;
+    const userId = req.user!.userId;
+
+    const data = await workDayStatisticsService.getAccountDetail(userId, Number(accountId), {
+      startDate: startDate as string,
+      endDate: endDate as string,
+    });
+
+    res.json({ success: true, data });
+  })
+);
+
+/**
+ * GET /api/work-day-statistics/works
+ * 获取作品数据列表(用于「作品数据」页)
+ *
+ * 查询参数:
+ * - startDate: 开始日期(必填)
+ * - endDate: 结束日期(必填)
+ * - platform: 平台(可选)
+ * - accountIds: 账号 ID 列表,逗号分隔(可选)
+ * - groupId: 分组 ID(可选)
+ * - keyword: 标题/账号关键字(可选)
+ * - sortBy: 排序字段(publish_desc/publish_asc/views_desc/likes_desc/comments_desc)
+ * - page: 页码(默认 1)
+ * - pageSize: 每页条数(默认 20)
+ */
+router.get(
+  '/works',
+  [
+    query('startDate').notEmpty().withMessage('startDate 不能为空'),
+    query('endDate').notEmpty().withMessage('endDate 不能为空'),
+    query('platform').optional().isString().withMessage('platform 必须是字符串'),
+    query('accountIds').optional().isString().withMessage('accountIds 必须是字符串'),
+    query('groupId').optional().isInt().withMessage('groupId 必须是整数'),
+    query('keyword').optional().isString().withMessage('keyword 必须是字符串'),
+    query('sortBy').optional().isString().withMessage('sortBy 必须是字符串'),
+    query('page').optional().isInt().withMessage('page 必须是整数'),
+    query('pageSize').optional().isInt().withMessage('pageSize 必须是整数'),
+    validateRequest,
+  ],
+  asyncHandler(async (req, res) => {
+    const {
+      startDate,
+      endDate,
+      platform,
+      accountIds,
+      groupId,
+      keyword,
+      sortBy,
+      page,
+      pageSize,
+    } = req.query;
+
+    const parsedAccountIds =
+      typeof accountIds === 'string' && accountIds.trim()
+        ? accountIds
+            .split(',')
+            .map((id) => Number(id))
+            .filter((id) => !Number.isNaN(id))
+        : undefined;
+
+    const data = await workDayStatisticsService.getWorksAnalytics(req.user!.userId, {
+      startDate: String(startDate),
+      endDate: String(endDate),
+      platform: (platform as string) || undefined,
+      accountIds: parsedAccountIds,
+      groupId: groupId ? Number(groupId) : undefined,
+      keyword: (keyword as string) || undefined,
+      sortBy: (sortBy as any) || undefined,
+      page: page ? Number(page) : undefined,
+      pageSize: pageSize ? Number(pageSize) : undefined,
+    });
+
+    res.json({ success: true, data });
+  })
+);
+
 export default router;
 

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

@@ -328,6 +328,7 @@ export class AccountService {
 
     try {
       if (platform === 'xiaohongshu') {
+        // 与定时任务相同:账号概览(观看/互动/涨粉)导出 + 粉丝数据页近30天 overall_new → user_day_statistics.fans_count
         const svc = new XiaohongshuAccountOverviewImportService();
         await svc.importAccountLast30Days(account);
       } else if (platform === 'douyin') {

+ 607 - 2
server/src/services/WorkDayStatisticsService.ts

@@ -703,6 +703,603 @@ export class WorkDayStatisticsService {
   }
 
   /**
+   * 获取账号维度的区间统计数据
+   * 用于「账号数据」页,支持按日期区间、平台、分组筛选
+   */
+  async getAccountsAnalytics(
+    userId: number,
+    options: {
+      startDate: string;
+      endDate: string;
+      platform?: string;
+      groupId?: number;
+    }
+  ): Promise<{
+    accounts: Array<{
+      id: number;
+      nickname: string;
+      username: string;
+      avatarUrl: string | null;
+      platform: string;
+      groupId: number | null;
+      income: number | null;
+      recommendationCount: number | null;
+      viewsCount: number;
+      commentsCount: number;
+      likesCount: number;
+      fansIncrease: number;
+      fansCount: number;
+      updateTime: string;
+      status: string;
+    }>;
+    summary: {
+      totalAccounts: number;
+      totalIncome: number;
+      recommendationCount: number | null;
+      viewsCount: number;
+      commentsCount: number;
+      likesCount: number;
+      fansIncrease: number;
+      totalFans: number;
+    };
+  }> {
+    const { startDate, endDate, platform, groupId } = options;
+
+    // 只查询支持的平台:抖音、百家号、视频号、小红书
+    const allowedPlatforms = ['douyin', 'baijiahao', 'weixin_video', 'xiaohongshu'];
+
+    const accountQuery = this.accountRepository
+      .createQueryBuilder('pa')
+      .where('pa.userId = :userId', { userId })
+      .andWhere('pa.platform IN (:...allowedPlatforms)', { allowedPlatforms });
+
+    if (platform) {
+      accountQuery.andWhere('pa.platform = :platform', { platform });
+    }
+    if (groupId) {
+      accountQuery.andWhere('pa.groupId = :groupId', { groupId });
+    }
+
+    const accounts = await accountQuery.getMany();
+    if (accounts.length === 0) {
+      return {
+        accounts: [],
+        summary: {
+          totalAccounts: 0,
+          totalIncome: 0,
+          recommendationCount: null,
+          viewsCount: 0,
+          commentsCount: 0,
+          likesCount: 0,
+          fansIncrease: 0,
+          totalFans: 0,
+        },
+      };
+    }
+
+    const accountIds = accounts.map(a => a.id);
+
+    // 使用 user_day_statistics 统计区间内的播放/评论/点赞/涨粉等
+    const statsRows = await this.userDayStatisticsRepository
+      .createQueryBuilder('uds')
+      .select('uds.account_id', 'accountId')
+      .addSelect('COALESCE(SUM(uds.play_count), 0)', 'viewsCount')
+      .addSelect('COALESCE(SUM(uds.comment_count), 0)', 'commentsCount')
+      .addSelect('COALESCE(SUM(uds.like_count), 0)', 'likesCount')
+      .addSelect('COALESCE(SUM(uds.fans_increase), 0)', 'fansIncrease')
+      .addSelect('MAX(uds.updated_at)', 'latestUpdateTime')
+      .where('uds.account_id IN (:...accountIds)', { accountIds })
+      .andWhere('DATE(uds.record_date) >= :startDate', { startDate })
+      .andWhere('DATE(uds.record_date) <= :endDate', { endDate })
+      .groupBy('uds.account_id')
+      .getRawMany();
+
+    const statMap = new Map<
+      number,
+      {
+        viewsCount: number;
+        commentsCount: number;
+        likesCount: number;
+        fansIncrease: number;
+        latestUpdateTime: Date | null;
+      }
+    >();
+
+    for (const row of statsRows || []) {
+      const accountId = Number(row.accountId) || 0;
+      if (!accountId) continue;
+      statMap.set(accountId, {
+        viewsCount: Number(row.viewsCount) || 0,
+        commentsCount: Number(row.commentsCount) || 0,
+        likesCount: Number(row.likesCount) || 0,
+        fansIncrease: Number(row.fansIncrease) || 0,
+        latestUpdateTime: row.latestUpdateTime ? new Date(row.latestUpdateTime) : null,
+      });
+    }
+
+    const resultAccounts: Array<{
+      id: number;
+      nickname: string;
+      username: string;
+      avatarUrl: string | null;
+      platform: string;
+      groupId: number | null;
+      income: number | null;
+      recommendationCount: number | null;
+      viewsCount: number;
+      commentsCount: number;
+      likesCount: number;
+      fansIncrease: number;
+      fansCount: number;
+      updateTime: string;
+      status: string;
+    }> = [];
+
+    let totalViews = 0;
+    let totalComments = 0;
+    let totalLikes = 0;
+    let totalFansIncrease = 0;
+    let totalFans = 0;
+
+    for (const account of accounts) {
+      const stat =
+        statMap.get(account.id) ?? {
+          viewsCount: 0,
+          commentsCount: 0,
+          likesCount: 0,
+          fansIncrease: 0,
+          latestUpdateTime: account.updatedAt ?? null,
+        };
+
+      const fansCount = account.fansCount || 0;
+      const updateTime = stat.latestUpdateTime
+        ? this.formatUpdateTime(stat.latestUpdateTime)
+        : this.formatUpdateTime(account.updatedAt ?? new Date());
+
+      resultAccounts.push({
+        id: account.id,
+        nickname: account.accountName || '',
+        username: account.accountId || '',
+        avatarUrl: account.avatarUrl,
+        platform: account.platform,
+        groupId: account.groupId ?? null,
+        income: null,
+        recommendationCount: null,
+        viewsCount: stat.viewsCount,
+        commentsCount: stat.commentsCount,
+        likesCount: stat.likesCount,
+        fansIncrease: stat.fansIncrease,
+        fansCount,
+        updateTime,
+        status: account.status,
+      });
+
+      totalViews += stat.viewsCount;
+      totalComments += stat.commentsCount;
+      totalLikes += stat.likesCount;
+      totalFansIncrease += stat.fansIncrease;
+      totalFans += fansCount;
+    }
+
+    return {
+      accounts: resultAccounts,
+      summary: {
+        totalAccounts: accounts.length,
+        totalIncome: 0,
+        recommendationCount: null,
+        viewsCount: totalViews,
+        commentsCount: totalComments,
+        likesCount: totalLikes,
+        fansIncrease: totalFansIncrease,
+        totalFans,
+      },
+    };
+  }
+
+  /**
+   * 获取单个账号的详情数据
+   * 包括:汇总统计、每日数据、作品列表
+   * 说明:
+   * - 收益、推荐量目前数据库尚未接入,统一返回 0
+   */
+  async getAccountDetail(
+    userId: number,
+    accountId: number,
+    options: {
+      startDate: string;
+      endDate: string;
+    }
+  ): Promise<{
+    summary: {
+      income: number;
+      recommendationCount: number;
+      viewsCount: number;
+      commentsCount: number;
+      likesCount: number;
+      fansIncrease: number;
+    };
+    dailyData: Array<{
+      date: string;
+      income: number;
+      recommendationCount: number;
+      viewsCount: number;
+      commentsCount: number;
+      likesCount: number;
+      fansIncrease: number;
+    }>;
+    works: Array<{
+      id: number;
+      title: string;
+      coverUrl: string;
+      platform: string;
+      publishTime: string | null;
+      recommendCount: number;
+      viewsCount: number;
+      commentsCount: number;
+      sharesCount: number;
+      collectsCount: number;
+      likesCount: number;
+    }>;
+  }> {
+    const { startDate, endDate } = options;
+    const account = await this.accountRepository.findOne({
+      where: { id: accountId, userId },
+    });
+
+    if (!account) {
+      // 账号不存在或不属于该用户,返回空数据
+      return {
+        summary: {
+          income: 0,
+          recommendationCount: 0,
+          viewsCount: 0,
+          commentsCount: 0,
+          likesCount: 0,
+          fansIncrease: 0,
+        },
+        dailyData: [],
+        works: [],
+      };
+    }
+
+    const startDateStr = startDate;
+    const endDateStr = endDate;
+
+    // 1. 每日数据:直接从 user_day_statistics 获取指定账号的每日记录
+    const udsRows = await this.userDayStatisticsRepository
+      .createQueryBuilder('uds')
+      .select('uds.record_date', 'recordDate')
+      .addSelect('uds.play_count', 'viewsCount')
+      .addSelect('uds.comment_count', 'commentsCount')
+      .addSelect('uds.like_count', 'likesCount')
+      .addSelect('uds.fans_increase', 'fansIncrease')
+      .where('uds.account_id = :accountId', { accountId })
+      .andWhere('DATE(uds.record_date) >= :startDate', { startDate: startDateStr })
+      .andWhere('DATE(uds.record_date) <= :endDate', { endDate: endDateStr })
+      .orderBy('uds.record_date', 'ASC')
+      .getRawMany();
+
+    const dailyMap = new Map<string, { views: number; comments: number; likes: number; fansIncrease: number }>();
+    for (const row of udsRows || []) {
+      if (!row.recordDate) continue;
+      let dateKey: string;
+      if (row.recordDate instanceof Date) {
+        const y = row.recordDate.getFullYear();
+        const m = String(row.recordDate.getMonth() + 1).padStart(2, '0');
+        const d = String(row.recordDate.getDate()).padStart(2, '0');
+        dateKey = `${y}-${m}-${d}`;
+      } else {
+        dateKey = String(row.recordDate).slice(0, 10);
+      }
+
+      dailyMap.set(dateKey, {
+        views: Number(row.viewsCount) || 0,
+        comments: Number(row.commentsCount) || 0,
+        likes: Number(row.likesCount) || 0,
+        fansIncrease: Number(row.fansIncrease) || 0,
+      });
+    }
+
+    const dStart = new Date(startDateStr);
+    const dEnd = new Date(endDateStr);
+    const cursor = new Date(dStart);
+
+    const dailyData: Array<{
+      date: string;
+      income: number;
+      recommendationCount: number;
+      viewsCount: number;
+      commentsCount: number;
+      likesCount: number;
+      fansIncrease: number;
+    }> = [];
+
+    let totalViews = 0;
+    let totalComments = 0;
+    let totalLikes = 0;
+    let totalFansIncrease = 0;
+
+    while (cursor <= dEnd) {
+      const dateKey = this.formatDate(cursor);
+      const value = dailyMap.get(dateKey) ?? {
+        views: 0,
+        comments: 0,
+        likes: 0,
+        fansIncrease: 0,
+      };
+
+      dailyData.push({
+        date: dateKey,
+        income: 0,
+        recommendationCount: 0,
+        viewsCount: value.views,
+        commentsCount: value.comments,
+        likesCount: value.likes,
+        fansIncrease: value.fansIncrease,
+      });
+
+      totalViews += value.views;
+      totalComments += value.comments;
+      totalLikes += value.likes;
+      totalFansIncrease += value.fansIncrease;
+
+      cursor.setDate(cursor.getDate() + 1);
+    }
+
+    // 2. 作品列表:按作品聚合 work_day_statistics 区间内的数据
+    const worksRows = await this.workRepository
+      .createQueryBuilder('w')
+      .leftJoin(WorkDayStatistics, 'wds', 'wds.work_id = w.id AND wds.record_date >= :wStart AND wds.record_date <= :wEnd', {
+        wStart: startDateStr,
+        wEnd: endDateStr,
+      })
+      .select('w.id', 'id')
+      .addSelect('w.title', 'title')
+      .addSelect('w.cover_url', 'coverUrl')
+      .addSelect('w.platform', 'platform')
+      .addSelect('w.publish_time', 'publishTime')
+      .addSelect('COALESCE(SUM(wds.play_count), 0)', 'viewsCount')
+      .addSelect('COALESCE(SUM(wds.comment_count), 0)', 'commentsCount')
+      .addSelect('COALESCE(SUM(wds.share_count), 0)', 'sharesCount')
+      .addSelect('COALESCE(SUM(wds.collect_count), 0)', 'collectsCount')
+      .addSelect('COALESCE(SUM(wds.like_count), 0)', 'likesCount')
+      .where('w.userId = :userId', { userId })
+      .andWhere('w.accountId = :accountId', { accountId })
+      .groupBy('w.id')
+      .orderBy('w.publish_time', 'DESC')
+      .getRawMany();
+
+    const works = (worksRows || []).map((row) => {
+      const publishTime =
+        row.publishTime instanceof Date
+          ? row.publishTime.toISOString()
+          : row.publishTime
+          ? String(row.publishTime)
+          : null;
+
+      return {
+        id: Number(row.id),
+        title: row.title || '',
+        coverUrl: row.coverUrl || '',
+        platform: row.platform || '',
+        publishTime,
+        // 推荐量目前没有独立字段,统一返回 0
+        recommendCount: 0,
+        viewsCount: Number(row.viewsCount) || 0,
+        commentsCount: Number(row.commentsCount) || 0,
+        sharesCount: Number(row.sharesCount) || 0,
+        collectsCount: Number(row.collectsCount) || 0,
+        likesCount: Number(row.likesCount) || 0,
+      };
+    });
+
+    return {
+      summary: {
+        income: 0,
+        recommendationCount: 0,
+        viewsCount: totalViews,
+        commentsCount: totalComments,
+        likesCount: totalLikes,
+        fansIncrease: totalFansIncrease,
+      },
+      dailyData,
+      works,
+    };
+  }
+
+  /**
+   * 获取作品数据列表(用于「作品数据」页)
+   * 依据 work_day_statistics 进行区间汇总统计
+   */
+  async getWorksAnalytics(
+    userId: number,
+    options: {
+      startDate: string;
+      endDate: string;
+      platform?: string;
+      accountIds?: number[];
+      groupId?: number;
+      keyword?: string;
+      sortBy?: 'publish_desc' | 'publish_asc' | 'views_desc' | 'likes_desc' | 'comments_desc';
+      page?: number;
+      pageSize?: number;
+    }
+  ): Promise<{
+    summary: {
+      totalWorks: number;
+      recommendCount: number;
+      viewsCount: number;
+      commentsCount: number;
+      sharesCount: number;
+      collectsCount: number;
+      likesCount: number;
+    };
+    total: number;
+    works: Array<{
+      id: number;
+      title: string;
+      coverUrl: string;
+      platform: string;
+      accountId: number;
+      accountName: string;
+      accountAvatar: string | null;
+      workType: string;
+      publishTime: string | null;
+      recommendCount: number;
+      viewsCount: number;
+      commentsCount: number;
+      sharesCount: number;
+      collectsCount: number;
+      likesCount: number;
+    }>;
+  }> {
+    const {
+      startDate,
+      endDate,
+      platform,
+      accountIds,
+      groupId,
+      keyword,
+      sortBy = 'publish_desc',
+      page = 1,
+      pageSize = 20,
+    } = options;
+
+    const startDateStr = startDate;
+    const endDateStr = endDate;
+
+    // 基础查询:当前用户的作品
+    const qb = this.workRepository
+      .createQueryBuilder('w')
+      .leftJoin(WorkDayStatistics, 'wds', 'wds.work_id = w.id AND wds.record_date >= :wStart AND wds.record_date <= :wEnd', {
+        wStart: startDateStr,
+        wEnd: endDateStr,
+      })
+      .innerJoin(PlatformAccount, 'pa', 'pa.id = w.accountId')
+      .select('w.id', 'id')
+      .addSelect('w.title', 'title')
+      .addSelect('w.cover_url', 'coverUrl')
+      .addSelect('w.platform', 'platform')
+      .addSelect('w.accountId', 'accountId')
+      .addSelect('pa.accountName', 'accountName')
+      .addSelect('pa.avatarUrl', 'accountAvatar')
+      .addSelect('w.status', 'workType')
+      .addSelect('w.publish_time', 'publishTime')
+      .addSelect('COALESCE(SUM(wds.play_count), 0)', 'viewsCount')
+      .addSelect('COALESCE(SUM(wds.comment_count), 0)', 'commentsCount')
+      .addSelect('COALESCE(SUM(wds.share_count), 0)', 'sharesCount')
+      .addSelect('COALESCE(SUM(wds.collect_count), 0)', 'collectsCount')
+      .addSelect('COALESCE(SUM(wds.like_count), 0)', 'likesCount')
+      .where('w.userId = :userId', { userId });
+
+    if (platform) {
+      qb.andWhere('w.platform = :platform', { platform });
+    }
+    if (accountIds && accountIds.length > 0) {
+      qb.andWhere('w.accountId IN (:...accountIds)', { accountIds });
+    }
+    if (groupId) {
+      qb.andWhere('pa.groupId = :groupId', { groupId });
+    }
+    if (keyword && keyword.trim()) {
+      const kw = `%${keyword.trim()}%`;
+      qb.andWhere('(w.title LIKE :kw OR pa.accountName LIKE :kw)', { kw });
+    }
+
+    qb.groupBy('w.id');
+
+    // 排序:统一按发布时间倒序(最新的在前)
+    qb.orderBy('w.publish_time', 'DESC');
+
+    // 统计总数(作品数)
+    const countQb = this.workRepository
+      .createQueryBuilder('w')
+      .innerJoin(PlatformAccount, 'pa', 'pa.id = w.accountId')
+      .where('w.userId = :userId', { userId });
+
+    if (platform) {
+      countQb.andWhere('w.platform = :platform', { platform });
+    }
+    if (accountIds && accountIds.length > 0) {
+      countQb.andWhere('w.accountId IN (:...accountIds)', { accountIds });
+    }
+    if (groupId) {
+      countQb.andWhere('pa.groupId = :groupId', { groupId });
+    }
+    if (keyword && keyword.trim()) {
+      const kw = `%${keyword.trim()}%`;
+      countQb.andWhere('(w.title LIKE :kw OR pa.accountName LIKE :kw)', { kw });
+    }
+
+    const total = await countQb.getCount();
+
+    // 分页
+    const offset = (page - 1) * pageSize;
+    qb.skip(offset).take(pageSize);
+
+    const rows = await qb.getRawMany();
+
+    let totalViews = 0;
+    let totalComments = 0;
+    let totalShares = 0;
+    let totalCollects = 0;
+    let totalLikes = 0;
+
+    const works = rows.map((row) => {
+      const views = Number(row.viewsCount) || 0;
+      const comments = Number(row.commentsCount) || 0;
+      const shares = Number(row.sharesCount) || 0;
+      const collects = Number(row.collectsCount) || 0;
+      const likes = Number(row.likesCount) || 0;
+
+      totalViews += views;
+      totalComments += comments;
+      totalShares += shares;
+      totalCollects += collects;
+      totalLikes += likes;
+
+      const publishTime =
+        row.publishTime instanceof Date
+          ? row.publishTime.toISOString()
+          : row.publishTime
+          ? String(row.publishTime)
+          : null;
+
+      return {
+        id: Number(row.id),
+        title: row.title || '',
+        coverUrl: row.coverUrl || '',
+        platform: row.platform || '',
+        accountId: Number(row.accountId) || 0,
+        accountName: row.accountName || '',
+        accountAvatar: row.accountAvatar || null,
+        workType: row.workType || '动态',
+        publishTime,
+        recommendCount: 0,
+        viewsCount: views,
+        commentsCount: comments,
+        sharesCount: shares,
+        collectsCount: collects,
+        likesCount: likes,
+      };
+    });
+
+    return {
+      summary: {
+        totalWorks: total,
+        recommendCount: 0,
+        viewsCount: totalViews,
+        commentsCount: totalComments,
+        sharesCount: totalShares,
+        collectsCount: totalCollects,
+        likesCount: totalLikes,
+      },
+      total,
+      works,
+    };
+  }
+
+  /**
    * 获取平台详情数据
    * 包括汇总统计、每日汇总数据和账号列表
    */
@@ -982,13 +1579,21 @@ export class WorkDayStatisticsService {
   }
 
   /**
-   * 格式化更新时间为 "MM-DD HH:mm" 格式
+   * 格式化更新时间为统一的人类可读格式:
+   * - 如果是今年:MM-DD HH:mm(例如:01-22 10:22)
+   * - 如果是往年:YYYY-MM-DD HH:mm(例如:2025-12-22 10:22)
    */
   private formatUpdateTime(date: Date): string {
+    const y = date.getFullYear();
+    const nowYear = new Date().getFullYear();
     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}`;
+
+    if (y === nowYear) {
+      return `${month}-${day} ${hours}:${minutes}`;
+    }
+    return `${y}-${month}-${day} ${hours}:${minutes}`;
   }
 }

+ 101 - 1
server/src/services/XiaohongshuAccountOverviewImportService.ts

@@ -1,6 +1,6 @@
 import fs from 'node:fs/promises';
 import path from 'node:path';
-import { chromium, type Browser } from 'playwright';
+import { chromium, type Browser, type Page, type BrowserContext } from 'playwright';
 import * as XLSXNS from 'xlsx';
 import { AppDataSource, PlatformAccount } from '../models/index.js';
 import { BrowserManager } from '../automation/browser.js';
@@ -453,6 +453,9 @@ export class XiaohongshuAccountOverviewImportService {
       // 3) 涨粉数据:只取“净涨粉趋势”(解析器已过滤)
       await exportAndImport('涨粉数据', 'fans');
 
+      // 4) 粉丝数据页:打开粉丝数据、点击近30天,解析 overall_new 接口,将每日粉丝总数写入 user_day_statistics.fans_count
+      await this.importFansDataTrendFromPage(context, page, account);
+
       logger.info(`[XHS Import] Account all tabs done. accountId=${account.id}`);
 
       await context.close();
@@ -462,5 +465,102 @@ export class XiaohongshuAccountOverviewImportService {
       }
     }
   }
+
+  /**
+   * 粉丝数据页:打开粉丝数据、点击「粉丝数据概览」近30天,监听 overall_new 接口响应,解析每日粉丝总数并写入 user_day_statistics.fans_count
+   */
+  private async importFansDataTrendFromPage(
+    _context: BrowserContext,
+    page: Page,
+    account: PlatformAccount
+  ): Promise<void> {
+    const fansDataUrl = 'https://creator.xiaohongshu.com/statistics/fans-data';
+    const overallNewPattern = /\/api\/galaxy\/creator\/data\/fans\/overall_new/i;
+    const near30ButtonSelector =
+      '#content-area > main > div:nth-child(3) > div > div.content > div.css-12s9z8c.fans-data-container > div.title-container > div.extra-box > div > label:nth-child(2)';
+
+    await page.goto(fansDataUrl, { waitUntil: 'domcontentloaded' });
+    await page.waitForTimeout(2000);
+
+    if (page.url().includes('login')) {
+      logger.warn(`[XHS Import] Fans data page redirected to login, skip fans trend. accountId=${account.id}`);
+      return;
+    }
+
+    const responsePromise = page.waitForResponse(
+      (res) => res.url().match(overallNewPattern) != null && res.request().method() === 'GET',
+      { timeout: 30_000 }
+    );
+
+    const btn = page.locator(near30ButtonSelector).or(page.locator('.fans-data-container').getByText('近30天').first());
+    await btn.click().catch(() => undefined);
+    await page.waitForTimeout(1500);
+
+    let res;
+    try {
+      res = await responsePromise;
+    } catch {
+      try {
+        res = await page.waitForResponse(
+          (r) => r.url().match(overallNewPattern) != null && r.request().method() === 'GET',
+          { timeout: 15_000 }
+        );
+      } catch {
+        logger.warn(`[XHS Import] No overall_new response captured, skip fans trend. accountId=${account.id}`);
+        return;
+      }
+    }
+
+    const body = await res.json().catch(() => null);
+    if (!body || typeof body !== 'object') {
+      logger.warn(`[XHS Import] overall_new response not valid JSON, skip. accountId=${account.id}`);
+      return;
+    }
+
+    const list = this.parseFansOverallNewResponse(body);
+    if (!list.length) {
+      logger.info(`[XHS Import] No fans trend items from overall_new. accountId=${account.id}`);
+      return;
+    }
+
+    let updated = 0;
+    for (const { recordDate, fansCount } of list) {
+      const r = await this.userDayStatisticsService.saveStatisticsForDate(account.id, recordDate, { fansCount });
+      updated += r.inserted + r.updated;
+    }
+    logger.info(`[XHS Import] Fans trend imported. accountId=${account.id} days=${list.length} updated=${updated}`);
+  }
+
+  /**
+   * 解析 overall_new 接口返回的 JSON,提取 (recordDate, fansCount) 列表
+   * 接口格式:data.thirty.fans_list(或 fans_list_iterator),每项 { date: 毫秒时间戳, count: 粉丝数 }
+   */
+  private parseFansOverallNewResponse(body: Record<string, unknown>): Array<{ recordDate: Date; fansCount: number }> {
+    const list: Array<{ recordDate: Date; fansCount: number }> = [];
+    const data = body.data as Record<string, unknown> | undefined;
+    if (!data || typeof data !== 'object') return list;
+
+    const thirty = data.thirty as Record<string, unknown> | undefined;
+    if (!thirty || typeof thirty !== 'object') return list;
+
+    const arr = (thirty.fans_list as unknown[]) ?? (thirty.fans_list_iterator as unknown[]) ?? [];
+    if (!Array.isArray(arr)) return list;
+
+    for (const item of arr) {
+      if (!item || typeof item !== 'object') continue;
+      const o = item as Record<string, unknown>;
+      const dateMs = o.date;
+      const countRaw = o.count;
+      if (dateMs == null || countRaw == null) continue;
+      const ts = typeof dateMs === 'number' ? dateMs : Number(dateMs);
+      if (!Number.isFinite(ts)) continue;
+      const d = new Date(ts);
+      d.setHours(0, 0, 0, 0);
+      const n = typeof countRaw === 'number' ? countRaw : Number(countRaw);
+      if (!Number.isFinite(n) || n < 0) continue;
+      list.push({ recordDate: d, fansCount: Math.round(n) });
+    }
+    return list;
+  }
 }