Bladeren bron

作品数据导出

Ethanfly 1 dag geleden
bovenliggende
commit
3484e297e4

+ 67 - 6
client/src/views/Analytics/Work/index.vue

@@ -137,8 +137,10 @@
             <div class="title-cell">
               <div class="work-title">{{ row.title }}</div>
               <div class="work-stats">
+                <!-- 推荐暂不展示
                 <span class="stat-item">推荐 <em>{{ row.recommendCount ?? '--' }}</em></span>
-                <span class="stat-item">阅读 <em>{{ row.viewsCount ?? 0 }}</em></span>
+                -->
+                <span class="stat-item">播放 <em>{{ row.viewsCount ?? 0 }}</em></span>
                 <span class="stat-item">评论 <em>{{ row.commentsCount ?? 0 }}</em></span>
                 <span class="stat-item">分享 <em>{{ row.sharesCount ?? 0 }}</em></span>
                 <span class="stat-item">收藏 <em>{{ row.collectsCount ?? 0 }}</em></span>
@@ -337,12 +339,14 @@ import { Search, Picture, Document, View, ChatDotRound, Share, Star, Pointer } f
 import { PLATFORMS, AVAILABLE_PLATFORM_TYPES } 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';
 import * as echarts from 'echarts';
 
 const authStore = useAuthStore();
+const serverStore = useServerStore();
 const loading = ref(false);
 
 // 日期筛选
@@ -402,7 +406,7 @@ const summaryData = ref({
 // 统计卡片数据
 const summaryStats = computed(() => [
   { label: '作品总数', value: summaryData.value.totalWorks, icon: Document },
-  { label: '推荐量', value: summaryData.value.recommendCount, icon: Pointer },
+  // { 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.sharesCount, icon: Share },
@@ -505,7 +509,7 @@ const trendTitle = computed(() => {
   if (!selectedWork.value) return '趋势';
   if (selectedWork.value.platform !== 'xiaohongshu') {
     const map: Record<TrendMetricKey, string> = {
-      playCount: '播放量趋势',
+      playCount: '播放(阅读)量趋势',
       totalWatchDuration: '播放总时长趋势',
       likeCount: '点赞量趋势',
       commentCount: '评论量趋势',
@@ -996,9 +1000,66 @@ watch(drawerVisible, (visible) => {
   }
 });
 
-// 导出数据
-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 params = new URLSearchParams();
+    params.set('startDate', startDate.value);
+    params.set('endDate', endDate.value);
+    if (selectedPlatform.value) params.set('platform', selectedPlatform.value);
+    if (selectedAccounts.value.length > 0) params.set('accountIds', selectedAccounts.value.join(','));
+    if (selectedGroup.value) params.set('groupId', String(selectedGroup.value));
+    if (searchKeyword.value) params.set('keyword', searchKeyword.value);
+    params.set('sortBy', sortBy.value);
+
+    const url = `${baseUrl}/api/work-day-statistics/works/export?${params.toString()}`;
+
+    const doFetch = async (token: string) => {
+      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);
+    ElMessage.success('导出成功');
+  } catch (error: any) {
+    console.error('导出失败:', error);
+    ElMessage.error(error?.message || '导出失败');
+  }
 }
 
 onMounted(() => {

+ 220 - 0
server/python/export_work_analytics_xlsx.py

@@ -0,0 +1,220 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+从 stdin 读取 JSON(作品数据列表),生成 xlsx 并输出到 stdout(二进制)。
+
+参考融媒宝作品数据格式,列:账号、平台、标题、发布时间、播放量、评论量、分享量、收藏量、点赞量。
+
+输入 JSON 格式:
+{
+  "works": [
+    {
+      "accountName": "xxx",
+      "platform": "douyin",
+      "title": "作品标题",
+      "publishTime": "2026-01-28T12:00:00Z",
+      "viewsCount": 1000,
+      "commentsCount": 10,
+      "sharesCount": 5,
+      "collectsCount": 20,
+      "likesCount": 100
+    }
+  ]
+}
+"""
+
+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:
+  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 = [18, 12, 50, 18, 12, 12, 12, 12, 12]
+
+PLATFORM_NAME_MAP = {
+  "douyin": "抖音",
+  "baijiahao": "百家号",
+  "weixin_video": "视频号",
+  "xiaohongshu": "小红书",
+  "kuaishou": "快手",
+}
+
+
+def _safe_int(v):
+  try:
+    if v is None or v == "":
+      return 0
+    return int(float(v))
+  except Exception:
+    return 0
+
+
+def _safe_str(v) -> str:
+  """将任意值转换为字符串,过滤 Excel 不支持的代理字符。"""
+  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_datetime_pretty(value: str) -> str:
+  """
+  将时间字符串格式化为人类可读格式:
+  - 今年:MM-DD HH:mm
+  - 往年:YYYY-MM-DD HH:mm
+  """
+  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)
+    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
+
+  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
+
+
+def build_xlsx(works):
+  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=True)
+  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 w in works:
+    platform_raw = (w.get("platform") or "").strip()
+    platform_cn = PLATFORM_NAME_MAP.get(platform_raw, platform_raw)
+
+    ws.append([
+      _safe_str(w.get("accountName")),
+      _safe_str(platform_cn),
+      _safe_str(w.get("title")),
+      _safe_str(_format_datetime_pretty(w.get("publishTime"))),
+      _safe_int(w.get("viewsCount")),
+      _safe_int(w.get("commentsCount")),
+      _safe_int(w.get("sharesCount")),
+      _safe_int(w.get("collectsCount")),
+      _safe_int(w.get("likesCount")),
+    ])
+
+  int_cols = {"E", "F", "G", "H", "I"}
+
+  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 in (1, 3):
+        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)
+
+  works = payload.get("works") or []
+  xlsx_bytes = build_xlsx(works)
+  sys.stdout.buffer.write(xlsx_bytes)
+
+
+if __name__ == "__main__":
+  main()

+ 9 - 98
server/src/routes/dashboard.ts

@@ -3,92 +3,21 @@ import { query } from 'express-validator';
 import { authenticate } from '../middleware/auth.js';
 import { asyncHandler } from '../middleware/error.js';
 import { validateRequest } from '../middleware/validate.js';
+import { WorkDayStatisticsService } from '../services/WorkDayStatisticsService.js';
 
 const router = Router();
+const workDayStatisticsService = new WorkDayStatisticsService();
 
 router.use(authenticate);
 
-// 在 Node 中声明全局 fetch
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-declare const fetch: any;
-
-/**
- * 调用本地 Python 统计服务的工具函数
- * 默认地址: http://localhost:5005
- */
-async function callPythonApi(pathname: string, params: Record<string, string | number | undefined>) {
-  const base = process.env.PYTHON_API_URL || 'http://localhost:5005';
-  const url = new URL(base);
-  url.pathname = pathname.startsWith('/') ? pathname : `/${pathname}`;
-
-  const search = new URLSearchParams();
-  Object.entries(params).forEach(([key, value]) => {
-    if (value !== undefined && value !== null) {
-      search.append(key, String(value));
-    }
-  });
-  url.search = search.toString();
-
-  try {
-    const resp = await fetch(url.toString(), { method: 'GET' });
-    
-    if (!resp.ok) {
-      let errorData: any;
-      try {
-        errorData = await resp.json();
-      } catch {
-        const text = await resp.text();
-        errorData = {
-          success: false,
-          error: `Python API 返回错误 (${resp.status}): ${text.substring(0, 500)}`,
-        };
-      }
-      throw new Error(errorData.error || `Python API 返回错误: ${resp.status} ${resp.statusText}`);
-    }
-
-    const contentType = resp.headers.get('content-type') || '';
-    if (!contentType.includes('application/json')) {
-      const text = await resp.text();
-      throw new Error(`Python API 返回非 JSON 响应: ${text.substring(0, 500)}`);
-    }
-
-    const json = await resp.json();
-    return json;
-  } catch (error: any) {
-    if (error.name === 'TypeError' && error.message.includes('fetch')) {
-      throw new Error(`无法连接 Python API (${base}): ${error.message}`);
-    }
-    throw error;
-  }
-}
-
 /**
  * GET /api/dashboard/trend
- * 获取数据趋势(通过 Node 转发到 Python API
+ * 获取数据趋势(直接调用 Node 统计服务,不再经 Python 转发)
  * 按平台分组返回,每个平台一条曲线
- * 
+ *
  * 查询参数:
  *   - days: 天数(默认30天)
  *   - account_id: 账号ID(可选,不填则查询所有平台)
- * 
- * 返回:
- * {
- *   success: true,
- *   data: {
- *     dates: ["01-01", "01-02", ...],
- *     platforms: [
- *       {
- *         platform: "xiaohongshu",
- *         platformName: "小红书",
- *         fansIncrease: [10, 20, ...],
- *         views: [100, 200, ...],
- *         likes: [50, 60, ...],
- *         comments: [5, 6, ...]
- *       },
- *       ...
- *     ]
- *   }
- * }
  */
 router.get(
   '/trend',
@@ -101,30 +30,12 @@ router.get(
     const days = req.query.days ? parseInt(req.query.days as string) : 30;
     const accountId = req.query.account_id ? parseInt(req.query.account_id as string) : undefined;
 
-    try {
-      const pythonResult = await callPythonApi('/work_day_statistics/trend', {
-        user_id: req.user!.userId,
-        days,
-        account_id: accountId,
-      });
-
-      if (!pythonResult || pythonResult.success === false) {
-        return res.status(500).json({
-          success: false,
-          error: pythonResult?.error || '获取数据趋势失败',
-          message: pythonResult?.error || '获取数据趋势失败',
-        });
-      }
+    const data = await workDayStatisticsService.getTrend(req.user!.userId, {
+      days,
+      accountId,
+    });
 
-      return res.json({ success: true, data: pythonResult.data });
-    } catch (error: any) {
-      console.error('[dashboard/trend] 调用 Python API 失败:', error);
-      return res.status(500).json({
-        success: false,
-        error: error.message || '调用 Python API 失败',
-        message: error.message || '调用 Python API 失败',
-      });
-    }
+    return res.json({ success: true, data });
   })
 );
 

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

@@ -63,6 +63,45 @@ function runPythonExportXlsx(payload: unknown): Promise<Buffer> {
   });
 }
 
+function runPythonExportWorksXlsx(payload: unknown): Promise<Buffer> {
+  const pythonBin = process.env.PYTHON_BIN || 'python';
+  const scriptPath = path.resolve(__dirname, '../../python/export_work_analytics_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);
+    }
+  });
+}
+
 function runPythonExportPlatformXlsx(payload: unknown): Promise<Buffer> {
   const pythonBin = process.env.PYTHON_BIN || 'python';
   const scriptPath = path.resolve(__dirname, '../../python/export_platform_statistics_xlsx.py');
@@ -416,6 +455,88 @@ router.get(
 );
 
 /**
+ * GET /api/work-day-statistics/works/export
+ * 导出「作品数据」xlsx(按当前筛选条件)
+ *
+ * 查询参数与 /works 一致:
+ * - startDate, endDate: 必填
+ * - platform, accountIds, groupId, keyword, sortBy: 可选
+ */
+router.get(
+  '/works/export',
+  [
+    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 必须是字符串'),
+    validateRequest,
+  ],
+  asyncHandler(async (req, res) => {
+    const {
+      startDate,
+      endDate,
+      platform,
+      accountIds,
+      groupId,
+      keyword,
+      sortBy,
+    } = 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: 1,
+      pageSize: 100000,
+    });
+
+    const exportPayload = {
+      works: (data.works || []).map((w) => ({
+        accountName: w.accountName || '',
+        platform: w.platform || '',
+        title: w.title || '',
+        publishTime: w.publishTime || '',
+        viewsCount: w.viewsCount ?? 0,
+        commentsCount: w.commentsCount ?? 0,
+        sharesCount: w.sharesCount ?? 0,
+        collectsCount: w.collectsCount ?? 0,
+        likesCount: w.likesCount ?? 0,
+      })),
+    };
+
+    const xlsxBuffer = await runPythonExportWorksXlsx(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_data_${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/work/:workId
  * 获取单个作品的历史统计数据(用于作品详情页)
  *