Browse Source

平台数据页面

Ethanfly 18 giờ trước cách đây
mục cha
commit
25525093b1

+ 4 - 0
client/src/assets/platforms/baijiahao.svg

@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+  <rect x="1" y="1" width="22" height="22" rx="6" fill="#2563eb"/>
+  <text x="12" y="15" text-anchor="middle" font-size="9" font-family="Arial, Helvetica, sans-serif" fill="#ffffff">BJH</text>
+</svg>

+ 4 - 0
client/src/assets/platforms/bilibili.svg

@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+  <rect x="1" y="1" width="22" height="22" rx="6" fill="#60a5fa"/>
+  <text x="12" y="15" text-anchor="middle" font-size="10" font-family="Arial, Helvetica, sans-serif" fill="#ffffff">B</text>
+</svg>

+ 4 - 0
client/src/assets/platforms/default.svg

@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+  <rect x="1" y="1" width="22" height="22" rx="6" fill="#64748b"/>
+  <text x="12" y="15" text-anchor="middle" font-size="10" font-family="Arial, Helvetica, sans-serif" fill="#ffffff">APP</text>
+</svg>

+ 4 - 0
client/src/assets/platforms/douyin.svg

@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+  <rect x="1" y="1" width="22" height="22" rx="6" fill="#111827"/>
+  <text x="12" y="15" text-anchor="middle" font-size="10" font-family="Arial, Helvetica, sans-serif" fill="#ffffff">DY</text>
+</svg>

+ 4 - 0
client/src/assets/platforms/kuaishou.svg

@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+  <rect x="1" y="1" width="22" height="22" rx="6" fill="#f59e0b"/>
+  <text x="12" y="15" text-anchor="middle" font-size="9" font-family="Arial, Helvetica, sans-serif" fill="#ffffff">KS</text>
+</svg>

+ 4 - 0
client/src/assets/platforms/weixin_video.svg

@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+  <rect x="1" y="1" width="22" height="22" rx="6" fill="#22c55e"/>
+  <text x="12" y="15" text-anchor="middle" font-size="9" font-family="Arial, Helvetica, sans-serif" fill="#ffffff">WX</text>
+</svg>

+ 4 - 0
client/src/assets/platforms/xiaohongshu.svg

@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+  <rect x="1" y="1" width="22" height="22" rx="6" fill="#ef4444"/>
+  <text x="12" y="15" text-anchor="middle" font-size="9" font-family="Arial, Helvetica, sans-serif" fill="#ffffff">XHS</text>
+</svg>

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

@@ -15,29 +15,48 @@ declare module 'vue' {
     ElBadge: typeof import('element-plus/es')['ElBadge']
     ElButton: typeof import('element-plus/es')['ElButton']
     ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
+    ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
+    ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
     ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
     ElContainer: typeof import('element-plus/es')['ElContainer']
     ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
+    ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
+    ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
     ElDialog: typeof import('element-plus/es')['ElDialog']
+    ElDivider: typeof import('element-plus/es')['ElDivider']
+    ElDrawer: typeof import('element-plus/es')['ElDrawer']
     ElDropdown: typeof import('element-plus/es')['ElDropdown']
     ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
     ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
     ElEmpty: typeof import('element-plus/es')['ElEmpty']
+    ElForm: typeof import('element-plus/es')['ElForm']
+    ElFormItem: typeof import('element-plus/es')['ElFormItem']
     ElIcon: typeof import('element-plus/es')['ElIcon']
     ElInput: typeof import('element-plus/es')['ElInput']
     ElMain: typeof import('element-plus/es')['ElMain']
     ElMenu: typeof import('element-plus/es')['ElMenu']
     ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
+    ElOption: typeof import('element-plus/es')['ElOption']
+    ElPagination: typeof import('element-plus/es')['ElPagination']
+    ElProgress: typeof import('element-plus/es')['ElProgress']
     ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
     ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
+    ElSelect: typeof import('element-plus/es')['ElSelect']
     ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
     ElSwitch: typeof import('element-plus/es')['ElSwitch']
+    ElTable: typeof import('element-plus/es')['ElTable']
+    ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
     ElTabPane: typeof import('element-plus/es')['ElTabPane']
     ElTabs: typeof import('element-plus/es')['ElTabs']
     ElTag: typeof import('element-plus/es')['ElTag']
+    ElText: typeof import('element-plus/es')['ElText']
+    ElUpload: typeof import('element-plus/es')['ElUpload']
     Icons: typeof import('./components/icons/index.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']
     TaskProgressDialog: typeof import('./components/TaskProgressDialog.vue')['default']
   }
+  export interface ComponentCustomProperties {
+    vLoading: typeof import('element-plus/es')['ElLoadingDirective']
+  }
 }

+ 103 - 44
client/src/views/Analytics/Platform/index.vue

@@ -50,16 +50,6 @@
             </div>
           </template>
         </el-table-column>
-        <el-table-column prop="income" label="收益" width="120" align="center">
-          <template #default="{ row }">
-            <span>{{ row.income ?? 0 }}</span>
-          </template>
-        </el-table-column>
-        <el-table-column prop="recommendCount" label="推荐量" width="120" align="center">
-          <template #default="{ row }">
-            <span>{{ row.recommendCount ?? '未支持' }}</span>
-          </template>
-        </el-table-column>
         <el-table-column prop="viewsCount" label="阅读(播放)量" width="140" align="center">
           <template #default="{ row }">
             <span>{{ row.viewsCount ?? '未支持' }}</span>
@@ -160,12 +150,20 @@ import * as echarts from 'echarts';
 import { PLATFORMS } from '@media-manager/shared';
 import type { PlatformType } from '@media-manager/shared';
 import { useAuthStore } from '@/stores/auth';
+import { useServerStore } from '@/stores/server';
 import { ElMessage } from 'element-plus';
 import dayjs from 'dayjs';
-
-const PYTHON_API_URL = 'http://localhost:5005';
+import request from '@/api/request';
+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 authStore = useAuthStore();
+const serverStore = useServerStore();
 const loading = ref(false);
 const chartLoading = ref(false);
 
@@ -174,8 +172,9 @@ const startDate = ref(dayjs().format('YYYY-MM-DD'));
 const endDate = ref(dayjs().format('YYYY-MM-DD'));
 const activeQuickBtn = ref('yesterday');
 
-// 快捷日期按钮
+// 快捷日期按钮(今天/昨天/前天/近三天/近七天/近一个月)
 const quickDateBtns = [
+  { label: '今天', value: 'today' },
   { label: '昨天', value: 'yesterday' },
   { label: '前天', value: 'beforeYesterday' },
   { label: '近三天', value: 'last3days' },
@@ -183,20 +182,21 @@ const quickDateBtns = [
   { label: '近一个月', value: 'lastMonth' },
 ];
 
-// 平台图标映射
+// 平台图标映射(使用本地 SVG,避免 Electron/CSP 阻止外链图标)
+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,
 };
 
 // 平台数据
 interface PlatformData {
   platform: PlatformType;
-  income: number | null;
-  recommendCount: number | null;
   viewsCount: number | null;
   commentsCount: number;
   likesCount: number;
@@ -224,7 +224,7 @@ function getPlatformName(platform: PlatformType) {
 }
 
 function getPlatformIcon(platform: PlatformType) {
-  return platformIcons[platform] || '';
+  return platformIcons[platform] || iconDefault;
 }
 
 function formatNumber(num: number) {
@@ -243,6 +243,10 @@ function handleQuickDate(type: string) {
   const today = dayjs();
   
   switch (type) {
+    case 'today':
+      startDate.value = today.format('YYYY-MM-DD');
+      endDate.value = today.format('YYYY-MM-DD');
+      break;
     case 'yesterday':
       startDate.value = today.subtract(1, 'day').format('YYYY-MM-DD');
       endDate.value = today.subtract(1, 'day').format('YYYY-MM-DD');
@@ -252,16 +256,19 @@ function handleQuickDate(type: string) {
       endDate.value = today.subtract(2, 'day').format('YYYY-MM-DD');
       break;
     case 'last3days':
-      startDate.value = today.subtract(3, 'day').format('YYYY-MM-DD');
-      endDate.value = today.subtract(1, 'day').format('YYYY-MM-DD');
+      // 今日 - 区间最早(含今天,共 3 天:今天/昨天/前天)
+      startDate.value = today.subtract(2, 'day').format('YYYY-MM-DD');
+      endDate.value = today.format('YYYY-MM-DD');
       break;
     case 'last7days':
-      startDate.value = today.subtract(7, 'day').format('YYYY-MM-DD');
-      endDate.value = today.subtract(1, 'day').format('YYYY-MM-DD');
+      // 含今天,共 7 天
+      startDate.value = today.subtract(6, 'day').format('YYYY-MM-DD');
+      endDate.value = today.format('YYYY-MM-DD');
       break;
     case 'lastMonth':
-      startDate.value = today.subtract(30, 'day').format('YYYY-MM-DD');
-      endDate.value = today.subtract(1, 'day').format('YYYY-MM-DD');
+      // 含今天,共 30 天
+      startDate.value = today.subtract(29, 'day').format('YYYY-MM-DD');
+      endDate.value = today.format('YYYY-MM-DD');
       break;
   }
 }
@@ -271,25 +278,20 @@ function handleQuery() {
   loadData();
 }
 
-// 加载数据
+// 加载数据(通过 Node 转发调用 Python 接口)
 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 data = await request.get('/api/analytics/platforms-from-python', {
+      params: {
+        startDate: startDate.value,
+        endDate: endDate.value,
+      },
     });
-    
-    const response = await fetch(`${PYTHON_API_URL}/work_day_statistics/platforms?${queryParams}`);
-    const result = await response.json();
-    
-    if (result.success && result.data) {
-      platformData.value = result.data;
+
+    if (data) {
+      platformData.value = data;
     }
   } catch (error) {
     console.error('加载平台数据失败:', error);
@@ -373,8 +375,65 @@ function updateDetailChart(trendData: { dates: string[]; fans: number[]; views:
 }
 
 // 导出数据
-function handleExport() {
-  ElMessage.info('导出功能开发中');
+async function handleExport() {
+  try {
+    const baseUrl = serverStore.currentServer?.url;
+    if (!baseUrl) {
+      ElMessage.error('未连接服务器');
+      return;
+    }
+
+    if (!authStore.accessToken) {
+      ElMessage.error('未连接服务器或未登录');
+      return;
+    }
+
+    const buildUrl = () => {
+      const params = new URLSearchParams();
+      if (startDate.value) params.set('startDate', startDate.value);
+      if (endDate.value) params.set('endDate', endDate.value);
+      return `${baseUrl}/api/work-day-statistics/platforms/export?${params.toString()}`;
+    };
+
+    const doFetch = async (token: string) => {
+      const url = buildUrl();
+      return await fetch(url, {
+        method: 'GET',
+        headers: {
+          Authorization: `Bearer ${token}`,
+        },
+      });
+    };
+
+    let resp = await doFetch(authStore.accessToken!);
+
+    if (resp.status === 401) {
+      const refreshed = await authStore.refreshAccessToken();
+      if (!refreshed || !authStore.accessToken) {
+        ElMessage.error('登录已过期,请重新登录');
+        return;
+      }
+      resp = await doFetch(authStore.accessToken);
+    }
+
+    if (!resp.ok) {
+      const text = await resp.text().catch(() => '');
+      throw new Error(text || `导出失败,状态码:${resp.status}`);
+    }
+
+    const blob = await resp.blob();
+    const downloadUrl = window.URL.createObjectURL(blob);
+    const a = document.createElement('a');
+    a.href = downloadUrl;
+    a.download = `平台数据_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`;
+    document.body.appendChild(a);
+    a.click();
+    a.remove();
+    window.URL.revokeObjectURL(downloadUrl);
+  } catch (error: any) {
+    console.error('导出失败:', error);
+    ElMessage.error(error?.message || '导出失败');
+  }
 }
 
 // 监听抽屉关闭

+ 23 - 40
client/src/views/Analytics/index.vue

@@ -80,8 +80,7 @@ import { PLATFORMS } 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';
+import request from '@/api/request';
 
 // 趋势数据类型
 interface TrendData {
@@ -180,30 +179,22 @@ function formatNumber(num: number) {
   return num.toString();
 }
 
-// 获取趋势数据(调用 Python API
+// 获取趋势数据(通过 Node 转发调用 Python 接口
 async function loadTrendData() {
-  const userId = authStore.user?.id;
-  if (!userId || !dateRange.value) return;
-  
-  // 获取实际的日期范围
+  if (!dateRange.value) return;
+
   const [start, end] = dateRange.value;
   const startDate = dayjs(start).format('YYYY-MM-DD');
   const endDate = dayjs(end).format('YYYY-MM-DD');
-  
-  const queryParams = new URLSearchParams({
-    user_id: userId.toString(),
-    start_date: startDate,
-    end_date: endDate,
+
+  const data = await request.get('/api/analytics/trend-from-python', {
+    params: {
+      startDate,
+      endDate,
+    },
   });
-  
-  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;
+
+  return data as TrendData;
 }
 
 // 平台统计数据类型
@@ -217,30 +208,22 @@ interface PlatformStats {
   collectsCount: number;
 }
 
-// 获取平台统计数据(调用 Python API
+// 获取平台统计数据(通过 Node 转发调用 Python 接口
 async function loadPlatformData() {
-  const userId = authStore.user?.id;
-  if (!userId || !dateRange.value) return;
-  
-  // 获取实际的日期范围
+  if (!dateRange.value) return;
+
   const [start, end] = dateRange.value;
   const startDate = dayjs(start).format('YYYY-MM-DD');
   const endDate = dayjs(end).format('YYYY-MM-DD');
-  
-  const queryParams = new URLSearchParams({
-    user_id: userId.toString(),
-    start_date: startDate,
-    end_date: endDate,
+
+  const data = await request.get('/api/analytics/platforms-from-python', {
+    params: {
+      startDate,
+      endDate,
+    },
   });
-  
-  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[];
+
+  return data as PlatformStats[];
 }
 
 async function loadData() {

+ 191 - 0
server/python/export_platform_statistics_xlsx.py

@@ -0,0 +1,191 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+从 stdin 读取 JSON(平台统计列表),生成 xlsx 并输出到 stdout(二进制)。
+
+输入 JSON 格式:
+{
+  "platforms": [
+    {
+      "platform": "baijiahao",
+      "viewsCount": 5,
+      "commentsCount": 1,
+      "likesCount": 0,
+      "fansIncrease": 1,
+      "updateTime": "2026-01-27 14:30:00"
+    }
+  ]
+}
+"""
+
+import json
+import sys
+from io import BytesIO
+from datetime import datetime
+
+# Ensure stdin is read as UTF-8 (important on Windows when Node passes UTF-8 JSON)
+if sys.platform == "win32":
+  import io as _io
+
+  if hasattr(sys.stdin, "buffer"):
+    sys.stdin = _io.TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace")
+
+try:
+  from openpyxl import Workbook
+  from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
+  from openpyxl.utils import get_column_letter
+except Exception as e:  # pragma: no cover
+  sys.stderr.write(
+    "Missing dependency: openpyxl. Please install it in your python env.\n"
+    "Example: pip install -r server/python/requirements.txt\n"
+    f"Detail: {e}\n"
+  )
+  sys.exit(3)
+
+
+HEADERS = [
+  "平台",
+  "阅读(播放量)",
+  "评论量",
+  "点赞量",
+  "涨粉量",
+  "更新时间",
+]
+
+COL_WIDTHS = [12, 16, 12, 12, 10, 18]
+
+
+def _safe_int(v):
+  try:
+    if v is None or v == "":
+      return None
+    return int(float(v))
+  except Exception:
+    return None
+
+
+def _safe_str(v) -> str:
+  """
+  将任意值转换为字符串,并过滤掉 Excel 不支持的非法 Unicode 代理字符。
+  """
+  if v is None:
+    return ""
+  try:
+    s = str(v)
+  except Exception:
+    s = repr(v)
+  return "".join(ch for ch in s if not (0xD800 <= ord(ch) <= 0xDFFF))
+
+
+def _format_date_only(value: str) -> str:
+  """
+  将各种时间字符串格式化为 YYYY-MM-DD,仅保留日期部分。
+  如果无法解析,则尽量取前 10 位作为日期返回。
+  """
+  if not value:
+    return ""
+
+  s = str(value).strip()
+  try:
+    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("%Y-%m-%d")
+  except Exception:
+    pass
+
+  if len(s) >= 10:
+    return s[:10]
+
+  return s
+
+
+def build_xlsx(platforms):
+  platform_name_map = {
+    "douyin": "抖音",
+    "baijiahao": "百家号",
+    "weixin_video": "视频号",
+    "xiaohongshu": "小红书",
+    "bilibili": "哔哩哔哩",
+    "kuaishou": "快手",
+  }
+
+  wb = Workbook()
+  ws = wb.active
+  ws.title = "平台数据"
+
+  ws.append(HEADERS)
+
+  header_font = Font(bold=True)
+  header_fill = PatternFill("solid", fgColor="F2F2F2")
+  center = Alignment(horizontal="center", vertical="center", wrap_text=False)
+  left = Alignment(horizontal="left", vertical="center", wrap_text=False)
+  thin = Side(style="thin", color="D9D9D9")
+  border = Border(left=thin, right=thin, top=thin, bottom=thin)
+
+  for col_idx in range(1, len(HEADERS) + 1):
+    cell = ws.cell(row=1, column=col_idx)
+    cell.font = header_font
+    cell.fill = header_fill
+    cell.alignment = center
+    cell.border = border
+
+  for i, w in enumerate(COL_WIDTHS, start=1):
+    col_letter = get_column_letter(i)
+    ws.column_dimensions[col_letter].width = w
+
+  for p in platforms:
+    platform_raw = (p.get("platform") or "").strip()
+    platform_cn = platform_name_map.get(platform_raw, platform_raw)
+
+    ws.append([
+      _safe_str(platform_cn),
+      _safe_int(p.get("viewsCount")) or 0,
+      _safe_int(p.get("commentsCount")) or 0,
+      _safe_int(p.get("likesCount")) or 0,
+      _safe_int(p.get("fansIncrease")) or 0,
+      _safe_str(_format_date_only(p.get("updateTime"))),
+    ])
+
+  int_cols = {"B", "C", "D", "E"}
+
+  for row in range(2, ws.max_row + 1):
+    for col in range(1, len(HEADERS) + 1):
+      c = ws.cell(row=row, column=col)
+      c.border = border
+      if col == 1:
+        c.alignment = left
+      else:
+        c.alignment = center
+
+    for c_letter in int_cols:
+      c = ws[f"{c_letter}{row}"]
+      if c.value is not None:
+        c.number_format = "0"
+
+  ws.freeze_panes = "A2"
+
+  bio = BytesIO()
+  wb.save(bio)
+  return bio.getvalue()
+
+
+def main():
+  try:
+    raw = sys.stdin.read()
+    payload = json.loads(raw) if raw.strip() else {}
+  except Exception as e:
+    sys.stderr.write(f"Invalid JSON input: {e}\n")
+    sys.exit(2)
+
+  platforms = payload.get("platforms") or []
+  xlsx_bytes = build_xlsx(platforms)
+  sys.stdout.buffer.write(xlsx_bytes)
+
+
+if __name__ == "__main__":
+  main()
+

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


+ 99 - 3
server/src/routes/analytics.ts

@@ -10,7 +10,33 @@ const analyticsService = new AnalyticsService();
 
 router.use(authenticate);
 
-// 获取汇总统计
+// 在 Node 中声明全局 fetch,避免 TypeScript 编译错误(运行时使用 Node 18+ 自带的 fetch)
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+declare const fetch: any;
+
+/**
+ * 调用本地 Python 统计服务的工具函数
+ * 默认地址: http://localhost:5005
+ */
+async function callPythonAnalyticsApi(pathname: string, params: Record<string, string | number | undefined>) {
+  const base = process.env.PYTHON_API_URL || 'http://localhost:5005';
+  const url = new URL(base);
+  url.pathname = pathname.startsWith('/') ? pathname : `/${pathname}`;
+
+  const search = new URLSearchParams();
+  Object.entries(params).forEach(([key, value]) => {
+    if (value !== undefined && value !== null) {
+      search.append(key, String(value));
+    }
+  });
+  url.search = search.toString();
+
+  const resp = await fetch(url.toString(), { method: 'GET' });
+  const json = await resp.json();
+  return json;
+}
+
+// 获取汇总统计(直接走 Node 本地统计)
 router.get(
   '/summary',
   [
@@ -30,7 +56,42 @@ router.get(
   })
 );
 
-// 获取趋势数据
+/**
+ * GET /api/analytics/trend-from-python
+ * 通过 Node 转发到 Python 的 /work_day_statistics/trend
+ * 前端只需要传 startDate / endDate,userId 由 JWT 决定
+ */
+router.get(
+  '/trend-from-python',
+  [
+    query('startDate').notEmpty().withMessage('开始日期不能为空'),
+    query('endDate').notEmpty().withMessage('结束日期不能为空'),
+    validateRequest,
+  ],
+  asyncHandler(async (req, res) => {
+    const { startDate, endDate } = req.query;
+
+    const pythonResult = await callPythonAnalyticsApi('/work_day_statistics/trend', {
+      user_id: req.user!.userId,
+      start_date: String(startDate),
+      end_date: String(endDate),
+    });
+
+    if (!pythonResult || pythonResult.success === false) {
+      return res.json({
+        success: false,
+        message: pythonResult?.error || '获取数据趋势失败',
+      });
+    }
+
+    return res.json({
+      success: true,
+      data: pythonResult.data,
+    });
+  })
+);
+
+// 获取趋势数据(直接走 Node 本地统计)
 router.get(
   '/trend',
   [
@@ -50,7 +111,42 @@ router.get(
   })
 );
 
-// 获取平台对比
+/**
+ * GET /api/analytics/platforms-from-python
+ * 通过 Node 转发到 Python 的 /work_day_statistics/platforms
+ * 前端只需要传 startDate / endDate,userId 由 JWT 决定
+ */
+router.get(
+  '/platforms-from-python',
+  [
+    query('startDate').notEmpty().withMessage('开始日期不能为空'),
+    query('endDate').notEmpty().withMessage('结束日期不能为空'),
+    validateRequest,
+  ],
+  asyncHandler(async (req, res) => {
+    const { startDate, endDate } = req.query;
+
+    const pythonResult = await callPythonAnalyticsApi('/work_day_statistics/platforms', {
+      user_id: req.user!.userId,
+      start_date: String(startDate),
+      end_date: String(endDate),
+    });
+
+    if (!pythonResult || pythonResult.success === false) {
+      return res.json({
+        success: false,
+        message: pythonResult?.error || '获取平台数据失败',
+      });
+    }
+
+    return res.json({
+      success: true,
+      data: pythonResult.data,
+    });
+  })
+);
+
+// 获取平台对比(直接走 Node 本地统计)
 router.get(
   '/platforms',
   [

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

@@ -61,6 +61,45 @@ function runPythonExportXlsx(payload: unknown): Promise<Buffer> {
   });
 }
 
+function runPythonExportPlatformXlsx(payload: unknown): Promise<Buffer> {
+  const pythonBin = process.env.PYTHON_BIN || 'python';
+  const scriptPath = path.resolve(__dirname, '../../python/export_platform_statistics_xlsx.py');
+
+  return new Promise((resolve, reject) => {
+    const child = spawn(pythonBin, [scriptPath], {
+      stdio: ['pipe', 'pipe', 'pipe'],
+      windowsHide: true,
+    });
+
+    const stdoutChunks: Buffer[] = [];
+    const stderrChunks: Buffer[] = [];
+
+    child.stdout.on('data', (d) => stdoutChunks.push(Buffer.from(d)));
+    child.stderr.on('data', (d) => stderrChunks.push(Buffer.from(d)));
+
+    child.on('error', (err) => {
+      reject(err);
+    });
+
+    child.on('close', (code) => {
+      if (code === 0) {
+        resolve(Buffer.concat(stdoutChunks));
+        return;
+      }
+      const stderr = Buffer.concat(stderrChunks).toString('utf-8').slice(0, 4000);
+      reject(new Error(`Python export failed (code=${code}). ${stderr}`));
+    });
+
+    try {
+      child.stdin.write(JSON.stringify(payload ?? {}), 'utf-8');
+      child.stdin.end();
+    } catch (e) {
+      child.kill();
+      reject(e);
+    }
+  });
+}
+
 /**
  * GET /api/work-day-statistics/overview
  * 获取数据总览(账号列表和汇总统计)
@@ -81,6 +120,58 @@ router.get(
 );
 
 /**
+ * GET /api/work-day-statistics/platforms/export
+ * 导出“平台数据”xlsx(Node 调用 Python 生成)
+ *
+ * 查询参数:
+ * - startDate: 开始日期(必填)
+ * - endDate: 结束日期(必填)
+ */
+router.get(
+  '/platforms/export',
+  [
+    query('startDate').notEmpty().withMessage('startDate 不能为空'),
+    query('endDate').notEmpty().withMessage('endDate 不能为空'),
+    validateRequest,
+  ],
+  asyncHandler(async (req, res) => {
+    const { startDate, endDate } = req.query;
+
+    const data = await workDayStatisticsService.getStatisticsByPlatform(req.user!.userId, {
+      startDate: startDate as string,
+      endDate: endDate as string,
+    });
+
+    const exportPayload = {
+      platforms: data.map((p) => ({
+        platform: p.platform || '',
+        viewsCount: p.viewsCount,
+        commentsCount: p.commentsCount,
+        likesCount: p.likesCount,
+        fansIncrease: p.fansIncrease,
+        // 使用 endDate 作为“更新时间”展示(仅日期)
+        updateTime: endDate || new Date().toISOString(),
+      })),
+    };
+
+    const xlsxBuffer = await runPythonExportPlatformXlsx(exportPayload);
+
+    const now = new Date();
+    const yyyy = now.getFullYear();
+    const mm = String(now.getMonth() + 1).padStart(2, '0');
+    const dd = String(now.getDate()).padStart(2, '0');
+    const hh = String(now.getHours()).padStart(2, '0');
+    const mi = String(now.getMinutes()).padStart(2, '0');
+    const ss = String(now.getSeconds()).padStart(2, '0');
+    const filename = `platform_statistics_${yyyy}${mm}${dd}_${hh}${mi}${ss}.xlsx`;
+
+    res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
+    res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
+    res.send(xlsxBuffer);
+  })
+);
+
+/**
  * GET /api/work-day-statistics/overview/export
  * 导出“数据总览”xlsx(Node 调用 Python 生成)
  *

+ 151 - 67
server/src/services/WorkDayStatisticsService.ts

@@ -51,6 +51,55 @@ export class WorkDayStatisticsService {
   private accountRepository = AppDataSource.getRepository(PlatformAccount);
   private userDayStatisticsRepository = AppDataSource.getRepository(UserDayStatistics);
 
+  private formatDate(d: Date) {
+    const yyyy = d.getFullYear();
+    const mm = String(d.getMonth() + 1).padStart(2, '0');
+    const dd = String(d.getDate()).padStart(2, '0');
+    return `${yyyy}-${mm}-${dd}`;
+  }
+
+  /**
+   * 获取某个账号在指定日期(<= targetDate)时各作品的“最新一条”累计数据总和
+   * 口径:对该账号所有作品,每个作品取 record_date <= targetDate 的最大日期那条记录,然后把 play/like/comment/collect 求和
+   */
+  private async getWorkSumsAtDate(
+    workIds: number[],
+    targetDate: string
+  ): Promise<{ views: number; likes: number; comments: number; collects: number }> {
+    if (!workIds.length) {
+      return { views: 0, likes: 0, comments: 0, collects: 0 };
+    }
+
+    // MySQL: 派生表先取每个作品 <= targetDate 的最新日期,再回连取该日数据求和
+    // 注意:workIds 使用 IN (...),由 TypeORM 负责参数化,避免注入
+    const placeholders = workIds.map(() => '?').join(',');
+    const sql = `
+      SELECT
+        COALESCE(SUM(wds.play_count), 0) AS views,
+        COALESCE(SUM(wds.like_count), 0) AS likes,
+        COALESCE(SUM(wds.comment_count), 0) AS comments,
+        COALESCE(SUM(wds.collect_count), 0) AS collects
+      FROM work_day_statistics wds
+      INNER JOIN (
+        SELECT wds2.work_id, MAX(wds2.record_date) AS record_date
+        FROM work_day_statistics wds2
+        WHERE wds2.work_id IN (${placeholders})
+          AND wds2.record_date <= ?
+        GROUP BY wds2.work_id
+      ) latest
+      ON latest.work_id = wds.work_id AND latest.record_date = wds.record_date
+    `;
+
+    const rows = await AppDataSource.query(sql, [...workIds, targetDate]);
+    const row = rows?.[0] || {};
+    return {
+      views: Number(row.views) || 0,
+      likes: Number(row.likes) || 0,
+      comments: Number(row.comments) || 0,
+      collects: Number(row.collects) || 0,
+    };
+  }
+
   /**
    * 保存作品日统计数据
    * 当天的数据走更新流,日期变化走新增流
@@ -254,87 +303,122 @@ export class WorkDayStatisticsService {
       dateStart.setDate(dateStart.getDate() - Math.min(days, 30) + 1);
     }
 
+    const endDateStr = endDate ? endDate : this.formatDate(dateEnd);
+    const startDateStr = startDate ? startDate : this.formatDate(dateStart);
+    const isSingleDay = startDateStr === endDateStr;
+    // 单日查询(如“昨天”)按“当日增量”口径:当日粉丝 - 前一日粉丝
+    const startBaselineStr = (() => {
+      if (!isSingleDay) return startDateStr;
+      const d = new Date(endDateStr);
+      d.setDate(d.getDate() - 1);
+      return this.formatDate(d);
+    })();
+
     // 获取用户的所有账号
     const accounts = await this.accountRepository.find({
       where: { userId },
     });
 
-    const platformData: PlatformStatItem[] = [];
+    // 按平台聚合数据:Map<platform, { fansCount, fansIncrease, viewsCount, likesCount, commentsCount, collectsCount }>
+    const platformMap = new Map<string, {
+      fansCount: number;
+      fansIncrease: number;
+      viewsCount: number;
+      likesCount: number;
+      commentsCount: number;
+      collectsCount: number;
+    }>();
 
+    // 遍历每个账号,计算该账号的数据,然后累加到对应平台
     for (const account of accounts) {
-      // 获取该账号在区间内第一天和最后一天的数据
-      const firstDayQuery = this.statisticsRepository
-        .createQueryBuilder('wds')
-        .innerJoin(Work, 'w', 'wds.work_id = w.id')
-        .select('SUM(wds.play_count)', 'views')
-        .addSelect('SUM(wds.like_count)', 'likes')
-        .addSelect('SUM(wds.comment_count)', 'comments')
-        .addSelect('SUM(wds.collect_count)', 'collects')
-        .where('w.accountId = :accountId', { accountId: account.id })
-        .andWhere('wds.record_date = (SELECT MIN(record_date) FROM work_day_statistics wds2 INNER JOIN works w2 ON wds2.work_id = w2.id WHERE w2.accountId = :accountId2 AND wds2.record_date >= :dateStart AND wds2.record_date <= :dateEnd)', {
-          accountId2: account.id,
-          dateStart,
-          dateEnd,
-        });
+      // 获取该账号的作品列表(用于按“所有作品累计值之和”计算)
+      const works = await this.workRepository.find({
+        where: { accountId: account.id },
+        select: ['id'],
+      });
+      const workIds = works.map(w => w.id);
 
-      const lastDayQuery = this.statisticsRepository
-        .createQueryBuilder('wds')
-        .innerJoin(Work, 'w', 'wds.work_id = w.id')
-        .select('SUM(wds.play_count)', 'views')
-        .addSelect('SUM(wds.like_count)', 'likes')
-        .addSelect('SUM(wds.comment_count)', 'comments')
-        .addSelect('SUM(wds.collect_count)', 'collects')
-        .where('w.accountId = :accountId', { accountId: account.id })
-        .andWhere('wds.record_date = (SELECT MAX(record_date) FROM work_day_statistics wds2 INNER JOIN works w2 ON wds2.work_id = w2.id WHERE w2.accountId = :accountId2 AND wds2.record_date >= :dateStart AND wds2.record_date <= :dateEnd)', {
-          accountId2: account.id,
-          dateStart,
-          dateEnd,
-        });
+      // ===== 粉丝口径修正 =====
+      // 取 endDate 当天粉丝(若当天没有记录,则取 <= endDate 的最近一条)
+      const endUserStat =
+        (await this.userDayStatisticsRepository
+          .createQueryBuilder('uds')
+          .where('uds.account_id = :accountId', { accountId: account.id })
+          .andWhere('DATE(uds.record_date) = :d', { d: endDateStr })
+          .getOne()) ??
+        (await this.userDayStatisticsRepository
+          .createQueryBuilder('uds')
+          .where('uds.account_id = :accountId', { accountId: account.id })
+          .andWhere('DATE(uds.record_date) <= :d', { d: endDateStr })
+          .orderBy('uds.record_date', 'DESC')
+          .getOne());
 
-      const [firstDay, lastDay] = await Promise.all([
-        firstDayQuery.getRawOne(),
-        lastDayQuery.getRawOne(),
-      ]);
+      const endFans = endUserStat?.fansCount ?? account.fansCount ?? 0;
 
-      // 从 user_day_statistics 表获取粉丝数
-      const todayDate = new Date();
-      todayDate.setHours(0, 0, 0, 0);
-      const todayUserStat = await this.userDayStatisticsRepository.findOne({
-        where: {
-          accountId: account.id,
-          recordDate: todayDate,
-        },
-      });
-      const currentFans = todayUserStat?.fansCount ?? account.fansCount ?? 0;
-      
-      // 获取最早日期的粉丝数
-      const earliestUserStat = await this.userDayStatisticsRepository
-        .createQueryBuilder('uds')
-        .where('uds.account_id = :accountId', { accountId: account.id })
-        .andWhere('uds.record_date >= :dateStart', { dateStart })
-        .andWhere('uds.record_date <= :dateEnd', { dateEnd })
-        .orderBy('uds.record_date', 'ASC')
-        .getOne();
-      const earliestFans = earliestUserStat?.fansCount ?? currentFans;
-      const fansIncrease = currentFans - earliestFans;
+      // 取 baseline 当天粉丝(若没有记录,则取 <= baseline 的最近一条)
+      const baselineUserStat =
+        (await this.userDayStatisticsRepository
+          .createQueryBuilder('uds')
+          .where('uds.account_id = :accountId', { accountId: account.id })
+          .andWhere('DATE(uds.record_date) = :d', { d: startBaselineStr })
+          .getOne()) ??
+        (await this.userDayStatisticsRepository
+          .createQueryBuilder('uds')
+          .where('uds.account_id = :accountId', { accountId: account.id })
+          .andWhere('DATE(uds.record_date) <= :d', { d: startBaselineStr })
+          .orderBy('uds.record_date', 'DESC')
+          .getOne());
+
+      const baselineFans = baselineUserStat?.fansCount ?? endFans;
+      // 涨粉量允许为负数(掉粉),不做截断
+      const accountFansIncrease = endFans - baselineFans;
+
+      // ===== 播放/点赞/评论/收藏口径修正 =====
+      // 单日:当日累计 - 前一日累计
+      // 区间:end 日累计 - start 日累计
+      const [endSums, baseSums] = await Promise.all([
+        this.getWorkSumsAtDate(workIds, endDateStr),
+        this.getWorkSumsAtDate(workIds, startBaselineStr),
+      ]);
 
-      const viewsIncrease = (parseInt(lastDay?.views) || 0) - (parseInt(firstDay?.views) || 0);
-      const likesIncrease = (parseInt(lastDay?.likes) || 0) - (parseInt(firstDay?.likes) || 0);
-      const commentsIncrease = (parseInt(lastDay?.comments) || 0) - (parseInt(firstDay?.comments) || 0);
-      const collectsIncrease = (parseInt(lastDay?.collects) || 0) - (parseInt(firstDay?.collects) || 0);
+      const accountViewsIncrease = endSums.views - baseSums.views;
+      const accountLikesIncrease = endSums.likes - baseSums.likes;
+      const accountCommentsIncrease = endSums.comments - baseSums.comments;
+      const accountCollectsIncrease = endSums.collects - baseSums.collects;
+
+      // 累加到平台聚合数据
+      const platformKey = account.platform;
+      if (!platformMap.has(platformKey)) {
+        platformMap.set(platformKey, {
+          fansCount: 0,
+          fansIncrease: 0,
+          viewsCount: 0,
+          likesCount: 0,
+          commentsCount: 0,
+          collectsCount: 0,
+        });
+      }
 
-      platformData.push({
-        platform: account.platform,
-        fansCount: currentFans,
-        fansIncrease,
-        viewsCount: Math.max(0, viewsIncrease),
-        likesCount: Math.max(0, likesIncrease),
-        commentsCount: Math.max(0, commentsIncrease),
-        collectsCount: Math.max(0, collectsIncrease),
-      });
+      const platformStat = platformMap.get(platformKey)!;
+      platformStat.fansCount += endFans;
+      platformStat.fansIncrease += accountFansIncrease;
+      platformStat.viewsCount += accountViewsIncrease;
+      platformStat.likesCount += accountLikesIncrease;
+      platformStat.commentsCount += accountCommentsIncrease;
+      platformStat.collectsCount += accountCollectsIncrease;
     }
 
-    // 按粉丝数降序排序
+    // 转换为数组格式,按粉丝数降序排序
+    const platformData: PlatformStatItem[] = Array.from(platformMap.entries()).map(([platform, stat]) => ({
+      platform,
+      fansCount: stat.fansCount,
+      fansIncrease: stat.fansIncrease,
+      viewsCount: stat.viewsCount,
+      likesCount: stat.likesCount,
+      commentsCount: stat.commentsCount,
+      collectsCount: stat.collectsCount,
+    }));
+
     platformData.sort((a, b) => b.fansCount - a.fansCount);
 
     return platformData;