Pārlūkot izejas kodu

数据总览导出

Ethanfly 20 stundas atpakaļ
vecāks
revīzija
e434a17952

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

@@ -18,6 +18,7 @@ declare module 'vue' {
     ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
     ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
     ElContainer: typeof import('element-plus/es')['ElContainer']
+    ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
     ElDialog: typeof import('element-plus/es')['ElDialog']
     ElDivider: typeof import('element-plus/es')['ElDivider']
     ElDrawer: typeof import('element-plus/es')['ElDrawer']

+ 84 - 43
client/src/views/Analytics/Overview/index.vue

@@ -154,13 +154,13 @@ import { Search } from '@element-plus/icons-vue';
 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';
 import request from '@/api/request';
 
-const PYTHON_API_URL = 'http://localhost:5005';
-
 const authStore = useAuthStore();
+const serverStore = useServerStore();
 const loading = ref(false);
 const refreshing = ref(false);
 
@@ -315,37 +315,32 @@ async function loadData() {
   loading.value = true;
   
   try {
-    const queryParams = new URLSearchParams({
-      user_id: userId.toString(),
+    // 改为直接走 Node 服务(/api/...),避免依赖本地 Python 端口(:5005)
+    const data = await request.get('/api/work-day-statistics/overview', {
+      params: { user_id: userId }, // 兼容历史参数;后端会忽略并以 token 用户为准
     });
-    
-    const response = await fetch(`${PYTHON_API_URL}/work_day_statistics/overview?${queryParams}`);
-    const result = await response.json();
-    
-    if (result.success && result.data) {
+
+    if (data) {
       // 确保只保留支持的平台
       const allowedPlatforms: PlatformType[] = ['douyin', 'baijiahao', 'weixin_video', 'xiaohongshu'];
-      accounts.value = (result.data.accounts || []).filter((a: AccountData) => 
+      accounts.value = (data.accounts || []).filter((a: AccountData) => 
         allowedPlatforms.includes(a.platform)
       );
       
       // 使用后端返回的汇总数据
-      if (result.data.summary) {
+      if (data.summary) {
         summaryData.value = {
-          totalAccounts: result.data.summary.totalAccounts || 0,
-          totalIncome: result.data.summary.totalIncome || 0,
-          yesterdayIncome: result.data.summary.yesterdayIncome || 0,
-          totalViews: result.data.summary.totalViews || 0,
-          yesterdayViews: result.data.summary.yesterdayViews || 0,
-          totalFans: result.data.summary.totalFans || 0,
-          yesterdayComments: result.data.summary.yesterdayComments || 0,
-          yesterdayLikes: result.data.summary.yesterdayLikes || 0,
-          yesterdayFansIncrease: result.data.summary.yesterdayFansIncrease || 0,
+          totalAccounts: data.summary.totalAccounts || 0,
+          totalIncome: data.summary.totalIncome || 0,
+          yesterdayIncome: data.summary.yesterdayIncome || 0,
+          totalViews: data.summary.totalViews || 0,
+          yesterdayViews: data.summary.yesterdayViews || 0,
+          totalFans: data.summary.totalFans || 0,
+          yesterdayComments: data.summary.yesterdayComments || 0,
+          yesterdayLikes: data.summary.yesterdayLikes || 0,
+          yesterdayFansIncrease: data.summary.yesterdayFansIncrease || 0,
         };
       }
-    } else {
-      console.error('加载数据失败:', result.error || '未知错误');
-      ElMessage.error(result.error || '加载数据失败');
     }
   } catch (error) {
     console.error('加载数据失败:', error);
@@ -372,24 +367,11 @@ async function handleRefreshAccount(account: AccountData) {
   try {
     const userId = authStore.user?.id;
     if (!userId) return;
-    
-    const queryParams = new URLSearchParams({
-      user_id: userId.toString(),
-      account_id: account.id.toString(),
-    });
-    
-    const response = await fetch(`${PYTHON_API_URL}/work_day_statistics/refresh_account?${queryParams}`, {
-      method: 'POST',
-    });
-    const result = await response.json();
-    
-    if (result.success) {
-      // 更新账号数据
-      Object.assign(account, result.data);
-      ElMessage.success('账号数据刷新成功');
-    } else {
-      ElMessage.error(result.error || '刷新失败');
-    }
+
+    // 直接复用 Node 现有刷新接口
+    const data = await request.post(`/api/accounts/${account.id}/refresh`);
+    Object.assign(account, data);
+    ElMessage.success('账号数据刷新成功');
   } catch (error) {
     ElMessage.error('刷新失败');
   } finally {
@@ -398,8 +380,67 @@ async function handleRefreshAccount(account: AccountData) {
 }
 
 // 导出数据
-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 (selectedGroup.value) params.set('groupId', String(selectedGroup.value));
+      if (selectedPlatform.value) params.set('platform', String(selectedPlatform.value));
+      if (searchKeyword.value) params.set('keyword', searchKeyword.value);
+      return `${baseUrl}/api/work-day-statistics/overview/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!);
+
+    // token 过期时,手动触发刷新逻辑并重试一次
+    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 || '导出失败');
+  }
 }
 
 onMounted(() => {

+ 231 - 0
server/python/export_work_day_overview_xlsx.py

@@ -0,0 +1,231 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+从 stdin 读取 JSON(数据总览账号列表),生成 xlsx 并输出到 stdout(二进制)。
+
+注意:使用 openpyxl,便于之后扩展更多导出样式。
+
+输入 JSON 格式:
+{
+  "accounts": [
+    {
+      "account": "xxx",
+      "platform": "douyin",
+      "totalIncome": 0.0,
+      "yesterdayIncome": 0.0,
+      "totalViews": 0,
+      "yesterdayViews": 0,
+      "fansCount": 0,
+      "yesterdayComments": 0,
+      "yesterdayLikes": 0,
+      "yesterdayFansIncrease": 0,
+      "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 = [22, 12, 12, 12, 12, 12, 10, 10, 10, 10, 20]
+
+
+def _safe_int(v):
+  try:
+    if v is None or v == "":
+      return None
+    return int(float(v))
+  except Exception:
+    return None
+
+
+def _safe_float(v):
+  try:
+    if v is None or v == "":
+      return None
+    return float(v)
+  except Exception:
+    return None
+
+
+def _safe_str(v) -> str:
+  """
+  将任意值转换为字符串,并过滤掉 Excel 不支持的非法 Unicode 代理字符。
+  只移除 U+D800~U+DFFF 范围的代理项,不影响正常中文等字符。
+  """
+  if v is None:
+    return ""
+  try:
+    s = str(v)
+  except Exception:
+    s = repr(v)
+
+  # 去掉孤立代理(surrogates),避免 "surrogates not allowed" 错误
+  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()
+  # 先尝试 ISO 格式解析
+  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")
+  except Exception:
+    pass
+
+  # 退化处理:如果长度>=10,优先取前 10 位
+  if len(s) >= 10:
+    return s[:10]
+
+  return s
+
+
+def build_xlsx(accounts):
+  platform_name_map = {
+    "douyin": "抖音",
+    "baijiahao": "百家号",
+    "weixin_video": "视频号",
+    "xiaohongshu": "小红书",
+  }
+
+  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 a in accounts:
+    platform_raw = (a.get("platform") or "").strip()
+    platform_cn = platform_name_map.get(platform_raw, platform_raw)
+
+    ws.append([
+      _safe_str(a.get("account")),
+      _safe_str(platform_cn),
+      _safe_float(a.get("totalIncome")),
+      _safe_float(a.get("yesterdayIncome")),
+      _safe_int(a.get("totalViews")),
+      _safe_int(a.get("yesterdayViews")),
+      _safe_int(a.get("fansCount")) or 0,
+      _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"))),
+    ])
+
+  money_cols = {"C", "D"}
+  int_cols = {"E", "F", "G", "H", "I", "J"}
+
+  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 money_cols:
+      c = ws[f"{c_letter}{row}"]
+      if c.value is not None:
+        c.number_format = "0.00"
+
+    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)
+
+    accounts = payload.get("accounts") or []
+    xlsx_bytes = build_xlsx(accounts)
+    sys.stdout.buffer.write(xlsx_bytes)
+
+
+if __name__ == "__main__":
+    main()
+

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


+ 10 - 9
server/python/requirements.txt

@@ -1,24 +1,25 @@
-# 多平台视频发布服务依赖
-# Python 3.8+
+openpyxl
 
-# Web 框架
+# Core dependencies for Python service (ASCII only comments)
+
+# Web framework
 flask>=2.0.0
 flask-cors>=3.0.0
 
-# 浏览器自动化
+# Browser automation
 playwright>=1.40.0
 
-# 小红书 SDK(可选,用于 API 方式发布,更稳定)
+# Xiaohongshu SDK (optional, API mode)
 xhs>=0.1.0
 
-# 图像处理
+# Image processing
 Pillow>=9.0.0
 
-# 二维码生成(登录用)
+# QR code generation (login)
 qrcode>=7.0
 
-# HTTP 请求(用于调用 Node.js API)
+# HTTP requests (call Node.js API)
 requests>=2.28.0
 
-# 异步 HTTP 客户端(用于百家号 API 调用)
+# Async HTTP client (Baijiahao API)
 aiohttp>=3.8.0

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

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

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

@@ -0,0 +1,153 @@
+import { Router } from 'express';
+import { query } from 'express-validator';
+import { spawn } from 'child_process';
+import path from 'path';
+import { fileURLToPath } from 'url';
+import { authenticate } from '../middleware/auth.js';
+import { asyncHandler } from '../middleware/error.js';
+import { validateRequest } from '../middleware/validate.js';
+import { WorkDayStatisticsService } from '../services/WorkDayStatisticsService.js';
+
+/**
+ * Work day statistics(原 Python 统计接口的 Node 版本)
+ * 目的:避免前端强依赖本地 Python 服务(:5005)
+ */
+const router = Router();
+const workDayStatisticsService = new WorkDayStatisticsService();
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+// 所有路由需要认证
+router.use(authenticate);
+
+function runPythonExportXlsx(payload: unknown): Promise<Buffer> {
+  const pythonBin = process.env.PYTHON_BIN || 'python';
+  const scriptPath = path.resolve(__dirname, '../../python/export_work_day_overview_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}`));
+    });
+
+    // stdin 写入 JSON(注意:不要写入额外换行/日志到 stdout)
+    try {
+      child.stdin.write(JSON.stringify(payload ?? {}), 'utf-8');
+      child.stdin.end();
+    } catch (e) {
+      child.kill();
+      reject(e);
+    }
+  });
+}
+
+/**
+ * GET /api/work-day-statistics/overview
+ * 获取数据总览(账号列表和汇总统计)
+ *
+ * 兼容前端可能传入的 user_id,但服务端始终以 JWT 用户为准。
+ */
+router.get(
+  '/overview',
+  [
+    // 前端历史遗留参数:不强制,但如果传了也应为数字
+    query('user_id').optional().isInt().withMessage('user_id 必须是整数'),
+    validateRequest,
+  ],
+  asyncHandler(async (req, res) => {
+    const data = await workDayStatisticsService.getOverview(req.user!.userId);
+    res.json({ success: true, data });
+  })
+);
+
+/**
+ * GET /api/work-day-statistics/overview/export
+ * 导出“数据总览”xlsx(Node 调用 Python 生成)
+ *
+ * 可选筛选参数:
+ * - groupId: 分组ID
+ * - platform: 平台(douyin/baijiahao/weixin_video/xiaohongshu)
+ * - keyword: 账号关键字(nickname/username)
+ */
+router.get(
+  '/overview/export',
+  [
+    query('groupId').optional().isInt().withMessage('groupId 必须是整数'),
+    query('platform').optional().isString().withMessage('platform 必须是字符串'),
+    query('keyword').optional().isString().withMessage('keyword 必须是字符串'),
+    validateRequest,
+  ],
+  asyncHandler(async (req, res) => {
+    const data = await workDayStatisticsService.getOverview(req.user!.userId);
+
+    const groupId = req.query.groupId ? Number(req.query.groupId) : undefined;
+    const platform = (req.query.platform as string | undefined) || undefined;
+    const keyword = ((req.query.keyword as string | undefined) || '').trim().toLowerCase() || undefined;
+
+    let accounts = data.accounts || [];
+    if (groupId) accounts = accounts.filter(a => a.groupId === groupId);
+    if (platform) accounts = accounts.filter(a => a.platform === platform);
+    if (keyword) {
+      accounts = accounts.filter(a =>
+        (a.nickname || '').toLowerCase().includes(keyword) ||
+        (a.username || '').toLowerCase().includes(keyword)
+      );
+    }
+
+    // 组装给 Python 的导出数据(列顺序按截图)
+    const exportPayload = {
+      accounts: accounts.map(a => ({
+        account: a.nickname || a.username || '',
+        platform: a.platform || '',
+        totalIncome: a.totalIncome,
+        yesterdayIncome: a.yesterdayIncome,
+        totalViews: a.totalViews,
+        yesterdayViews: a.yesterdayViews,
+        fansCount: a.fansCount,
+        yesterdayComments: a.yesterdayComments ?? 0,
+        yesterdayLikes: a.yesterdayLikes ?? 0,
+        yesterdayFansIncrease: a.yesterdayFansIncrease ?? 0,
+        // 交给 Python 解析为 datetime,避免 Excel 显示 #######
+        updateTime: a.updateTime || '',
+      })),
+    };
+
+    const xlsxBuffer = await runPythonExportXlsx(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 = `work_day_overview_${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);
+  })
+);
+
+export default router;
+

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

@@ -412,6 +412,7 @@ export class WorkDayStatisticsService {
       avatarUrl: string | null;
       platform: string;
       groupId: number | null;
+      groupName?: string | null;
       fansCount: number;
       totalIncome: number | null;
       yesterdayIncome: number | null;
@@ -444,6 +445,7 @@ export class WorkDayStatisticsService {
         userId,
         platform: In(allowedPlatforms),
       },
+      relations: ['group'],
     });
 
     // 使用中国时区(UTC+8)计算“今天/昨天”的业务日期
@@ -553,6 +555,7 @@ export class WorkDayStatisticsService {
           avatarUrl: account.avatarUrl,
           platform: account.platform,
           groupId: account.groupId,
+        groupName: account.group?.name ?? null,
           fansCount: accountFansCount,
           totalIncome: null,
           yesterdayIncome: null,
@@ -740,6 +743,7 @@ export class WorkDayStatisticsService {
         avatarUrl: account.avatarUrl,
         platform: account.platform,
         groupId: account.groupId,
+        groupName: account.group?.name ?? null,
         fansCount: accountFansCount,
         totalIncome: null, // 收益数据需要从其他表获取,暂时为null
         yesterdayIncome: null,