Quellcode durchsuchen

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

Ethanfly vor 1 Monat
Ursprung
Commit
bc38c573e7
52 geänderte Dateien mit 2346 neuen und 995 gelöschten Zeilen
  1. 5 1
      .gitignore
  2. 0 560
      client/dist-electron/main.js
  3. 0 0
      client/dist-electron/main.js.map
  4. 0 43
      client/dist-electron/preload.js
  5. 76 0
      client/electron/main.ts
  6. 5 0
      client/electron/preload.ts
  7. 0 10
      client/src/components.d.ts
  8. 2 2
      client/src/views/Accounts/index.vue
  9. 715 126
      client/src/views/Analytics/Work/index.vue
  10. 14 0
      database/migrations/delete_weixin_video_works_and_related.sql
  11. 1 0
      server/package.json
  12. 2 0
      server/python/README.md
  13. BIN
      server/python/__pycache__/app.cpython-311.pyc
  14. BIN
      server/python/__pycache__/app.cpython-313.pyc
  15. 1 2
      server/python/app.py
  16. 220 0
      server/python/export_work_analytics_xlsx.py
  17. BIN
      server/python/platforms/__pycache__/__init__.cpython-311.pyc
  18. BIN
      server/python/platforms/__pycache__/__init__.cpython-313.pyc
  19. BIN
      server/python/platforms/__pycache__/baijiahao.cpython-311.pyc
  20. BIN
      server/python/platforms/__pycache__/baijiahao.cpython-313.pyc
  21. BIN
      server/python/platforms/__pycache__/base.cpython-311.pyc
  22. BIN
      server/python/platforms/__pycache__/base.cpython-313.pyc
  23. BIN
      server/python/platforms/__pycache__/douyin.cpython-311.pyc
  24. BIN
      server/python/platforms/__pycache__/douyin.cpython-313.pyc
  25. BIN
      server/python/platforms/__pycache__/kuaishou.cpython-311.pyc
  26. BIN
      server/python/platforms/__pycache__/kuaishou.cpython-313.pyc
  27. BIN
      server/python/platforms/__pycache__/weixin.cpython-311.pyc
  28. BIN
      server/python/platforms/__pycache__/weixin.cpython-313.pyc
  29. BIN
      server/python/platforms/__pycache__/xiaohongshu.cpython-311.pyc
  30. BIN
      server/python/platforms/__pycache__/xiaohongshu.cpython-313.pyc
  31. 1 1
      server/python/platforms/base.py
  32. 4 5
      server/python/platforms/weixin.py
  33. 201 132
      server/python/platforms/xiaohongshu.py
  34. 15 0
      server/python/restart.ps1
  35. BIN
      server/python/weixin_private_msg_116080.png
  36. BIN
      server/python/weixin_private_msg_116198.png
  37. BIN
      server/python/weixin_private_msg_116258.png
  38. BIN
      server/python/weixin_private_msg_376515.png
  39. BIN
      server/python/weixin_private_msg_376632.png
  40. 0 0
      server/python/xiaohongshu_cookies.json
  41. 41 0
      server/src/models/entities/Work.ts
  42. 9 98
      server/src/routes/dashboard.ts
  43. 181 0
      server/src/routes/workDayStatistics.ts
  44. 29 1
      server/src/scheduler/index.ts
  45. 18 0
      server/src/scripts/run-weixin-video-work-stats-import.ts
  46. 148 3
      server/src/services/DouyinWorkStatisticsImportService.ts
  47. 1 1
      server/src/services/HeadlessBrowserService.ts
  48. 447 0
      server/src/services/WeixinVideoWorkStatisticsImportService.ts
  49. 21 0
      server/src/services/WorkDayStatisticsService.ts
  50. 51 1
      server/src/services/WorkService.ts
  51. 121 9
      server/src/services/XiaohongshuWorkNoteStatisticsImportService.ts
  52. 17 0
      shared/src/types/work.ts

+ 5 - 1
.gitignore

@@ -31,6 +31,7 @@ Thumbs.db
 # Electron
 release/
 out/
+client/dist-electron/
 
 # Uploads
 uploads/
@@ -62,4 +63,7 @@ server/python/**/__pycache__/
 .pytest_cache/
 .mypy_cache/
 *.egg-info/
-*.egg
+*.egg
+
+# 本地调试截图/临时文件(不上传)
+server/python/weixin_private_msg_*.png

+ 0 - 560
client/dist-electron/main.js

@@ -1,560 +0,0 @@
-"use strict";
-const { app, BrowserWindow, ipcMain, shell, session, Menu, Tray, nativeImage, webContents } = require("electron");
-const { join } = require("path");
-require("fs");
-let mainWindow = null;
-let tray = null;
-let isQuitting = false;
-const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL;
-function getIconPath() {
-  return VITE_DEV_SERVER_URL ? join(__dirname, "../public/icons/icon-256.png") : join(__dirname, "../dist/icons/icon-256.png");
-}
-function getTrayIconPath() {
-  return VITE_DEV_SERVER_URL ? join(__dirname, "../public/icons/tray-icon.png") : join(__dirname, "../dist/icons/tray-icon.png");
-}
-function createTrayIcon() {
-  const trayIconPath = getTrayIconPath();
-  return nativeImage.createFromPath(trayIconPath);
-}
-function createTray() {
-  const trayIcon = createTrayIcon();
-  tray = new Tray(trayIcon);
-  const contextMenu = Menu.buildFromTemplate([
-    {
-      label: "显示主窗口",
-      click: () => {
-        if (mainWindow) {
-          mainWindow.show();
-          mainWindow.focus();
-        }
-      }
-    },
-    {
-      label: "最小化到托盘",
-      click: () => {
-        mainWindow == null ? void 0 : mainWindow.hide();
-      }
-    },
-    { type: "separator" },
-    {
-      label: "退出",
-      click: () => {
-        isQuitting = true;
-        app.quit();
-      }
-    }
-  ]);
-  tray.setToolTip("多平台媒体管理系统");
-  tray.setContextMenu(contextMenu);
-  tray.on("click", () => {
-    if (mainWindow) {
-      if (mainWindow.isVisible()) {
-        mainWindow.focus();
-      } else {
-        mainWindow.show();
-        mainWindow.focus();
-      }
-    }
-  });
-  tray.on("double-click", () => {
-    if (mainWindow) {
-      mainWindow.show();
-      mainWindow.focus();
-    }
-  });
-}
-function createWindow() {
-  Menu.setApplicationMenu(null);
-  const iconPath = getIconPath();
-  mainWindow = new BrowserWindow({
-    width: 1400,
-    height: 900,
-    minWidth: 1200,
-    minHeight: 700,
-    icon: iconPath,
-    webPreferences: {
-      preload: join(__dirname, "preload.js"),
-      nodeIntegration: false,
-      contextIsolation: true,
-      webviewTag: true
-      // 启用 webview 标签
-    },
-    frame: false,
-    // 无边框窗口,自定义标题栏
-    transparent: false,
-    backgroundColor: "#f0f2f5",
-    show: false
-  });
-  mainWindow.once("ready-to-show", () => {
-    mainWindow == null ? void 0 : mainWindow.show();
-    setupWindowEvents();
-  });
-  if (VITE_DEV_SERVER_URL) {
-    mainWindow.loadURL(VITE_DEV_SERVER_URL);
-    mainWindow.webContents.openDevTools();
-  } else {
-    mainWindow.loadFile(join(__dirname, "../dist/index.html"));
-  }
-  mainWindow.webContents.setWindowOpenHandler(({ url }) => {
-    shell.openExternal(url);
-    return { action: "deny" };
-  });
-  mainWindow.on("close", (event) => {
-    if (!isQuitting) {
-      event.preventDefault();
-      mainWindow == null ? void 0 : mainWindow.hide();
-      if (tray && !app.isPackaged) ;
-    }
-  });
-  mainWindow.on("closed", () => {
-    mainWindow = null;
-  });
-}
-const gotTheLock = app.requestSingleInstanceLock();
-if (!gotTheLock) {
-  app.quit();
-} else {
-  app.on("second-instance", () => {
-    if (mainWindow) {
-      mainWindow.show();
-      if (mainWindow.isMinimized()) mainWindow.restore();
-      mainWindow.focus();
-    }
-  });
-  app.whenReady().then(() => {
-    createTray();
-    createWindow();
-    setupWebviewSessions();
-    app.on("activate", () => {
-      if (BrowserWindow.getAllWindows().length === 0) {
-        createWindow();
-      } else if (mainWindow) {
-        mainWindow.show();
-      }
-    });
-  });
-}
-function setupWebviewSessions() {
-  app.on("web-contents-created", (_event, contents) => {
-    if (contents.getType() === "webview") {
-      contents.setUserAgent(
-        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
-      );
-      contents.on("will-navigate", (event, url) => {
-        if (!isAllowedUrl(url)) {
-          console.log("[WebView] 阻止导航到自定义协议:", url);
-          event.preventDefault();
-        }
-      });
-      contents.setWindowOpenHandler(({ url }) => {
-        if (!isAllowedUrl(url)) {
-          console.log("[WebView] 阻止打开自定义协议窗口:", url);
-          return { action: "deny" };
-        }
-        console.log("[WebView] 拦截新窗口,在当前页面打开:", url);
-        contents.loadURL(url);
-        return { action: "deny" };
-      });
-      contents.session.setPermissionRequestHandler((_webContents, permission, callback) => {
-        callback(true);
-      });
-      contents.session.webRequest.onBeforeSendHeaders((details, callback) => {
-        delete details.requestHeaders["X-DevTools-Emulate-Network-Conditions-Client-Id"];
-        if (!details.requestHeaders["Origin"] && !details.requestHeaders["origin"]) ;
-        callback({ requestHeaders: details.requestHeaders });
-      });
-    }
-  });
-}
-function isAllowedUrl(url) {
-  if (!url) return false;
-  const lowerUrl = url.toLowerCase();
-  return lowerUrl.startsWith("http://") || lowerUrl.startsWith("https://") || lowerUrl.startsWith("about:") || lowerUrl.startsWith("data:");
-}
-app.on("window-all-closed", () => {
-});
-app.on("before-quit", () => {
-  isQuitting = true;
-});
-app.on("quit", () => {
-  if (tray) {
-    tray.destroy();
-    tray = null;
-  }
-});
-ipcMain.handle("get-app-version", () => {
-  return app.getVersion();
-});
-ipcMain.handle("get-platform", () => {
-  return process.platform;
-});
-ipcMain.on("window-minimize", () => {
-  mainWindow == null ? void 0 : mainWindow.minimize();
-});
-ipcMain.on("window-maximize", () => {
-  if (mainWindow == null ? void 0 : mainWindow.isMaximized()) {
-    mainWindow.unmaximize();
-  } else {
-    mainWindow == null ? void 0 : mainWindow.maximize();
-  }
-});
-ipcMain.on("window-close", () => {
-  mainWindow == null ? void 0 : mainWindow.hide();
-});
-ipcMain.on("app-quit", () => {
-  isQuitting = true;
-  app.quit();
-});
-ipcMain.handle("window-is-maximized", () => {
-  return (mainWindow == null ? void 0 : mainWindow.isMaximized()) || false;
-});
-function setupWindowEvents() {
-  mainWindow == null ? void 0 : mainWindow.on("maximize", () => {
-    mainWindow == null ? void 0 : mainWindow.webContents.send("window-maximized", true);
-  });
-  mainWindow == null ? void 0 : mainWindow.on("unmaximize", () => {
-    mainWindow == null ? void 0 : mainWindow.webContents.send("window-maximized", false);
-  });
-}
-ipcMain.handle("get-webview-cookies", async (_event, partition, url) => {
-  try {
-    const ses = session.fromPartition(partition);
-    const cookies = await ses.cookies.get({ url });
-    return cookies;
-  } catch (error) {
-    console.error("获取 cookies 失败:", error);
-    return [];
-  }
-});
-ipcMain.handle("clear-webview-cookies", async (_event, partition) => {
-  try {
-    const ses = session.fromPartition(partition);
-    await ses.clearStorageData({ storages: ["cookies"] });
-    return true;
-  } catch (error) {
-    console.error("清除 cookies 失败:", error);
-    return false;
-  }
-});
-ipcMain.handle("set-webview-cookies", async (_event, partition, cookies) => {
-  try {
-    console.log(`[Main] 设置 webview cookies, partition=${partition}, count=${cookies.length}`);
-    const ses = session.fromPartition(partition);
-    let successCount = 0;
-    for (const cookie of cookies) {
-      try {
-        const cookieToSet = {
-          url: cookie.url,
-          name: cookie.name,
-          value: cookie.value,
-          domain: cookie.domain,
-          path: cookie.path || "/"
-        };
-        if (cookie.expirationDate) {
-          cookieToSet.expirationDate = cookie.expirationDate;
-        }
-        if (cookie.httpOnly !== void 0) {
-          cookieToSet.httpOnly = cookie.httpOnly;
-        }
-        if (cookie.secure !== void 0) {
-          cookieToSet.secure = cookie.secure;
-        }
-        if (cookie.sameSite) {
-          cookieToSet.sameSite = cookie.sameSite;
-        }
-        await ses.cookies.set(cookieToSet);
-        successCount++;
-        if (cookie.name === "BDUSS" || cookie.name === "STOKEN" || cookie.name === "sessionid") {
-          console.log(`[Main] 成功设置关键 Cookie: ${cookie.name}, domain: ${cookie.domain}`);
-        }
-      } catch (error) {
-        console.error(`[Main] 设置 cookie 失败 (${cookie.name}):`, error);
-      }
-    }
-    console.log(`[Main] 成功设置 ${successCount}/${cookies.length} 个 cookies`);
-    try {
-      const setCookies = await ses.cookies.get({ domain: ".baidu.com" });
-      console.log(`[Main] 验证:当前 session 中有 ${setCookies.length} 个百度 Cookie`);
-      const keyNames = setCookies.slice(0, 5).map((c) => c.name).join(", ");
-      console.log(`[Main] 关键 Cookie 名称: ${keyNames}`);
-    } catch (verifyError) {
-      console.error("[Main] 验证 Cookie 失败:", verifyError);
-    }
-    return true;
-  } catch (error) {
-    console.error("[Main] 设置 cookies 失败:", error);
-    return false;
-  }
-});
-ipcMain.handle("capture-webview-page", async (_event, webContentsId) => {
-  try {
-    const wc = webContents.fromId(webContentsId);
-    if (!wc) {
-      console.error("找不到 webContents:", webContentsId);
-      return null;
-    }
-    const image = await wc.capturePage();
-    if (!image || image.isEmpty()) {
-      console.warn("截图为空");
-      return null;
-    }
-    const buffer = image.toJPEG(80);
-    return buffer.toString("base64");
-  } catch (error) {
-    console.error("截图失败:", error);
-    return null;
-  }
-});
-ipcMain.handle("webview-send-mouse-click", async (_event, webContentsId, x, y) => {
-  try {
-    const wc = webContents.fromId(webContentsId);
-    if (!wc) {
-      console.error("找不到 webContents:", webContentsId);
-      return false;
-    }
-    wc.sendInputEvent({
-      type: "mouseMove",
-      x: Math.round(x),
-      y: Math.round(y)
-    });
-    await new Promise((resolve) => setTimeout(resolve, 50));
-    wc.sendInputEvent({
-      type: "mouseDown",
-      x: Math.round(x),
-      y: Math.round(y),
-      button: "left",
-      clickCount: 1
-    });
-    await new Promise((resolve) => setTimeout(resolve, 50));
-    wc.sendInputEvent({
-      type: "mouseUp",
-      x: Math.round(x),
-      y: Math.round(y),
-      button: "left",
-      clickCount: 1
-    });
-    console.log(`[webview-send-mouse-click] Clicked at (${x}, ${y})`);
-    return true;
-  } catch (error) {
-    console.error("发送点击事件失败:", error);
-    return false;
-  }
-});
-ipcMain.handle("webview-send-text-input", async (_event, webContentsId, text) => {
-  try {
-    const wc = webContents.fromId(webContentsId);
-    if (!wc) {
-      console.error("找不到 webContents:", webContentsId);
-      return false;
-    }
-    for (const char of text) {
-      wc.sendInputEvent({
-        type: "char",
-        keyCode: char
-      });
-      await new Promise((resolve) => setTimeout(resolve, 30));
-    }
-    console.log(`[webview-send-text-input] Typed: ${text}`);
-    return true;
-  } catch (error) {
-    console.error("发送输入事件失败:", error);
-    return false;
-  }
-});
-ipcMain.handle("webview-get-element-position", async (_event, webContentsId, selector) => {
-  try {
-    const wc = webContents.fromId(webContentsId);
-    if (!wc) {
-      console.error("找不到 webContents:", webContentsId);
-      return null;
-    }
-    const result = await wc.executeJavaScript(`
-      (function() {
-        const el = document.querySelector('${selector.replace(/'/g, "\\'")}');
-        if (!el) return null;
-        const rect = el.getBoundingClientRect();
-        return {
-          x: rect.left + rect.width / 2,
-          y: rect.top + rect.height / 2,
-          width: rect.width,
-          height: rect.height
-        };
-      })()
-    `);
-    return result;
-  } catch (error) {
-    console.error("获取元素位置失败:", error);
-    return null;
-  }
-});
-ipcMain.handle("webview-click-by-text", async (_event, webContentsId, text) => {
-  try {
-    const wc = webContents.fromId(webContentsId);
-    if (!wc) {
-      console.error("找不到 webContents:", webContentsId);
-      return false;
-    }
-    const position = await wc.executeJavaScript(`
-      (function() {
-        const searchText = '${text.replace(/'/g, "\\'")}';
-        
-        // 查找可点击元素
-        const clickables = document.querySelectorAll('a, button, [role="button"], [onclick], input[type="submit"], input[type="button"]');
-        for (const el of clickables) {
-          if (el.textContent?.includes(searchText) || el.getAttribute('aria-label')?.includes(searchText) || el.getAttribute('title')?.includes(searchText)) {
-            const rect = el.getBoundingClientRect();
-            if (rect.width > 0 && rect.height > 0) {
-              return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
-            }
-          }
-        }
-        
-        // 查找所有包含文本的元素
-        const allElements = document.querySelectorAll('*');
-        for (const el of allElements) {
-          const text = el.innerText?.trim();
-          if (text && text.length < 100 && text.includes(searchText)) {
-            const rect = el.getBoundingClientRect();
-            if (rect.width > 0 && rect.height > 0 && rect.width < 500) {
-              return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
-            }
-          }
-        }
-        
-        return null;
-      })()
-    `);
-    if (!position) {
-      console.warn(`[webview-click-by-text] 未找到包含 "${text}" 的元素`);
-      return false;
-    }
-    wc.sendInputEvent({ type: "mouseMove", x: Math.round(position.x), y: Math.round(position.y) });
-    await new Promise((resolve) => setTimeout(resolve, 50));
-    wc.sendInputEvent({ type: "mouseDown", x: Math.round(position.x), y: Math.round(position.y), button: "left", clickCount: 1 });
-    await new Promise((resolve) => setTimeout(resolve, 50));
-    wc.sendInputEvent({ type: "mouseUp", x: Math.round(position.x), y: Math.round(position.y), button: "left", clickCount: 1 });
-    console.log(`[webview-click-by-text] Clicked "${text}" at (${position.x}, ${position.y})`);
-    return true;
-  } catch (error) {
-    console.error("通过文本点击失败:", error);
-    return false;
-  }
-});
-const networkInterceptors = /* @__PURE__ */ new Map();
-ipcMain.handle("enable-network-intercept", async (_event, webContentsId, patterns) => {
-  var _a;
-  try {
-    const wc = webContents.fromId(webContentsId);
-    if (!wc) {
-      console.error("[CDP] 找不到 webContents:", webContentsId);
-      return false;
-    }
-    if (networkInterceptors.has(webContentsId)) {
-      try {
-        wc.debugger.detach();
-      } catch (e) {
-      }
-    }
-    networkInterceptors.set(webContentsId, {
-      patterns,
-      pendingRequests: /* @__PURE__ */ new Map()
-    });
-    try {
-      wc.debugger.attach("1.3");
-    } catch (err) {
-      const error = err;
-      if (!((_a = error.message) == null ? void 0 : _a.includes("Already attached"))) {
-        throw err;
-      }
-    }
-    await wc.debugger.sendCommand("Network.enable");
-    wc.debugger.on("message", async (_e, method, params) => {
-      const config = networkInterceptors.get(webContentsId);
-      if (!config) return;
-      if (method === "Network.responseReceived") {
-        const { requestId, response } = params;
-        if (!requestId || !(response == null ? void 0 : response.url)) return;
-        if (response.url.includes("baijiahao.baidu.com")) {
-          if (response.url.includes("/pcui/") || response.url.includes("/article")) {
-            console.log(`[CDP DEBUG] 百家号 API: ${response.url}`);
-          }
-        }
-        for (const pattern of config.patterns) {
-          if (response.url.includes(pattern.match)) {
-            config.pendingRequests.set(requestId, {
-              url: response.url,
-              timestamp: Date.now()
-            });
-            console.log(`[CDP] 匹配到 API: ${pattern.key} - ${response.url}`);
-            break;
-          }
-        }
-      }
-      if (method === "Network.loadingFinished") {
-        const { requestId } = params;
-        if (!requestId) return;
-        const pending = config.pendingRequests.get(requestId);
-        if (!pending) return;
-        config.pendingRequests.delete(requestId);
-        try {
-          const result = await wc.debugger.sendCommand("Network.getResponseBody", { requestId });
-          let body = result.body;
-          if (result.base64Encoded) {
-            body = Buffer.from(body, "base64").toString("utf8");
-          }
-          const data = JSON.parse(body);
-          let matchedKey = "";
-          for (const pattern of config.patterns) {
-            if (pending.url.includes(pattern.match)) {
-              matchedKey = pattern.key;
-              break;
-            }
-          }
-          if (matchedKey) {
-            console.log(`[CDP] 获取到响应: ${matchedKey}`, JSON.stringify(data).substring(0, 200));
-            mainWindow == null ? void 0 : mainWindow.webContents.send("network-intercept-data", {
-              webContentsId,
-              key: matchedKey,
-              url: pending.url,
-              data
-            });
-          }
-        } catch (err) {
-          console.warn(`[CDP] 获取响应体失败:`, err);
-        }
-      }
-    });
-    console.log(`[CDP] 已启用网络拦截,webContentsId: ${webContentsId}, patterns:`, patterns.map((p) => p.key));
-    return true;
-  } catch (error) {
-    console.error("[CDP] 启用网络拦截失败:", error);
-    return false;
-  }
-});
-ipcMain.handle("disable-network-intercept", async (_event, webContentsId) => {
-  try {
-    const wc = webContents.fromId(webContentsId);
-    if (wc) {
-      try {
-        wc.debugger.detach();
-      } catch (e) {
-      }
-    }
-    networkInterceptors.delete(webContentsId);
-    console.log(`[CDP] 已禁用网络拦截,webContentsId: ${webContentsId}`);
-    return true;
-  } catch (error) {
-    console.error("[CDP] 禁用网络拦截失败:", error);
-    return false;
-  }
-});
-ipcMain.handle("update-network-patterns", async (_event, webContentsId, patterns) => {
-  const config = networkInterceptors.get(webContentsId);
-  if (config) {
-    config.patterns = patterns;
-    console.log(`[CDP] 已更新 patterns,webContentsId: ${webContentsId}`);
-    return true;
-  }
-  return false;
-});
-//# sourceMappingURL=main.js.map

Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
client/dist-electron/main.js.map


Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 43
client/dist-electron/preload.js


+ 76 - 0
client/electron/main.ts

@@ -306,6 +306,82 @@ function setupWindowEvents() {
   });
 }
 
+// 弹窗打开平台后台(独立窗口,不嵌入;用于实验,可回归为嵌入)
+ipcMain.handle('open-backend-external', async (_event: unknown, payload: { url: string; cookieData?: string; title?: string }) => {
+  const { url, cookieData, title } = payload || {};
+  if (!url || typeof url !== 'string') return;
+
+  const partition = 'persist:backend-popup-' + Date.now();
+  const ses = session.fromPartition(partition);
+
+  if (cookieData && typeof cookieData === 'string' && cookieData.trim()) {
+    const raw = cookieData.trim();
+    let cookiesToSet: Array<{ name: string; value: string; domain?: string; path?: string }> = [];
+    try {
+      if (raw.startsWith('[') || raw.startsWith('{')) {
+        const parsed = JSON.parse(raw);
+        const arr = Array.isArray(parsed) ? parsed : (parsed?.cookies || []);
+        cookiesToSet = arr.map((c: { name?: string; value?: string; domain?: string; path?: string }) => ({
+          name: String(c?.name ?? '').trim(),
+          value: String(c?.value ?? '').trim(),
+          domain: c?.domain ? String(c.domain) : undefined,
+          path: c?.path ? String(c.path) : '/',
+        })).filter((c: { name: string }) => c.name);
+      } else {
+        raw.split(';').forEach((p: string) => {
+          const idx = p.indexOf('=');
+          if (idx > 0) {
+            const name = p.slice(0, idx).trim();
+            const value = p.slice(idx + 1).trim();
+            if (name) cookiesToSet.push({ name, value, path: '/' });
+          }
+        });
+      }
+    } catch (e) {
+      console.warn('[open-backend-external] 解析 cookie 失败', e);
+    }
+
+    const origin = new URL(url).origin;
+    const hostname = new URL(url).hostname;
+    const defaultDomain = hostname.startsWith('www.') ? hostname.slice(4) : hostname;
+    const domainWithDot = defaultDomain.includes('.') ? '.' + defaultDomain.split('.').slice(-2).join('.') : undefined;
+
+    for (const c of cookiesToSet) {
+      try {
+        await ses.cookies.set({
+          url: origin + '/',
+          name: c.name,
+          value: c.value,
+          domain: c.domain || domainWithDot || hostname,
+          path: c.path || '/',
+        });
+      } catch (err) {
+        console.warn('[open-backend-external] 设置 cookie 失败', c.name, err);
+      }
+    }
+  }
+
+  const win = new BrowserWindow({
+    width: 1280,
+    height: 800,
+    title: title || '平台后台',
+    icon: getIconPath(),
+    webPreferences: {
+      session: ses,
+      nodeIntegration: false,
+      contextIsolation: true,
+    },
+    show: false,
+  });
+
+  win.once('ready-to-show', () => {
+    win.show();
+  });
+
+  await win.loadURL(url);
+  return { ok: true };
+});
+
 // 获取 webview 的 cookies
 ipcMain.handle('get-webview-cookies', async (_event: unknown, partition: string, url: string) => {
   try {

+ 5 - 0
client/electron/preload.ts

@@ -26,6 +26,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
   showNotification: (title: string, body: string) =>
     ipcRenderer.send('show-notification', { title, body }),
 
+  // 弹窗打开平台后台(独立窗口,不嵌入;实验用)
+  openBackendExternal: (url: string, cookieData?: string, title?: string) =>
+    ipcRenderer.invoke('open-backend-external', { url, cookieData, title }),
+
   // Webview Cookie 操作
   getWebviewCookies: (partition: string, url: string) =>
     ipcRenderer.invoke('get-webview-cookies', partition, url),
@@ -86,6 +90,7 @@ declare global {
       webviewSendTextInput: (webContentsId: number, text: string) => Promise<boolean>;
       webviewGetElementPosition: (webContentsId: number, selector: string) => Promise<{ x: number; y: number; width: number; height: number } | null>;
       webviewClickByText: (webContentsId: number, text: string) => Promise<boolean>;
+      openBackendExternal: (url: string, cookieData?: string, title?: string) => Promise<{ ok: boolean }>;
       // CDP 网络拦截
       enableNetworkIntercept: (webContentsId: number, patterns: Array<{match: string, key: string}>) => Promise<boolean>;
       disableNetworkIntercept: (webContentsId: number) => Promise<boolean>;

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

@@ -15,17 +15,11 @@ declare module 'vue' {
     ElBadge: typeof import('element-plus/es')['ElBadge']
     ElButton: typeof import('element-plus/es')['ElButton']
     ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
-    ElCascader: typeof import('element-plus/es')['ElCascader']
     ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
-    ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
-    ElCol: typeof import('element-plus/es')['ElCol']
     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']
@@ -34,7 +28,6 @@ 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']
@@ -44,7 +37,6 @@ declare module 'vue' {
     ElProgress: typeof import('element-plus/es')['ElProgress']
     ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
     ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
-    ElRow: typeof import('element-plus/es')['ElRow']
     ElSelect: typeof import('element-plus/es')['ElSelect']
     ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
     ElSwitch: typeof import('element-plus/es')['ElSwitch']
@@ -53,8 +45,6 @@ declare module 'vue' {
     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']

+ 2 - 2
client/src/views/Accounts/index.vue

@@ -657,7 +657,7 @@ async function openPlatformAdmin(account: PlatformAccount) {
     }
     
     console.log('[账号管理] 获取到 Cookie 数据,准备打开后台');
-    
+
     // 在标签页中打开平台后台,带上 Cookie,设置为管理后台模式
     tabsStore.openBrowserTab(
       account.platform,
@@ -667,7 +667,7 @@ async function openPlatformAdmin(account: PlatformAccount) {
       cookieData,
       true // isAdminMode: 管理后台模式,不校验登录和保存账号
     );
-    
+
     ElMessage.success('正在打开后台...');
   } catch (error) {
     console.error('获取账号 Cookie 失败:', error);

+ 715 - 126
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>
@@ -159,15 +161,13 @@
             <span class="publish-time">{{ formatTime(row.publishTime) }}</span>
           </template>
         </el-table-column>
-        <!-- 操作列暂时注释
-        <el-table-column label="操作" width="80" align="center" fixed="right">
+        <el-table-column label="操作" width="100" align="center" fixed="right">
           <template #default="{ row }">
             <el-button type="primary" link @click="handleView(row)">
               查看
             </el-button>
           </template>
         </el-table-column>
-        -->
       </el-table>
       
       <!-- 分页 -->
@@ -182,76 +182,171 @@
       </div>
     </div>
     
-    <!-- 作品详情抽屉 -->
-    <el-drawer v-model="drawerVisible" title="作品详情" size="50%">
+    <!-- 作品详情弹窗 -->
+    <el-dialog
+      v-model="drawerVisible"
+      title="作品详情"
+      width="80%"
+      :close-on-click-modal="false"
+      destroy-on-close
+      class="work-detail-dialog"
+    >
       <div v-if="selectedWork" class="work-detail">
-        <!-- 作品基本信息 -->
+        <!-- 标题和发布时间 -->
         <div class="detail-header">
-          <el-image 
-            :src="selectedWork.coverUrl" 
-            class="work-cover"
-            fit="cover"
-          >
-            <template #error>
-              <div class="cover-placeholder">
-                <el-icon :size="32"><Picture /></el-icon>
+          <h3 class="work-title">{{ selectedWork.title }}</h3>
+          <div class="publish-time">{{ formatTime(selectedWork.publishTime) }}</div>
+        </div>
+
+        <!-- 标签页 -->
+        <el-tabs v-model="activeTab" class="detail-tabs">
+          <!-- 核心数据标签页 -->
+          <el-tab-pane label="核心数据" name="core">
+            <div class="core-data-content">
+              <!-- 流量数据卡片 -->
+              <div class="traffic-data">
+                <div class="section-title-row">
+                  <h4 class="section-title">流量数据</h4>
+                </div>
+                <div class="data-cards">
+                  <!-- 小红书:指标更多 + 支持点选联动趋势图 -->
+                  <template v-if="selectedWork.platform === 'xiaohongshu'">
+                    <div
+                      v-for="item in xhsMetricCards"
+                      :key="item.key"
+                      class="data-card"
+                      :class="{ highlight: activeTrendMetric === item.key }"
+                      role="button"
+                      tabindex="0"
+                      @click="setTrendMetric(item.key)"
+                      @keyup.enter="setTrendMetric(item.key)"
+                    >
+                      <div class="card-label">{{ item.label }}</div>
+                      <div class="card-value">{{ item.value }}</div>
+                    </div>
+                  </template>
+
+                  <!-- 抖音:按小红书样式展示可用字段 -->
+                  <template v-else-if="selectedWork.platform === 'douyin'">
+                    <div
+                      v-for="item in douyinMetricCards"
+                      :key="item.label"
+                      class="data-card"
+                      :class="{ highlight: item.key && activeTrendMetric === item.key }"
+                      role="button"
+                      tabindex="0"
+                      @click="item.key && setTrendMetric(item.key)"
+                      @keyup.enter="item.key && setTrendMetric(item.key)"
+                    >
+                      <div class="card-label">{{ item.label }}</div>
+                      <div class="card-value">{{ item.value }}</div>
+                    </div>
+                  </template>
+
+                  <!-- 其他平台:保持原口径 -->
+                  <template v-else>
+                    <div
+                      class="data-card highlight"
+                      role="button"
+                      tabindex="0"
+                      @click="setTrendMetric('playCount')"
+                      @keyup.enter="setTrendMetric('playCount')"
+                    >
+                      <div class="card-label">播放量</div>
+                      <div class="card-value">{{ formatNumber(workDetailData.playCount || 0) }}</div>
+                    </div>
+                    <div
+                      class="data-card"
+                      role="button"
+                      tabindex="0"
+                      @click="setTrendMetric('totalWatchDuration')"
+                      @keyup.enter="setTrendMetric('totalWatchDuration')"
+                    >
+                      <div class="card-label">播放总时长</div>
+                      <div class="card-value">{{ workDetailData.totalWatchDuration || '0秒' }}</div>
+                    </div>
+                    <div
+                      class="data-card"
+                      role="button"
+                      tabindex="0"
+                      @click="setTrendMetric('likeCount')"
+                      @keyup.enter="setTrendMetric('likeCount')"
+                    >
+                      <div class="card-label">点赞量</div>
+                      <div class="card-value">{{ formatNumber(workDetailData.likeCount || 0) }}</div>
+                    </div>
+                    <div
+                      class="data-card"
+                      role="button"
+                      tabindex="0"
+                      @click="setTrendMetric('commentCount')"
+                      @keyup.enter="setTrendMetric('commentCount')"
+                    >
+                      <div class="card-label">评论量</div>
+                      <div class="card-value">{{ formatNumber(workDetailData.commentCount || 0) }}</div>
+                    </div>
+                    <div
+                      class="data-card"
+                      role="button"
+                      tabindex="0"
+                      @click="setTrendMetric('collectCount')"
+                      @keyup.enter="setTrendMetric('collectCount')"
+                    >
+                      <div class="card-label">收藏量</div>
+                      <div class="card-value">{{ formatNumber(workDetailData.collectCount || 0) }}</div>
+                    </div>
+                    <div
+                      class="data-card"
+                      role="button"
+                      tabindex="0"
+                      @click="setTrendMetric('shareCount')"
+                      @keyup.enter="setTrendMetric('shareCount')"
+                    >
+                      <div class="card-label">分享量</div>
+                      <div class="card-value">{{ formatNumber(workDetailData.shareCount || 0) }}</div>
+                    </div>
+                    <div
+                      class="data-card"
+                      role="button"
+                      tabindex="0"
+                      @click="setTrendMetric('fansIncrease')"
+                      @keyup.enter="setTrendMetric('fansIncrease')"
+                    >
+                      <div class="card-label">涨粉量</div>
+                      <div class="card-value">{{ formatNumber(workDetailData.fansIncrease || 0) }}</div>
+                    </div>
+                  </template>
+                </div>
+              </div>
+
+              <!-- 播放量趋势 -->
+              <div class="trend-section">
+                <h4 class="section-title">{{ trendTitle }}</h4>
+                <div ref="playTrendChartRef" style="height: 300px" v-loading="detailLoading"></div>
               </div>
-            </template>
-          </el-image>
-          <div class="header-info">
-            <h3>{{ selectedWork.title }}</h3>
-            <div class="meta-info">
-              <el-tag size="small">{{ getPlatformName(selectedWork.platform) }}</el-tag>
-              <span class="publish-time">发布于 {{ formatTime(selectedWork.publishTime) }}</span>
             </div>
-          </div>
-        </div>
-        
-        <!-- 数据统计 -->
-        <div class="detail-stats">
-          <div class="stat-item">
-            <div class="stat-value">{{ selectedWork.viewsCount || 0 }}</div>
-            <div class="stat-label">阅读</div>
-          </div>
-          <div class="stat-item">
-            <div class="stat-value">{{ selectedWork.likesCount || 0 }}</div>
-            <div class="stat-label">点赞</div>
-          </div>
-          <div class="stat-item">
-            <div class="stat-value">{{ selectedWork.commentsCount || 0 }}</div>
-            <div class="stat-label">评论</div>
-          </div>
-          <div class="stat-item">
-            <div class="stat-value">{{ selectedWork.collectsCount || 0 }}</div>
-            <div class="stat-label">收藏</div>
-          </div>
-          <div class="stat-item">
-            <div class="stat-value">{{ selectedWork.sharesCount || 0 }}</div>
-            <div class="stat-label">分享</div>
-          </div>
-        </div>
-        
-        <!-- 作品内容 -->
-        <div class="detail-content" v-if="selectedWork.content">
-          <h4>作品内容</h4>
-          <div class="content-text">{{ selectedWork.content }}</div>
-        </div>
+          </el-tab-pane>
+
+        </el-tabs>
       </div>
-    </el-drawer>
+    </el-dialog>
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, computed, onMounted } from 'vue';
+import { ref, computed, onMounted, watch, nextTick } from 'vue';
 import { Search, Picture, Document, View, ChatDotRound, Share, Star, Pointer } from '@element-plus/icons-vue';
 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);
 
 // 日期筛选
@@ -311,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 },
@@ -341,9 +436,117 @@ interface WorkData {
 
 const workList = ref<WorkData[]>([]);
 
-// 抽屉相关
+// 详情弹窗相关
 const drawerVisible = ref(false);
 const selectedWork = ref<WorkData | null>(null);
+// 作品基础信息(来自 works 表 /api/works/:id)
+const selectedWorkBase = ref<any | null>(null);
+const activeTab = ref('core');
+const detailLoading = ref(false);
+
+// 作品详情数据
+interface WorkDetailData {
+  playCount: number;
+  exposureCount: number;
+  totalWatchDuration: string;
+  likeCount: number;
+  commentCount: number;
+  collectCount: number;
+  shareCount: number;
+  fansIncrease: number;
+  coverClickRate: string;
+  avgWatchDuration: string;
+  completionRate: string;
+  twoSecondExitRate: string;
+  // 抖音:5s 完播率(仅昨日快照,无趋势)
+  completionRate5s: string;
+}
+
+const workDetailData = ref<WorkDetailData>({
+  playCount: 0,
+  exposureCount: 0,
+  totalWatchDuration: '0秒',
+  likeCount: 0,
+  commentCount: 0,
+  collectCount: 0,
+  shareCount: 0,
+  fansIncrease: 0,
+  coverClickRate: '0',
+  avgWatchDuration: '0',
+  completionRate: '0',
+  twoSecondExitRate: '0',
+  completionRate5s: '0',
+});
+
+// 播放量趋势图
+const playTrendChartRef = ref<HTMLElement>();
+let playTrendChart: echarts.ECharts | null = null;
+
+type TrendMetricKey =
+  | 'exposureCount'
+  | 'playCount'
+  | 'likeCount'
+  | 'commentCount'
+  | 'collectCount'
+  | 'shareCount'
+  | 'coverClickRate'
+  | 'avgWatchDuration'
+  | 'completionRate'
+  | 'twoSecondExitRate'
+  | 'fansIncrease'
+  | 'totalWatchDuration';
+
+const activeTrendMetric = ref<TrendMetricKey>('playCount');
+const workStatsHistory = ref<any[]>([]);
+
+function toIntSafe(v: any): number {
+  const n = Number(String(v ?? '0').replace(/[^\d.-]/g, ''));
+  if (!Number.isFinite(n)) return 0;
+  return Math.max(0, Math.trunc(n));
+}
+
+const trendTitle = computed(() => {
+  if (!selectedWork.value) return '趋势';
+  if (selectedWork.value.platform !== 'xiaohongshu') {
+    const map: Record<TrendMetricKey, string> = {
+      playCount: '播放(阅读)量趋势',
+      totalWatchDuration: '播放总时长趋势',
+      likeCount: '点赞量趋势',
+      commentCount: '评论量趋势',
+      collectCount: '收藏量趋势',
+      shareCount: '分享量趋势',
+      fansIncrease: '涨粉量趋势',
+      exposureCount: '曝光量趋势',
+      coverClickRate: '封面点击率趋势',
+      avgWatchDuration: '平均观看时长趋势',
+      completionRate: '完播率趋势',
+      twoSecondExitRate: '2s退出率趋势',
+    };
+    return map[activeTrendMetric.value] || '趋势';
+  }
+  const map: Record<TrendMetricKey, string> = {
+    exposureCount: '曝光量趋势',
+    playCount: '播放(阅读)量趋势',
+    likeCount: '点赞量趋势',
+    commentCount: '评论量趋势',
+    collectCount: '收藏量趋势',
+    shareCount: '分享量趋势',
+    coverClickRate: '封面点击率趋势',
+    avgWatchDuration: '平均观看时长趋势',
+    completionRate: '完播率趋势',
+    twoSecondExitRate: '2s退出率趋势',
+    fansIncrease: '涨粉量趋势',
+    totalWatchDuration: '播放总时长趋势',
+  };
+  return map[activeTrendMetric.value] || '趋势';
+});
+
+function setTrendMetric(key: TrendMetricKey) {
+  activeTrendMetric.value = key;
+  if (workStatsHistory.value.length > 0) {
+    updatePlayTrendChart(workStatsHistory.value);
+  }
+}
 
 function getPlatformName(platform: PlatformType) {
   return PLATFORMS[platform]?.name || platform;
@@ -511,15 +714,359 @@ async function loadData() {
   }
 }
 
+// 格式化数字
+function formatNumber(num: number | null | undefined): string {
+  if (num === null || num === undefined) return '0';
+  if (num >= 10000) {
+    return (num / 10000).toFixed(1) + '万';
+  }
+  return String(num);
+}
+
+function formatDurationSeconds(secLike: any): string {
+  const s = Math.max(0, parseInt(String(secLike ?? '0'), 10) || 0);
+  if (s >= 60) return `${Math.floor(s / 60)}分${s % 60}秒`;
+  return `${s}秒`;
+}
+
+/** 平均观看时长:保留 2 位小数,不取整 */
+function formatAvgWatchDurationSeconds(secLike: any): string {
+  const s = Math.max(0, parseFloat(String(secLike ?? '0')) || 0);
+  if (s >= 60) return `${Math.floor(s / 60)}分${(s % 60).toFixed(2)}秒`;
+  return `${s.toFixed(2)}秒`;
+}
+
+function formatRate(rateLike: any): string {
+  const raw = String(rateLike ?? '0').trim();
+  if (!raw) return '0%';
+  if (raw.includes('%')) return raw;
+  const n = Number(raw);
+  if (Number.isNaN(n)) return raw;
+  // 兼容:有的入库是 0.12(表示 12%),有的是 12(表示 12%)
+  const pct = n <= 1 ? n * 100 : n;
+  return `${pct.toFixed(2).replace(/\.00$/, '')}%`;
+}
+
+function calcDetailRangeDatesFixed14Days(): { start: string; end: string } {
+  const end = dayjs(endDate.value || dayjs().format('YYYY-MM-DD'));
+  const start = end.subtract(13, 'day'); // 近14天(含当天)
+  const publish = dayjs(selectedWork.value?.publishTime);
+  const clampedStart = publish.isValid() && publish.isAfter(start) ? publish : start;
+  return { start: clampedStart.format('YYYY-MM-DD'), end: end.format('YYYY-MM-DD') };
+}
+
+interface MetricCardConfig {
+  key?: TrendMetricKey;
+  label: string;
+  value: string;
+}
+
+const xhsMetricCards = computed<MetricCardConfig[]>(() => {
+  const d = workDetailData.value;
+  return [
+    { key: 'exposureCount' as const, label: '曝光量', value: formatNumber(d.exposureCount || 0) },
+    { key: 'playCount' as const, label: '播放(阅读)量', value: formatNumber(d.playCount || selectedWork.value?.viewsCount || 0) },
+    { key: 'likeCount' as const, label: '点赞量', value: formatNumber(d.likeCount || selectedWork.value?.likesCount || 0) },
+    { key: 'commentCount' as const, label: '评论量', value: formatNumber(d.commentCount || selectedWork.value?.commentsCount || 0) },
+    { key: 'collectCount' as const, label: '收藏量', value: formatNumber(d.collectCount || selectedWork.value?.collectsCount || 0) },
+    { key: 'shareCount' as const, label: '分享量', value: formatNumber(d.shareCount || selectedWork.value?.sharesCount || 0) },
+    { key: 'coverClickRate' as const, label: '封面点击率', value: formatRate(d.coverClickRate) },
+    { key: 'avgWatchDuration' as const, label: '平均观看时长', value: formatAvgWatchDurationSeconds(d.avgWatchDuration) },
+    { key: 'completionRate' as const, label: '完播率', value: formatRate(d.completionRate) },
+    { key: 'twoSecondExitRate' as const, label: '2s退出率', value: formatRate(d.twoSecondExitRate) },
+    { key: 'fansIncrease' as const, label: '涨粉量', value: formatNumber(d.fansIncrease || 0) },
+  ];
+});
+
+// 抖音:按照小红书样式展示已有字段(不包含曝光量,新增 5s 完播率与播放总时长)
+const douyinMetricCards = computed<MetricCardConfig[]>(() => {
+  const d = workDetailData.value;
+  const base = selectedWork.value;
+  return [
+    { key: 'playCount', label: '播放量', value: formatNumber(d.playCount || base?.viewsCount || 0) },
+    { key: 'likeCount', label: '点赞量', value: formatNumber(d.likeCount || base?.likesCount || 0) },
+    { key: 'commentCount', label: '评论量', value: formatNumber(d.commentCount || base?.commentsCount || 0) },
+    { key: 'collectCount', label: '收藏量', value: formatNumber(d.collectCount || base?.collectsCount || 0) },
+    { key: 'shareCount', label: '分享量', value: formatNumber(d.shareCount || base?.sharesCount || 0) },
+    { key: 'fansIncrease', label: '涨粉量', value: formatNumber(d.fansIncrease || 0) },
+    { label: '平均观看时长', value: formatAvgWatchDurationSeconds(d.avgWatchDuration) },
+    { key: 'completionRate', label: '完播率', value: formatRate(d.completionRate) },
+    { key: 'twoSecondExitRate', label: '2s退出率', value: formatRate(d.twoSecondExitRate) },
+    // 5s 完播率仅为昨日快照,不参与趋势联动
+    { label: '5s完播率', value: formatRate(d.completionRate5s) },
+  ];
+});
+
 // 查看详情
-function handleView(row: WorkData) {
+async function handleView(row: WorkData) {
   selectedWork.value = row;
+  selectedWorkBase.value = null;
+  activeTrendMetric.value = row.platform === 'xiaohongshu' ? 'exposureCount' : 'playCount';
+  workStatsHistory.value = [];
   drawerVisible.value = true;
+  activeTab.value = 'core';
+  
+  // 先用列表行做“瞬时占位”(列表来自区间汇总,可能不等于 works 表累计值)
+  workDetailData.value = {
+    playCount: row.viewsCount || 0,
+    exposureCount: 0,
+    totalWatchDuration: '0秒',
+    likeCount: row.likesCount || 0,
+    commentCount: row.commentsCount || 0,
+    collectCount: row.collectsCount || 0,
+    shareCount: row.sharesCount || 0,
+    fansIncrease: 0,
+    coverClickRate: '0',
+    avgWatchDuration: '0',
+    completionRate: '0',
+    twoSecondExitRate: '0',
+  };
+  
+  // 1) 加载 works 表基础信息(标题、发布时间、累计播放/点赞等)
+  await loadWorkBase(row.id);
+  // 2) 加载 work_day_statistics 历史快照(用于“最新累计值”与趋势)
+  await loadWorkDetail(row.id);
+}
+
+// 加载作品基础信息(works 表)
+async function loadWorkBase(workId: number) {
+  try {
+    const data = await request.get(`/api/works/${workId}`);
+    if (!data) return;
+    selectedWorkBase.value = data;
+
+    // 基础信息补齐:以 works 表为准(如果 works 缺失字段则回退到列表行)
+    if (selectedWork.value) {
+      selectedWork.value = {
+        ...selectedWork.value,
+        title: data.title || selectedWork.value.title,
+        publishTime: data.publishTime || selectedWork.value.publishTime,
+        coverUrl: data.coverUrl || selectedWork.value.coverUrl,
+      };
+    }
+
+    // 顶部卡片:按需求展示 works.yesterday_*(昨日快照)
+    workDetailData.value = {
+      playCount: toIntSafe(data.yesterdayPlayCount ?? 0),
+      exposureCount: toIntSafe(data.yesterdayExposureCount ?? 0),
+      totalWatchDuration: formatDurationSeconds(data.yesterdayTotalWatchDuration ?? 0),
+      likeCount: toIntSafe(data.yesterdayLikeCount ?? 0),
+      commentCount: toIntSafe(data.yesterdayCommentCount ?? 0),
+      collectCount: toIntSafe(data.yesterdayCollectCount ?? 0),
+      shareCount: toIntSafe(data.yesterdayShareCount ?? 0),
+      fansIncrease: toIntSafe(data.yesterdayFansIncrease ?? 0),
+      coverClickRate: String(data.yesterdayCoverClickRate ?? '0'),
+      avgWatchDuration: String(data.yesterdayAvgWatchDuration ?? '0'),
+      completionRate: String(data.yesterdayCompletionRate ?? '0'),
+      twoSecondExitRate: String(data.yesterdayTwoSecondExitRate ?? '0'),
+      completionRate5s: String(data.yesterdayCompletionRate5s ?? '0'),
+    };
+  } catch (error) {
+    // works 表请求失败不影响后续趋势展示
+    console.warn('加载作品基础信息失败:', error);
+  }
+}
+
+// 加载作品详情数据(历史统计数据)
+async function loadWorkDetail(workId: number) {
+  detailLoading.value = true;
+  
+  try {
+    // 趋势固定:近 14 天(按 work_day_statistics 日新增量口径)
+    const { start: startDateStr, end: endDateStr } = calcDetailRangeDatesFixed14Days();
+    
+    // 调用接口获取该作品的历史统计数据(work_day_statistics 快照)
+    const data = await request.get(`/api/work-day-statistics/work/${workId}`, {
+      params: {
+        // 注意:后端校验参数名为 startDate/endDate
+        startDate: startDateStr,
+        endDate: endDateStr,
+      },
+    });
+    
+    if (data && Array.isArray(data)) {
+      const workStats = data;
+      workStatsHistory.value = workStats;
+      
+      // 绘制趋势图(按天新增量)
+      await nextTick();
+      updatePlayTrendChart(workStats);
+    }
+  } catch (error) {
+    console.error('加载作品详情失败:', error);
+    ElMessage.error('加载作品详情失败,请稍后重试');
+  } finally {
+    detailLoading.value = false;
+  }
+}
+
+// 更新播放量趋势图
+function updatePlayTrendChart(stats: Array<any>) {
+  if (!playTrendChartRef.value) return;
+  
+  if (!playTrendChart) {
+    playTrendChart = echarts.init(playTrendChartRef.value);
+  }
+  
+  // 按日期排序
+  const sortedStats = [...stats].sort((a, b) => 
+    dayjs(a.recordDate).valueOf() - dayjs(b.recordDate).valueOf()
+  );
+  
+  const dates = sortedStats.map(s => dayjs(s.recordDate).format('YYYY-MM-DD'));
+
+  const metric = activeTrendMetric.value;
+  const seriesName = trendTitle.value.replace('趋势', '');
+
+  const values = sortedStats.map((s) => {
+    const v = s?.[metric];
+    if (metric === 'coverClickRate' || metric === 'completionRate' || metric === 'twoSecondExitRate') {
+      const raw = String(v ?? '0').trim();
+      if (raw.includes('%')) return Number(raw.replace('%', '')) || 0;
+      const n = Number(raw);
+      if (Number.isNaN(n)) return 0;
+      return n <= 1 ? n * 100 : n;
+    }
+    if (metric === 'avgWatchDuration') {
+      return Math.max(0, parseInt(String(v ?? '0'), 10) || 0);
+    }
+    if (metric === 'totalWatchDuration') {
+      return Math.max(0, parseInt(String(v ?? '0'), 10) || 0);
+    }
+    return Number(v) || 0;
+  });
+  
+  const option: echarts.EChartsOption = {
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: {
+        type: 'cross',
+      },
+    },
+    grid: {
+      left: '3%',
+      right: '4%',
+      bottom: '3%',
+      containLabel: true,
+    },
+    xAxis: {
+      type: 'category',
+      boundaryGap: false,
+      data: dates,
+      axisLabel: {
+        formatter: (value: string) => {
+          return dayjs(value).format('MM-DD');
+        },
+      },
+    },
+    yAxis: {
+      type: 'value',
+      axisLabel: {
+        formatter: (value: number) => {
+          if (value >= 10000) {
+            return (value / 10000).toFixed(1) + '万';
+          }
+          return String(value);
+        },
+      },
+    },
+    series: [
+      {
+        name: seriesName || '趋势',
+        type: 'line',
+        smooth: true,
+        data: values,
+        itemStyle: {
+          color: '#ff6b9d',
+        },
+        areaStyle: {
+          color: {
+            type: 'linear',
+            x: 0,
+            y: 0,
+            x2: 0,
+            y2: 1,
+            colorStops: [
+              { offset: 0, color: 'rgba(255, 107, 157, 0.3)' },
+              { offset: 1, color: 'rgba(255, 107, 157, 0.05)' },
+            ],
+          },
+        },
+      },
+    ],
+  };
+  
+  playTrendChart.setOption(option, true);
 }
 
-// 导出数据
-function handleExport() {
-  ElMessage.info('导出功能开发中');
+// 监听弹窗关闭,清理图表
+watch(drawerVisible, (visible) => {
+  if (!visible && playTrendChart) {
+    playTrendChart.dispose();
+    playTrendChart = null;
+  }
+});
+
+// 导出数据(按当前筛选条件导出作品列表)
+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(() => {
@@ -673,91 +1220,133 @@ onMounted(() => {
   }
 }
 
+:deep(.work-detail-dialog) {
+  .el-dialog__header {
+    padding-bottom: 0;
+  }
+  
+  .el-dialog__body {
+    padding-top: 20px;
+  }
+}
+
 .work-detail {
   .detail-header {
-    display: flex;
-    gap: 16px;
     margin-bottom: 24px;
+    padding-bottom: 16px;
+    border-bottom: 1px solid #f0f0f0;
+    
+    .work-title {
+      margin: 0 0 8px 0;
+      font-size: 18px;
+      font-weight: 600;
+      color: $text-primary;
+    }
     
-    .work-cover {
-      width: 120px;
-      height: 120px;
-      border-radius: 8px;
-      flex-shrink: 0;
+    .publish-time {
+      font-size: 14px;
+      color: $text-secondary;
+    }
+  }
+  
+  .detail-tabs {
+    :deep(.el-tabs__header) {
+      margin-bottom: 24px;
+    }
+    
+    :deep(.el-tabs__item) {
+      font-size: 15px;
+      font-weight: 500;
+    }
+  }
+  
+  .core-data-content {
+    .traffic-data {
+      margin-bottom: 32px;
       
-      .cover-placeholder {
-        width: 100%;
-        height: 100%;
-        background: #f3f4f6;
+      .section-title-row {
         display: flex;
         align-items: center;
-        justify-content: center;
-        color: #9ca3af;
+        justify-content: space-between;
+        gap: 12px;
+        margin-bottom: 16px;
       }
-    }
-    
-    .header-info {
-      flex: 1;
-      
-      h3 {
-        margin: 0 0 12px 0;
+
+      .section-title {
+        margin: 0;
         font-size: 16px;
-        line-height: 1.5;
+        font-weight: 600;
+        color: $text-primary;
       }
-      
-      .meta-info {
+
+      .detail-range {
         display: flex;
-        align-items: center;
-        gap: 12px;
+        gap: 8px;
+        flex-wrap: wrap;
+        justify-content: flex-end;
+      }
+      
+      .data-cards {
+        display: grid;
+        grid-template-columns: repeat(7, 1fr);
+        gap: 16px;
         
-        .publish-time {
-          font-size: 13px;
-          color: $text-secondary;
+        @media (max-width: 1400px) {
+          grid-template-columns: repeat(4, 1fr);
+        }
+        
+        @media (max-width: 900px) {
+          grid-template-columns: repeat(3, 1fr);
+        }
+        
+        @media (max-width: 600px) {
+          grid-template-columns: repeat(2, 1fr);
+        }
+        
+        .data-card {
+          background: #f8fafc;
+          border-radius: 8px;
+          padding: 20px 16px;
+          text-align: center;
+          border: 1px solid #e5e7eb;
+          cursor: pointer;
+          
+          &.highlight {
+            background: #fff5f7;
+            border-color: #ff6b9d;
+            
+            .card-value {
+              color: #ff6b9d;
+              font-weight: 700;
+            }
+          }
+          
+          .card-label {
+            font-size: 13px;
+            color: $text-secondary;
+            margin-bottom: 8px;
+          }
+          
+          .card-value {
+            font-size: 24px;
+            font-weight: 600;
+            color: $text-primary;
+            line-height: 1.2;
+          }
         }
       }
     }
-  }
-  
-  .detail-stats {
-    display: grid;
-    grid-template-columns: repeat(5, 1fr);
-    gap: 16px;
-    margin-bottom: 24px;
     
-    .stat-item {
-      background: #f8fafc;
-      border-radius: 12px;
-      padding: 16px;
-      text-align: center;
-      
-      .stat-value {
-        font-size: 24px;
+    .trend-section {
+      .section-title {
+        margin: 0 0 16px 0;
+        font-size: 16px;
         font-weight: 600;
-        color: $primary-color;
-      }
-      
-      .stat-label {
-        font-size: 13px;
-        color: $text-secondary;
-        margin-top: 4px;
+        color: $text-primary;
       }
     }
   }
   
-  .detail-content {
-    h4 {
-      margin: 0 0 12px 0;
-      font-size: 15px;
-      color: $text-primary;
-    }
-    
-    .content-text {
-      font-size: 14px;
-      line-height: 1.8;
-      color: $text-regular;
-      white-space: pre-wrap;
-    }
-  }
 }
 
 @media (max-width: 1400px) {

+ 14 - 0
database/migrations/delete_weixin_video_works_and_related.sql

@@ -0,0 +1,14 @@
+-- 删除 platform=weixin_video 的作品及其关联数据,便于重新同步(使用正确的 objectId)
+-- 执行顺序:先删评论(comments 无 work_id 外键),再删 works(work_day_statistics 有 ON DELETE CASCADE 会随 works 一起删除)
+-- 执行日期: 2026-02-03
+
+USE media_manager;
+
+-- 1. 删除视频号作品对应的评论(comments 表按 account_id + platform + video_id 与作品对应)
+DELETE FROM comments
+WHERE platform = 'weixin_video'
+  AND video_id IN (SELECT platform_video_id FROM works WHERE platform = 'weixin_video');
+
+-- 2. 删除视频号作品(work_day_statistics 因外键 ON DELETE CASCADE 会一并删除)
+DELETE FROM works
+WHERE platform = 'weixin_video';

+ 1 - 0
server/package.json

@@ -9,6 +9,7 @@
     "xhs:import": "tsx src/scripts/run-xhs-import.ts",
     "xhs:work-stats": "tsx src/scripts/run-xhs-work-stats-import.ts",
     "dy:work-stats": "tsx src/scripts/run-dy-work-stats-import.ts",
+    "wx:work-stats": "tsx src/scripts/run-weixin-video-work-stats-import.ts",
     "check:trend": "tsx src/scripts/check-trend-data.ts",
     "clean:work-day-orphans": "tsx src/scripts/clean-work-day-statistics-orphans.ts",
     "check:douyin-account": "tsx src/scripts/check-douyin-account.ts",

+ 2 - 0
server/python/README.md

@@ -104,6 +104,8 @@ python app.py --headless false
 python app.py --debug
 ```
 
+**作品列表调试日志**:若终端里看不到 `/works` 或视频号作品解析的打印,可查看文件 `server/python/tmp/works_debug.log`,每次请求和每条作品的 objectId/exportId/work_id 会追加写入该文件。
+
 ## API 接口
 
 ### 健康检查

BIN
server/python/__pycache__/app.cpython-311.pyc


BIN
server/python/__pycache__/app.cpython-313.pyc


+ 1 - 2
server/python/app.py

@@ -77,7 +77,6 @@ from platforms import get_publisher, PLATFORM_MAP
 from platforms.base import PublishParams
 from platforms.weixin import WeixinPublisher
 
-
 def parse_datetime(date_str: str):
     """解析日期时间字符串"""
     if not date_str:
@@ -899,7 +898,7 @@ def get_works():
         page_size = data.get("page_size", 20)
         auto_paging = bool(data.get("auto_paging", False))
         
-        print(f"[Works] 收到请求: platform={platform}, page={page}, page_size={page_size}, auto_paging={auto_paging}")
+        print(f"[Works] 收到请求: platform={platform}, page={page}, page_size={page_size}, auto_paging={auto_paging}", flush=True)
         
         if not platform:
             return jsonify({"success": False, "error": "缺少 platform 参数"}), 400

+ 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()

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


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


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


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


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


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


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


BIN
server/python/platforms/__pycache__/douyin.cpython-313.pyc


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


BIN
server/python/platforms/__pycache__/kuaishou.cpython-313.pyc


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


BIN
server/python/platforms/__pycache__/weixin.cpython-313.pyc


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


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


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

@@ -124,7 +124,7 @@ class WorksResult:
     next_page: Any = ""
     error: str = ""
     debug_info: str = ""  # 调试信息
-    
+
     def to_dict(self) -> Dict[str, Any]:
         return {
             "success": self.success,

+ 4 - 5
server/python/platforms/weixin.py

@@ -19,7 +19,6 @@ import time
 # 允许通过环境变量手动指定“上传视频入口”的选择器,便于在页面结构频繁变更时快速调整
 WEIXIN_UPLOAD_SELECTOR = os.environ.get("WEIXIN_UPLOAD_SELECTOR", "").strip()
 
-
 def format_short_title(origin_title: str) -> str:
     """
     格式化短标题
@@ -329,8 +328,7 @@ class WeixinPublisher(BasePublisher):
         # 如果没有安装 Chrome,则使用默认 Chromium
         try:
             self.browser = await playwright.chromium.launch(
-                # headless=self.headless,
-                headless=False,
+                headless=self.headless,
                 channel="chrome",  # 使用系统 Chrome
                 proxy=proxy if proxy and proxy.get('server') else None
             )
@@ -1155,7 +1153,8 @@ class WeixinPublisher(BasePublisher):
             
             for item in raw_list:
                 try:
-                    work_id = str(item.get("objectId") or item.get("id") or "").strip()
+                    # 存 works.platform_video_id 统一用 post_list 接口回参中的 exportId(如 export/xxx)
+                    work_id = str(item.get("exportId") or item.get("objectId") or item.get("id") or "").strip()
                     if not work_id:
                         work_id = f"weixin_{hash(item.get('createTime',0))}_{hash(item.get('desc', {}).get('description',''))}"
                     
@@ -1296,7 +1295,7 @@ class WeixinPublisher(BasePublisher):
                     target_index = i
                     work_title = post.get("desc", {}).get("description", "无标题")
                     print(f"[{self.platform_name}] ✅ 找到目标作品: {work_title}")
-                    break
+                    continue
             
             if not target_feed or not target_post:
                 print(f"[{self.platform_name}] ❌ 未找到 work_id={work_id} 对应的作品")

+ 201 - 132
server/python/platforms/xiaohongshu.py

@@ -16,6 +16,9 @@ from .base import (
     BasePublisher, PublishParams, PublishResult,
     WorkItem, WorksResult, CommentItem, CommentsResult
 )
+from playwright.async_api import async_playwright
+
+stored_cookies = None
 
 # 添加 matrix 项目路径,用于导入签名脚本
 MATRIX_PATH = Path(__file__).parent.parent.parent.parent / "matrix"
@@ -1629,150 +1632,216 @@ class XiaohongshuPublisher(BasePublisher):
             debug_info=debug_info
         )
     
+
     async def get_comments(self, cookies: str, work_id: str, cursor: str = "") -> CommentsResult:
-        """获取小红书作品评论 - 通过创作者后台评论管理页面"""
-        print(f"\n{'='*60}")
-        print(f"[{self.platform_name}] 获取作品评论")
-        print(f"[{self.platform_name}] work_id={work_id}, cursor={cursor}")
-        print(f"{'='*60}")
-        
-        comments: List[CommentItem] = []
-        total = 0
+        """
+        获取账号下所有作品的评论 —— 完全复刻 get_xiaohongshu_work_comments.py 的7步流程。
+        """
+        all_comments: List[CommentItem] = []
+        total_comments = 0
         has_more = False
-        next_cursor = ""
-        captured_data = {}
-        
+        browser = None
+        print(222222222222222222222222222222222222)
+        print(work_id)
+        global stored_cookies
         try:
-            await self.init_browser()
+            # --- Step 1: 初始化浏览器和 Cookie ---
             cookie_list = self.parse_cookies(cookies)
-            await self.set_cookies(cookie_list)
-            
-            if not self.page:
-                raise Exception("Page not initialized")
-            
-            # 设置 API 响应监听器
-            async def handle_response(response):
-                nonlocal captured_data
-                url = response.url
-                # 监听评论相关 API - 创作者后台和普通页面的 API
-                if '/comment/' in url and ('page' in url or 'list' in url):
-                    try:
-                        json_data = await response.json()
-                        print(f"[{self.platform_name}] 捕获到评论 API: {url[:100]}...", flush=True)
-                        if json_data.get('success') or json_data.get('code') == 0:
-                            data = json_data.get('data', {})
-                            comment_list = data.get('comments') or data.get('list') or []
-                            if comment_list:
-                                captured_data = json_data
-                                print(f"[{self.platform_name}] 评论 API 响应成功,comments={len(comment_list)}", flush=True)
-                            else:
-                                print(f"[{self.platform_name}] 评论 API 响应成功但无评论", flush=True)
-                    except Exception as e:
-                        print(f"[{self.platform_name}] 解析评论响应失败: {e}", flush=True)
-            
-            self.page.on('response', handle_response)
-            print(f"[{self.platform_name}] 已注册评论 API 响应监听器", flush=True)
-            
-            # 访问创作者后台评论管理页面
-            comment_url = "https://creator.xiaohongshu.com/creator/comment"
-            print(f"[{self.platform_name}] 访问评论管理页面: {comment_url}", flush=True)
-            await self.page.goto(comment_url, wait_until="domcontentloaded", timeout=30000)
-            await asyncio.sleep(5)
-            
-            # 检查是否被重定向到登录页
-            current_url = self.page.url
-            print(f"[{self.platform_name}] 当前页面 URL: {current_url}", flush=True)
-            if "login" in current_url:
-                raise Exception("Cookie 已过期,请重新登录")
-            
-            # 等待评论加载
-            if not captured_data:
-                print(f"[{self.platform_name}] 等待评论 API 响应...", flush=True)
-                # 尝试滚动页面触发评论加载
-                await self.page.evaluate('window.scrollBy(0, 500)')
-                await asyncio.sleep(3)
-            
-            if not captured_data:
-                # 再等待一会,可能评论 API 加载较慢
-                print(f"[{self.platform_name}] 继续等待评论加载...", flush=True)
-                await asyncio.sleep(5)
-            
-            # 移除监听器
-            self.page.remove_listener('response', handle_response)
-            
-            # 解析评论数据
-            if captured_data:
-                data = captured_data.get('data', {})
-                comment_list = data.get('comments') or data.get('list') or []
-                has_more = data.get('has_more', False)
-                next_cursor = data.get('cursor', '')
-                
-                print(f"[{self.platform_name}] 解析评论: has_more={has_more}, comments={len(comment_list)}", flush=True)
-                
-                for comment in comment_list:
-                    cid = comment.get('id', '')
-                    if not cid:
-                        continue
-                    
-                    user_info = comment.get('user_info', {})
+            playwright = await async_playwright().start()
+            browser = await playwright.chromium.launch(headless=False) 
+            context = await browser.new_context(
+                viewport={"width": 1400, "height": 900},
+                user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
+            )
+            if os.path.exists("cookies.json"):
+                with open("cookies.json", "r") as f:
+                        stored_cookies = json.load(f)
+            if stored_cookies:
+                await context.add_cookies(stored_cookies)
+            page = await context.new_page()
+
+            # --- Step 2: 打开小红书主页 ---
+            await page.goto("https://www.xiaohongshu.com", wait_until="domcontentloaded")
+            await asyncio.sleep(1.5)
+
+            # --- Step 3: 检查并处理登录弹窗 ---
+            try:
+                if await page.is_visible(".login-container", timeout=3000):
+                    await page.wait_for_selector(".login-container", state="hidden", timeout=120000)
+                    stored_cookies = await context.cookies()
+                    with open("xiaohongshu_cookies.json", "w") as f:
+                        json.dump(stored_cookies, f)
                     
-                    # 解析子评论
-                    replies = []
-                    sub_comments = comment.get('sub_comments', []) or []
-                    for sub in sub_comments:
-                        sub_user = sub.get('user_info', {})
-                        replies.append(CommentItem(
-                            comment_id=sub.get('id', ''),
+
+
+            except Exception as e:
+                pass  # 忽略超时,继续执行
+
+            # --- 提取 User ID ---
+            user_id = None
+            for cookie in cookie_list:
+                if cookie.get('name') == 'x-user-id-creator.xiaohongshu.com':
+                    user_id = cookie.get('value')
+                    break
+            if not user_id:
+                raise ValueError("无法从 Cookie 中提取 user_id")
+
+            # --- Step 4: 跳转到用户主页 ---
+            profile_url = f"https://www.xiaohongshu.com/user/profile/{user_id}"
+            await page.goto(profile_url, wait_until="domcontentloaded")
+            await asyncio.sleep(2)
+
+            # --- 等待笔记区域加载 ---
+            try:
+                await page.wait_for_selector("#userPostedFeeds .note-item", timeout=20000)
+            except:
+                raise Exception("笔记区域未加载,请检查账号是否公开或 Cookie 是否有效")
+
+            # --- Step 5: 滚动到底部加载全部笔记 ---
+            last_height = None
+            while True:
+                await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
+                await asyncio.sleep(2)
+                new_height = await page.evaluate("document.body.scrollHeight")
+                if new_height == last_height:
+                    break
+                last_height = new_height
+
+            # --- 获取所有封面图 ---
+            note_imgs = await page.query_selector_all("#userPostedFeeds .note-item .cover img")
+            print(f"共找到 {len(note_imgs)} 张封面图")
+
+            # --- Step 6 & 7: 依次点击封面图,捕获评论并结构化 ---
+            for i, img in enumerate(note_imgs):
+                try:
+                    # >>> 新增:从 img 提取 note_id 并与 work_id 比较 <<<
+                    note_id = await img.evaluate('''el => {
+                        const item = el.closest('.note-item');
+                        if (!item) return null;
+                        const link = item.querySelector('a[href^="/explore/"]');
+                        return link ? link.href.split('/').pop() : null;
+                    }''')
+                    if note_id != work_id:
+                        print(f"note_id {note_id} 与目标 work_id {work_id} 不匹配,跳出循环")
+                        continue
+                    # <<< 新增结束 >>>
+
+                    await img.scroll_into_view_if_needed()
+                    await asyncio.sleep(0.5)
+
+                    comment_resp = None
+                    def handle_response(response):
+                        nonlocal comment_resp
+                        if "edith.xiaohongshu.com/api/sns/web/v2/comment/page" in response.url:
+                            comment_resp = response
+
+                    page.on("response", handle_response)
+                    await img.click()
+                    await asyncio.sleep(1.5)
+                    page.remove_listener("response", handle_response)
+
+                    if not comment_resp:
+                        await page.keyboard.press("Escape")
+                        continue
+
+                    json_data = await comment_resp.json()
+                    if not (json_data.get("success") or json_data.get("code") == 0):
+                        await page.keyboard.press("Escape")
+                        continue
+
+                    data = json_data.get("data", {})
+                    raw_comments = data.get("comments", [])
+                    note_id = data.get("note_id", "")
+
+                    for main_cmt in raw_comments:
+                        # 主评论
+                        user_info = main_cmt.get("user_info", {})
+                        all_comments.append(CommentItem(
+                            comment_id=main_cmt["id"],
+                            parent_comment_id=None,
                             work_id=work_id,
-                            content=sub.get('content', ''),
-                            author_id=sub_user.get('user_id', ''),
-                            author_name=sub_user.get('nickname', ''),
-                            author_avatar=sub_user.get('image', ''),
-                            like_count=sub.get('like_count', 0),
-                            create_time=sub.get('create_time', ''),
-                        ))
-                    
-                    comments.append(CommentItem(
-                        comment_id=cid,
-                        work_id=work_id,
-                        content=comment.get('content', ''),
-                        author_id=user_info.get('user_id', ''),
-                        author_name=user_info.get('nickname', ''),
-                        author_avatar=user_info.get('image', ''),
-                        like_count=comment.get('like_count', 0),
-                        reply_count=comment.get('sub_comment_count', 0),
-                        create_time=comment.get('create_time', ''),
-                        replies=replies,
-                    ))
-                
-                total = len(comments)
-                print(f"[{self.platform_name}] 解析到 {total} 条评论", flush=True)
-            else:
-                print(f"[{self.platform_name}] 未捕获到评论 API 响应", flush=True)
-            
+                            content=main_cmt["content"],
+                            author_id=user_info.get("user_id", ""),
+                            author_name=user_info.get("nickname", ""),
+                            author_avatar=user_info.get("image", ""),
+                            like_count=int(main_cmt.get("like_count", 0)),
+                            reply_count=main_cmt.get("sub_comment_count", 0),
+                            create_time=self._timestamp_to_readable(main_cmt.get("create_time", 0)),
+                            ))
+
+                        # 子评论
+                        for sub_cmt in main_cmt.get("sub_comments", []):
+                            sub_user = sub_cmt.get("user_info", {})
+                            all_comments.append(CommentItem(
+                                comment_id=sub_cmt["id"],
+                                parent_comment_id=main_cmt["id"],
+                                work_id=work_id,
+                                content=sub_cmt["content"],
+                                author_id=sub_user.get("user_id", ""),
+                                author_name=sub_user.get("nickname", ""),
+                                author_avatar=sub_user.get("image", ""),
+                                like_count=int(sub_cmt.get("like_count", 0)),
+                                reply_count=0,
+                                create_time=self._timestamp_to_readable(sub_cmt.get("create_time", 0)),
+                            ))
+
+                    # 关闭弹窗
+                    await page.keyboard.press("Escape")
+                    await asyncio.sleep(1)
+
+                except Exception as e:
+                    # 出错也尝试关闭弹窗
+                    try:
+                        await page.keyboard.press("Escape")
+                        await asyncio.sleep(0.5)
+                    except:
+                        pass
+                    continue
+
+            # --- 返回结果 ---
+            total_comments = len(all_comments)
+            # return {
+            #     'success': True,
+            #     'platform': self.platform_name,
+            #     'work_comments': all_comments,  # 注意:此处为扁平列表,如需按作品分组可在外层处理
+            #     'total': total_comments
+            # }
+            return CommentsResult(
+                success=True, 
+                platform=self.platform_name, 
+                work_id=work_id, 
+                comments=all_comments, 
+                total=total_comments,
+                has_more=has_more
+            )
+
+
         except Exception as e:
             import traceback
             traceback.print_exc()
             return CommentsResult(
-                success=False,
-                platform=self.platform_name,
-                work_id=work_id,
-                error=str(e)
+                success=True, 
+                platform=self.platform_name, 
+                work_id=work_id, 
+                total=0
             )
         finally:
-            await self.close_browser()
-        
-        result = CommentsResult(
-            success=True,
-            platform=self.platform_name,
-            work_id=work_id,
-            comments=comments,
-            total=total,
-            has_more=has_more
-        )
-        result.__dict__['cursor'] = next_cursor
-        return result
-    
+            if browser:
+                await browser.close()
+
+
+
+    def _timestamp_to_readable(self, ts_ms: int) -> str:
+        """将毫秒时间戳转换为可读格式"""
+        from datetime import datetime
+        if not ts_ms:
+            return ""
+        try:
+            return datetime.fromtimestamp(ts_ms / 1000).strftime("%Y-%m-%d %H:%M:%S")
+        except Exception:
+            return ""
+
+
     async def get_all_comments(self, cookies: str) -> dict:
         """获取所有作品的评论 - 通过评论管理页面"""
         print(f"\n{'='*60}")

+ 15 - 0
server/python/restart.ps1

@@ -0,0 +1,15 @@
+# 重启 Python 服务:先结束占用 5005 的进程,再启动 app.py
+# 用法:在 PowerShell 中执行 .\restart.ps1
+
+# 1. 结束占用 5005 端口的进程
+$conn = Get-NetTCPConnection -LocalPort 5005 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1
+if ($conn) {
+    Stop-Process -Id $conn.OwningProcess -Force -ErrorAction SilentlyContinue
+    Write-Host "已结束占用 5005 的进程 PID: $($conn.OwningProcess)"
+} else {
+    Write-Host "5005 端口未被占用,无需结束进程"
+}
+
+# 2. 启动 Python 服务(当前窗口会一直运行,Ctrl+C 可停止)
+Set-Location $PSScriptRoot
+python app.py

BIN
server/python/weixin_private_msg_116080.png


BIN
server/python/weixin_private_msg_116198.png


BIN
server/python/weixin_private_msg_116258.png


BIN
server/python/weixin_private_msg_376515.png


BIN
server/python/weixin_private_msg_376632.png


+ 0 - 0
server/python/xiaohongshu_cookies.json


+ 41 - 0
server/src/models/entities/Work.ts

@@ -58,6 +58,47 @@ export class Work {
   @Column({ name: 'collect_count', type: 'int', default: 0 })
   collectCount!: number;
 
+  // ===== 昨日数据快照(yesterday_*)=====
+
+  @Column({ name: 'yesterday_play_count', type: 'int', default: 0 })
+  yesterdayPlayCount!: number;
+
+  @Column({ name: 'yesterday_like_count', type: 'int', default: 0 })
+  yesterdayLikeCount!: number;
+
+  @Column({ name: 'yesterday_comment_count', type: 'int', default: 0 })
+  yesterdayCommentCount!: number;
+
+  @Column({ name: 'yesterday_share_count', type: 'int', default: 0 })
+  yesterdayShareCount!: number;
+
+  @Column({ name: 'yesterday_collect_count', type: 'int', default: 0 })
+  yesterdayCollectCount!: number;
+
+  @Column({ name: 'yesterday_fans_increase', type: 'int', default: 0 })
+  yesterdayFansIncrease!: number;
+
+  @Column({ name: 'yesterday_cover_click_rate', type: 'varchar', length: 50, default: '0' })
+  yesterdayCoverClickRate!: string;
+
+  @Column({ name: 'yesterday_avg_watch_duration', type: 'varchar', length: 50, default: '0' })
+  yesterdayAvgWatchDuration!: string;
+
+  @Column({ name: 'yesterday_total_watch_duration', type: 'varchar', length: 50, default: '0' })
+  yesterdayTotalWatchDuration!: string;
+
+  @Column({ name: 'yesterday_completion_rate', type: 'varchar', length: 50, default: '0' })
+  yesterdayCompletionRate!: string;
+
+  @Column({ name: 'yesterday_two_second_exit_rate', type: 'varchar', length: 50, default: '0' })
+  yesterdayTwoSecondExitRate!: string;
+
+  @Column({ name: 'yesterday_completion_rate_5s', type: 'varchar', length: 50, default: '0' })
+  yesterdayCompletionRate5s!: string;
+
+  @Column({ name: 'yesterday_exposure_count', type: 'int', default: 0 })
+  yesterdayExposureCount!: number;
+
   @Column({ type: 'datetime', name: 'created_at', default: () => 'CURRENT_TIMESTAMP' })
   createdAt!: Date;
 

+ 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 });
   })
 );
 

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

@@ -7,6 +7,7 @@ import { authenticate } from '../middleware/auth.js';
 import { asyncHandler } from '../middleware/error.js';
 import { validateRequest } from '../middleware/validate.js';
 import { WorkDayStatisticsService } from '../services/WorkDayStatisticsService.js';
+import { AppDataSource, Work } from '../models/index.js';
 import { logger } from '../utils/logger.js';
 
 /**
@@ -62,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');
@@ -414,5 +454,146 @@ 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
+ * 获取单个作品的历史统计数据(用于作品详情页)
+ *
+ * 查询参数:
+ * - startDate: 开始日期(可选)
+ * - endDate: 结束日期(可选)
+ */
+router.get(
+  '/work/:workId',
+  [
+    query('startDate').optional().isString().withMessage('startDate 必须是字符串'),
+    query('endDate').optional().isString().withMessage('endDate 必须是字符串'),
+    validateRequest,
+  ],
+  asyncHandler(async (req, res) => {
+    const workId = Number(req.params.workId);
+    const { startDate, endDate } = req.query;
+
+    if (Number.isNaN(workId)) {
+      return res.status(400).json({
+        success: false,
+        message: 'workId 必须是数字',
+      });
+    }
+
+    // 验证作品属于当前用户
+    const workRepository = AppDataSource.getRepository(Work);
+    const work = await workRepository.findOne({
+      where: { id: workId },
+      relations: ['account'],
+    });
+
+    if (!work) {
+      return res.status(404).json({
+        success: false,
+        message: '作品不存在',
+      });
+    }
+
+    if (work.account.userId !== req.user!.userId) {
+      return res.status(403).json({
+        success: false,
+        message: '无权访问该作品',
+      });
+    }
+
+    const data = await workDayStatisticsService.getWorkStatisticsHistory([workId], {
+      startDate: startDate as string | undefined,
+      endDate: endDate as string | undefined,
+    });
+
+    res.json({
+      success: true,
+      data: data[String(workId)] || [],
+    });
+  })
+);
+
 export default router;
 

+ 29 - 1
server/src/scheduler/index.ts

@@ -12,6 +12,7 @@ import { BaijiahaoContentOverviewImportService } from '../services/BaijiahaoCont
 import { WeixinVideoDataCenterImportService } from '../services/WeixinVideoDataCenterImportService.js';
 import { XiaohongshuWorkNoteStatisticsImportService } from '../services/XiaohongshuWorkNoteStatisticsImportService.js';
 import { DouyinWorkStatisticsImportService } from '../services/DouyinWorkStatisticsImportService.js';
+import { WeixinVideoWorkStatisticsImportService } from '../services/WeixinVideoWorkStatisticsImportService.js';
 
 /**
  * 定时任务调度器
@@ -25,6 +26,7 @@ export class TaskScheduler {
   private isDyWorkImportRunning = false; // 抖音作品日统计导入锁
   private isBjImportRunning = false; // 百家号导入锁,防止任务重叠执行
   private isWxImportRunning = false; // 视频号导入锁,防止任务重叠执行
+  private isWxWorkImportRunning = false; // 视频号作品日统计导入锁
   private isAutoReplying = false; // 私信回复锁,防止任务重叠执行
   /**
    * 启动调度器
@@ -61,7 +63,15 @@ export class TaskScheduler {
 
     // 每天 12:30:批量导出视频号“数据中心-各子菜单-增长详情(数据详情)-近30天-下载表格”,导入 user_day_statistics
     this.scheduleJob('wx-video-data-center-import', '30 12 * * *', this.importWeixinVideoDataCenterLast30Days.bind(this));
-    
+
+    // 每天 12:35:同步视频号作品维度的「作品列表 + 按天聚合-全部」数据,写入 work_day_statistics
+    // [已中止] 暂时禁用,等待接口问题解决
+    // this.scheduleJob(
+    //   'wx-video-work-statistics-import',
+    //   '35 12 * * *',
+    //   this.importWeixinVideoWorkStatistics.bind(this)
+    // );
+
     this.scheduleJob('auto-reply-messages', '* * * * *', this.autoReplyMessages.bind(this));
     // 注意:账号刷新由客户端定时触发,不在服务端自动执行
     // 这样可以确保只刷新当前登录用户的账号,避免处理其他用户的数据
@@ -79,6 +89,7 @@ export class TaskScheduler {
     logger.info('[Scheduler]   - dy-work-statistics-import:  daily at 12:50 (50 12 * * *)');
     logger.info('[Scheduler]   - bj-content-overview-import: daily at 12:20 (20 12 * * *)');
     logger.info('[Scheduler]   - wx-video-data-center-import: daily at 12:30 (30 12 * * *)');
+    // logger.info('[Scheduler]   - wx-video-work-statistics-import: daily at 12:35 (35 12 * * *)');
     logger.info('[Scheduler]   - auto-reply-messages: every minute (* * * * *)');
     logger.info('[Scheduler] Note: Account refresh is triggered by client, not server');
     logger.info('[Scheduler] ========================================');
@@ -524,6 +535,23 @@ export class TaskScheduler {
       this.isWxImportRunning = false;
     }
   }
+
+  /**
+   * 视频号:作品维度「作品列表 + feed_aggreagate_data_by_tab_type 全部」→ 导入 work_day_statistics
+   */
+  private async importWeixinVideoWorkStatistics(): Promise<void> {
+    if (this.isWxWorkImportRunning) {
+      logger.info('[Scheduler] Weixin video work statistics import is already running, skipping...');
+      return;
+    }
+
+    this.isWxWorkImportRunning = true;
+    try {
+      await WeixinVideoWorkStatisticsImportService.runDailyImport();
+    } finally {
+      this.isWxWorkImportRunning = false;
+    }
+  }
 }
 
 export const taskScheduler = new TaskScheduler();

+ 18 - 0
server/src/scripts/run-weixin-video-work-stats-import.ts

@@ -0,0 +1,18 @@
+import { initDatabase } from '../models/index.js';
+import { logger } from '../utils/logger.js';
+import { WeixinVideoWorkStatisticsImportService } from '../services/WeixinVideoWorkStatisticsImportService.js';
+
+async function main() {
+  try {
+    await initDatabase();
+    logger.info('[WX WorkStats] Manual run start...');
+    await WeixinVideoWorkStatisticsImportService.runDailyImport();
+    logger.info('[WX WorkStats] Manual run done.');
+    process.exit(0);
+  } catch (e) {
+    logger.error('[WX WorkStats] Manual run failed:', e);
+    process.exit(1);
+  }
+}
+
+void main();

+ 148 - 3
server/src/services/DouyinWorkStatisticsImportService.ts

@@ -174,11 +174,23 @@ function toInt(val: unknown, defaultValue = 0): number {
 function normalizePercentString(val: unknown): string | undefined {
   const n = toNumber(val, NaN);
   if (!Number.isFinite(n)) return undefined;
-  // 去掉多余的 0:48.730000 -> 48.73
-  const s = n.toString();
+  // 小于等于 0 统一记为 "0"
+  if (n <= 0) return '0';
+  // 原始值视为 0-1 之间的小数,这里 *100 后四舍五入保留两位小数并加 "%"
+  const scaled = n * 100;
+  const rounded = Math.round(scaled * 100) / 100;
+  const s = rounded.toFixed(2).replace(/\.00$/, '').replace(/(\.\d)0$/, '$1');
   return `${s}%`;
 }
 
+/** 平均时长等:保留两位小数,四舍五入 */
+function toFixed2String(val: unknown): string | undefined {
+  const n = toNonNegativeNumber(val);
+  if (n == null) return undefined;
+  const rounded = Math.round(n * 100) / 100;
+  return rounded.toFixed(2);
+}
+
 function isDouyinLoginExpiredByApi(body: any): boolean {
   const code = Number(body?.status_code);
   const msg = String(body?.status_msg || '');
@@ -212,7 +224,8 @@ class DouyinMetricsTrendClient {
       browser_online: 'true',
       timezone_name: 'Asia/Shanghai',
       item_id: itemId,
-      trend_type: '1',
+      // 按照浏览器抓包使用 trend_type=2,表示直接使用原始指标曲线(不是增量/差值)
+      trend_type: '2',
       time_unit: '1',
       metrics_group: '0,1,3',
       metrics: metric,
@@ -337,6 +350,26 @@ class DouyinMetricsTrendClient {
   }
 }
 
+function toNonNegativeNumber(val: unknown): number | undefined {
+  const n = toNumber(val, NaN);
+  if (!Number.isFinite(n)) return undefined;
+  return n < 0 ? 0 : n;
+}
+
+/**
+ * 比率类:0 不加 "%"(返回 "0"),非 0 时 *100 后四舍五入到两位小数并加 "%"
+ * 例如: 0.12345 -> "12.35%", 0.0 -> "0"
+ */
+function toRatePercentStringFromValue(val: unknown): string | undefined {
+  const n = toNumber(val, NaN);
+  if (!Number.isFinite(n)) return undefined;
+  if (n === 0) return '0';
+  const scaled = n * 100;
+  const rounded = Math.round(scaled * 100) / 100; // 保留两位小数,四舍五入
+  const s = rounded.toFixed(2).replace(/\.00$/, '').replace(/(\.\d)0$/, '$1');
+  return `${s}%`;
+}
+
 export class DouyinWorkStatisticsImportService {
   private accountRepository = AppDataSource.getRepository(PlatformAccount);
   private workRepository = AppDataSource.getRepository(Work);
@@ -469,6 +502,19 @@ export class DouyinWorkStatisticsImportService {
             const body = await client.fetchTrend(ctx, page, itemId, m.metric, m.label, detailUrl);
             if (!body || typeof body !== 'object') continue;
 
+            // 调试:把 metrics_trend 原始返回打印成 JSON,方便和抖音后台对比
+            if (work.id === 39 && m.metric === 'completion_rate') {
+              try {
+                logger.info(
+                  `[DY WorkStats][debug metrics_trend raw] workId=${work.id} itemId=${itemId} metric=${m.metric} body=${JSON.stringify(
+                    body
+                  )}`
+                );
+              } catch {
+                // ignore JSON stringify error
+              }
+            }
+
             if (isDouyinLoginExpiredByApi(body)) {
               throw new DouyinLoginExpiredError(body.status_msg || 'metrics_trend: user not match');
             }
@@ -488,7 +534,27 @@ export class DouyinWorkStatisticsImportService {
               ? metricMap['0']
               : Object.values(metricMap).flatMap((arr) => (Array.isArray(arr) ? arr : []));
 
+            // 调试:打印 workId=39 的 completion_rate 全量 points,确认与抖音后台返回是否一致
+            if (work.id === 39 && m.metric === 'completion_rate') {
+              try {
+                logger.info(
+                  `[DY WorkStats][debug completion_rate points] workId=${work.id} itemId=${itemId} points=${JSON.stringify(
+                    points
+                  )}`
+                );
+              } catch {
+                // ignore JSON stringify error
+              }
+            }
+
             for (const pt of points) {
+              // 调试:打印指定作品的完播率原始值
+              if (work.id === 39 && m.metric === 'completion_rate') {
+                logger.info(
+                  `[DY WorkStats][debug completion_rate] workId=${work.id} itemId=${itemId} date=${pt?.date_time} raw_value=${pt?.value}`
+                );
+              }
+
               const d = parseChinaDateFromDateTimeString(pt?.date_time);
               if (!d) continue;
               const key = d.getTime();
@@ -506,6 +572,16 @@ export class DouyinWorkStatisticsImportService {
           );
           if (!patches.length) continue;
 
+          // 同时补充作品级昨日快照(works.yesterday_*),使用 item/mget metrics
+          try {
+            await this.applyWorkSnapshotFromItemMget(ctx, itemId, detailUrl, work.id);
+          } catch (e) {
+            logger.warn(
+              `[DY WorkStats] Failed to update works snapshot from item/mget. accountId=${account.id} workId=${work.id} itemId=${itemId}`,
+              e
+            );
+          }
+
           const result = await this.workDayStatisticsService.saveStatisticsForDateBatch(
             patches.map((p) => ({
               workId: p.workId,
@@ -582,6 +658,75 @@ export class DouyinWorkStatisticsImportService {
     }
   }
 
+  /**
+   * 使用 item/mget 接口为 works 表补充昨日快照(yesterday_* 字段)
+   */
+  private async applyWorkSnapshotFromItemMget(
+    ctx: BrowserContext,
+    itemId: string,
+    refererUrl: string,
+    workId: number
+  ): Promise<void> {
+    const url = `https://creator.douyin.com/web/api/creator/item/mget?ids=${encodeURIComponent(
+      itemId
+    )}&fields=metrics%2Creview%2Cplay_info`;
+
+    const headers: Record<string, string> = {
+      accept: '*/*',
+      'accept-language': 'zh-CN,zh;q=0.9',
+      referer: refererUrl,
+      'user-agent':
+        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36',
+    };
+
+    const res = await ctx.request.get(url, { headers, timeout: 25_000 });
+    const body = (await res.json().catch(() => null)) as any;
+    if (!body || typeof body !== 'object' || Number(body.status_code) !== 0) {
+      return;
+    }
+
+    const items = Array.isArray(body.items) ? body.items : [];
+    const first = items[0];
+    if (!first || typeof first !== 'object') return;
+
+    const metrics = first.metrics || {};
+    const patch: Partial<Work> = {};
+
+    const viewCount = toNonNegativeNumber(metrics.view_count);
+    if (viewCount != null) (patch as any).yesterdayPlayCount = Math.trunc(viewCount);
+
+    const likeCount = toNonNegativeNumber(metrics.like_count);
+    if (likeCount != null) (patch as any).yesterdayLikeCount = Math.trunc(likeCount);
+
+    const commentCount = toNonNegativeNumber(metrics.comment_count);
+    if (commentCount != null) (patch as any).yesterdayCommentCount = Math.trunc(commentCount);
+
+    const shareCount = toNonNegativeNumber(metrics.share_count);
+    if (shareCount != null) (patch as any).yesterdayShareCount = Math.trunc(shareCount);
+
+    const collectCount = toNonNegativeNumber(metrics.favorite_count);
+    if (collectCount != null) (patch as any).yesterdayCollectCount = Math.trunc(collectCount);
+
+    const fansIncrease = toNonNegativeNumber(metrics.subscribe_count);
+    if (fansIncrease != null) (patch as any).yesterdayFansIncrease = Math.trunc(fansIncrease);
+
+    // 平均观看时长(秒):保留两位小数
+    const avgWatchStr = toFixed2String(metrics.avg_view_second);
+    if (avgWatchStr != null) (patch as any).yesterdayAvgWatchDuration = avgWatchStr;
+
+    const completionRateStr = toRatePercentStringFromValue(metrics.completion_rate);
+    if (completionRateStr != null) (patch as any).yesterdayCompletionRate = completionRateStr;
+
+    const twoSecondExitRateStr = toRatePercentStringFromValue(metrics.bounce_rate_2s);
+    if (twoSecondExitRateStr != null) (patch as any).yesterdayTwoSecondExitRate = twoSecondExitRateStr;
+
+    const completion5sStr = toRatePercentStringFromValue(metrics.completion_rate_5s);
+    if (completion5sStr != null) (patch as any).yesterdayCompletionRate5s = completion5sStr;
+
+    if (Object.keys(patch).length === 0) return;
+    await this.workRepository.update(workId, patch as any);
+  }
+
   private async markAccountExpired(account: PlatformAccount, reason: string): Promise<void> {
     await this.accountRepository.update(account.id, { status: 'expired' as any });
     wsManager.sendToUser(account.userId, WS_EVENTS.ACCOUNT_UPDATED, {

+ 1 - 1
server/src/services/HeadlessBrowserService.ts

@@ -735,6 +735,7 @@ class HeadlessBrowserService {
       const pageParam: number | string = useCursorPagination ? cursor : pageIndex;
       logger.info(`[Python API] Fetching works page=${String(pageParam)}, page_size=${pageSize} for ${platform}`);
 
+      logger.info(`[Python API] 调用 Python /works: platform=${platform} -> pythonPlatform=${pythonPlatform}, page=${String(pageParam)}, url=${PYTHON_SERVICE_URL}`);
       const response: Response = await fetch(`${PYTHON_SERVICE_URL}/works`, {
         method: 'POST',
         headers: {
@@ -755,7 +756,6 @@ class HeadlessBrowserService {
 
       const result: any = await response.json();
 
-      // 记录 Python API 的详细响应(用于调试)
       if (pageIndex === 0) {
         logger.info(`[Python API] Response for ${platform}: success=${result.success}, works_count=${result.works?.length || 0}, total=${result.total || 0}, has_more=${result.has_more}, error=${result.error || 'none'}`);
         if (result.error) {

+ 447 - 0
server/src/services/WeixinVideoWorkStatisticsImportService.ts

@@ -0,0 +1,447 @@
+/**
+ * 视频号:作品维度「作品列表 + 按天聚合数据」→ 导入 work_day_statistics
+ *
+ * 流程:
+ * 1. 获取 works 表中 platform=weixin_video 的作品(platform_video_id 存的是 exportId)
+ * 2. 调用 post_list 接口获取作品列表,通过 exportId 匹配得到 objectId
+ * 3. 对每个作品调用 feed_aggreagate_data_by_tab_type,取「全部」tab 的按天数据
+ * 4. 将 browse→播放、like→点赞、comment→评论 写入 work_day_statistics(follow=关注、fav/forward 暂不入库)
+ */
+
+import crypto from 'crypto';
+import { AppDataSource, PlatformAccount, Work } from '../models/index.js';
+import { logger } from '../utils/logger.js';
+import { WorkDayStatisticsService } from './WorkDayStatisticsService.js';
+import { CookieManager } from '../automation/cookie.js';
+
+const POST_LIST_BASE =
+  'https://channels.weixin.qq.com/micro/statistic/cgi-bin/mmfinderassistant-bin/statistic/post_list';
+const FEED_AGGREGATE_BASE =
+  'https://channels.weixin.qq.com/micro/statistic/cgi-bin/mmfinderassistant-bin/statistic/feed_aggreagate_data_by_tab_type';
+
+/** 列表页 _pageUrl(与浏览器「数据统计-作品」列表一致) */
+const POST_LIST_PAGE_URL = 'https://channels.weixin.qq.com/micro/statistic/post';
+/** 详情页 _pageUrl(与浏览器 postDetail 一致,feed_aggreagate 用) */
+const POST_DETAIL_PAGE_URL = 'https://channels.weixin.qq.com/micro/statistic/postDetail';
+
+/** 生成随机 _rid(格式如 6982df69-ff6e46a5,8hex-8hex) */
+function generateRandomRid(): string {
+  const a = crypto.randomBytes(4).toString('hex');
+  const b = crypto.randomBytes(4).toString('hex');
+  return `${a}-${b}`;
+}
+
+/**
+ * 构建带 _aid、_rid、_pageUrl 的 URL。
+ * 若传入 sessionAid/sessionRid 则优先使用(本账号 post_list 生成的,复用于 feed_aggreagate);
+ * 否则读环境变量 WX_VIDEO_AID、WX_VIDEO_RID。
+ */
+function buildUrlWithAidRid(
+  base: string,
+  pageUrl: string,
+  sessionAid?: string,
+  sessionRid?: string
+): string {
+  const aid = sessionAid ?? process.env.WX_VIDEO_AID?.trim() ?? '';
+  const rid = sessionRid ?? process.env.WX_VIDEO_RID?.trim() ?? '';
+  const params = new URLSearchParams();
+  if (aid) params.set('_aid', aid);
+  if (rid) params.set('_rid', rid);
+  params.set('_pageUrl', pageUrl);
+  const qs = params.toString();
+  return qs ? `${base}?${qs}` : base;
+}
+
+function tryDecryptCookieData(cookieData: string | null): string | null {
+  if (!cookieData) return null;
+  const raw = cookieData.trim();
+  if (!raw) return null;
+  try {
+    return CookieManager.decrypt(raw);
+  } catch {
+    return raw;
+  }
+}
+
+/** 将账号 cookie_data 转为 HTTP Cookie 头字符串 */
+function getCookieHeaderString(cookieData: string | null): string {
+  const raw = tryDecryptCookieData(cookieData);
+  if (!raw) return '';
+  const s = raw.trim();
+  if (!s) return '';
+  if (s.startsWith('[') || s.startsWith('{')) {
+    try {
+      const parsed = JSON.parse(s);
+      const arr = Array.isArray(parsed) ? parsed : parsed?.cookies ?? [];
+      if (!Array.isArray(arr)) return '';
+      return arr
+        .map((c: { name?: string; value?: string }) => {
+          const name = String(c?.name ?? '').trim();
+          const value = String(c?.value ?? '').trim();
+          return name ? `${name}=${value}` : '';
+        })
+        .filter(Boolean)
+        .join('; ');
+    } catch {
+      return s;
+    }
+  }
+  return s;
+}
+
+/** 从 Cookie 字符串中解析 x-wechat-uin(可选) */
+function getXWechatUinFromCookie(cookieHeader: string): string | undefined {
+  const match = cookieHeader.match(/\bwxuin=(\d+)/i);
+  return match ? match[1] : undefined;
+}
+
+/** 从账号 account_id 得到 _log_finder_id(去掉 weixin_video_ 前缀,保证以 @finder 结尾) */
+function getLogFinderId(accountId: string | null): string {
+  if (!accountId) return '';
+  const s = String(accountId).trim();
+  const prefix = 'weixin_video_';
+  const id = s.startsWith(prefix) ? s.slice(prefix.length) : s;
+  if (!id) return '';
+  return id.endsWith('@finder') ? id : `${id}@finder`;
+}
+
+function buildPostListUrl(sessionAid?: string, sessionRid?: string): string {
+  return buildUrlWithAidRid(POST_LIST_BASE, POST_LIST_PAGE_URL, sessionAid, sessionRid);
+}
+
+function buildFeedAggregateUrl(sessionAid?: string, sessionRid?: string): string {
+  return buildUrlWithAidRid(FEED_AGGREGATE_BASE, POST_DETAIL_PAGE_URL, sessionAid, sessionRid);
+}
+
+/** 近30天到昨天:返回 [startTime, endTime] Unix 秒(中国时间 00:00:00 起算) */
+function getLast30DaysRange(): { startTime: number; endTime: number; startDate: Date; endDate: Date } {
+  const now = new Date();
+  const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
+  const startDate = new Date(yesterday.getFullYear(), yesterday.getMonth(), yesterday.getDate() - 30);
+  startDate.setHours(0, 0, 0, 0);
+  yesterday.setHours(23, 59, 59, 999);
+  const startTime = Math.floor(startDate.getTime() / 1000);
+  const endTime = Math.floor(yesterday.getTime() / 1000);
+  const endDateNorm = new Date(yesterday.getFullYear(), yesterday.getMonth(), yesterday.getDate());
+  endDateNorm.setHours(0, 0, 0, 0);
+  return { startTime, endTime, startDate, endDate: endDateNorm };
+}
+
+function toInt(val: unknown, defaultVal = 0): number {
+  if (typeof val === 'number') return Number.isFinite(val) ? Math.round(val) : defaultVal;
+  if (typeof val === 'string') {
+    const n = parseInt(val, 10);
+    return Number.isFinite(n) ? n : defaultVal;
+  }
+  return defaultVal;
+}
+
+interface PostListItem {
+  objectId?: string;
+  exportId?: string;
+}
+
+interface FeedAggregateDataByTabType {
+  tabType?: number;
+  tabTypeName?: string;
+  data?: {
+    browse?: string[];
+    like?: string[];
+    comment?: string[];
+    forward?: string[];
+    fav?: string[];
+    follow?: string[];
+  };
+}
+
+export class WeixinVideoWorkStatisticsImportService {
+  private accountRepository = AppDataSource.getRepository(PlatformAccount);
+  private workRepository = AppDataSource.getRepository(Work);
+  private workDayStatisticsService = new WorkDayStatisticsService();
+
+  static async runDailyImport(): Promise<void> {
+    const svc = new WeixinVideoWorkStatisticsImportService();
+    await svc.runDailyImportForAllWeixinVideoAccounts();
+  }
+
+  async runDailyImportForAllWeixinVideoAccounts(): Promise<void> {
+    const accounts = await this.accountRepository.find({
+      where: { platform: 'weixin_video' as any },
+    });
+    logger.info(`[WX WorkStats] Start import for ${accounts.length} weixin_video accounts`);
+    for (const account of accounts) {
+      try {
+        await this.importAccountWorksStatistics(account);
+      } catch (e) {
+        logger.error(
+          `[WX WorkStats] Account failed. accountId=${account.id} name=${account.accountName || ''}`,
+          e
+        );
+      }
+    }
+    logger.info('[WX WorkStats] All accounts done');
+  }
+
+  private async importAccountWorksStatistics(account: PlatformAccount): Promise<void> {
+    const cookieHeader = getCookieHeaderString(account.cookieData);
+    if (!cookieHeader) {
+      logger.warn(`[WX WorkStats] accountId=${account.id} cookieData 为空或无法解析,跳过`);
+      return;
+    }
+
+    const works = await this.workRepository.find({
+      where: { accountId: account.id, platform: 'weixin_video' as any },
+    });
+    if (!works.length) {
+      logger.info(`[WX WorkStats] accountId=${account.id} 没有作品,跳过`);
+      return;
+    }
+
+    const { startTime, endTime, startDate, endDate } = getLast30DaysRange();
+    const logFinderId = getLogFinderId(account.accountId);
+    const xWechatUin = getXWechatUinFromCookie(cookieHeader);
+
+    // _aid:post_list 时生成一次,本批次请求数据接口(feed_aggreagate)时复用;_rid 每次请求随机
+    const sessionAid =
+      process.env.WX_VIDEO_AID?.trim() || crypto.randomUUID();
+    logger.info(`[WX WorkStats] accountId=${account.id} post_list 生成 aid=${sessionAid},数据接口复用此 aid,rid 每次随机`);
+
+    const headers: Record<string, string> = {
+      accept: '*/*',
+      'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
+      'content-type': 'application/json',
+      cookie: cookieHeader,
+      origin: 'https://channels.weixin.qq.com',
+      referer: 'https://channels.weixin.qq.com/micro/statistic/post',
+      'user-agent':
+        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 Edg/144.0.0.0',
+    };
+    if (xWechatUin) headers['x-wechat-uin'] = xWechatUin;
+
+    const postListBody = {
+      pageSize: 100,
+      currentPage: 1,
+      sort: 0,
+      order: 0,
+      startTime,
+      endTime,
+      timestamp: String(Date.now()),
+      _log_finder_uin: '',
+      _log_finder_id: logFinderId,
+      rawKeyBuff: null,
+      pluginSessionId: null,
+      scene: 7,
+      reqScene: 7,
+    };
+
+    const postListUrl = buildPostListUrl(sessionAid, generateRandomRid());
+    let res: Response;
+    try {
+      res = await fetch(postListUrl, {
+        method: 'POST',
+        headers,
+        body: JSON.stringify(postListBody),
+        signal: AbortSignal.timeout(30_000),
+      });
+    } catch (e) {
+      logger.error(`[WX WorkStats] post_list request failed. accountId=${account.id}`, e);
+      throw e;
+    }
+
+    if (!res.ok) {
+      logger.warn(`[WX WorkStats] post_list HTTP ${res.status}. accountId=${account.id}`);
+      return;
+    }
+
+    const postListJson = (await res.json().catch(() => null)) as {
+      errCode?: number;
+      errMsg?: string;
+      data?: { list?: PostListItem[]; totalCount?: number };
+    } | null;
+
+    if (!postListJson || postListJson.errCode !== 0) {
+      logger.warn(
+        `[WX WorkStats] post_list errCode=${postListJson?.errCode} errMsg=${postListJson?.errMsg}. accountId=${account.id}`
+      );
+      return;
+    }
+
+    const list = postListJson.data?.list ?? [];
+    const totalCount = postListJson.data?.totalCount ?? list.length;
+    const exportIdToObjectId = new Map<string, string>();
+    for (const item of list) {
+      const exportId = item.exportId ?? '';
+      const objectId = item.objectId ?? '';
+      if (exportId && objectId) exportIdToObjectId.set(exportId, objectId);
+    }
+
+    // 日志:对比 API 与 DB 的 exportId
+    logger.info(
+      `[WX WorkStats] accountId=${account.id} post_list 返回 totalCount=${totalCount} list.length=${list.length}`
+    );
+    const apiExportIds: string[] = [];
+    for (let i = 0; i < list.length; i++) {
+      const item = list[i];
+      const eid = (item.exportId ?? '').trim();
+      const oid = (item.objectId ?? '').trim();
+      apiExportIds.push(eid);
+      logger.info(`[WX WorkStats] post_list[${i}] exportId=${eid} objectId=${oid}`);
+    }
+    for (const work of works) {
+      const dbExportId = (work.platformVideoId ?? '').trim();
+      if (!dbExportId) continue;
+      const matched = exportIdToObjectId.has(dbExportId);
+      logger.info(
+        `[WX WorkStats] DB workId=${work.id} platform_video_id(exportId)=${dbExportId} 匹配post_list=${matched}`
+      );
+      if (!matched && apiExportIds.length > 0) {
+        const sameLength = apiExportIds.filter((e) => e.length === dbExportId.length).length;
+        const containsDb = apiExportIds.some((e) => e === dbExportId || e.includes(dbExportId) || dbExportId.includes(e));
+        logger.info(
+          `[WX WorkStats] 对比: DB长度=${dbExportId.length} API条数=${apiExportIds.length} 同长API条数=${sameLength} 是否包含关系=${containsDb}`
+        );
+      }
+    }
+
+    let totalInserted = 0;
+    let totalUpdated = 0;
+
+    const feedHeaders: Record<string, string> = {
+      ...headers,
+      referer: 'https://channels.weixin.qq.com/micro/statistic/postDetail?isImageMode=0',
+      'finger-print-device-id':
+        process.env.WX_VIDEO_FINGERPRINT_DEVICE_ID?.trim() ||
+        '4605bc28ad3962eb9ee791897b199217',
+    };
+
+    for (const work of works) {
+      const exportId = (work.platformVideoId ?? '').trim();
+      if (!exportId) continue;
+
+      const objectId = exportIdToObjectId.get(exportId);
+      if (!objectId) {
+        logger.debug(`[WX WorkStats] workId=${work.id} exportId=${exportId} 未在 post_list 中匹配到 objectId,跳过`);
+        continue;
+      }
+
+      const feedBody = {
+        startTs: String(startTime),
+        endTs: String(endTime),
+        interval: 3,
+        feedId: objectId,
+        timestamp: String(Date.now()),
+        _log_finder_uin: '',
+        _log_finder_id: logFinderId,
+        rawKeyBuff: null,
+        pluginSessionId: null,
+        scene: 7,
+        reqScene: 7,
+      };
+
+      const feedUrl = buildFeedAggregateUrl(sessionAid, generateRandomRid());
+      let feedRes: Response;
+      try {
+        feedRes = await fetch(feedUrl, {
+          method: 'POST',
+          headers: feedHeaders,
+          body: JSON.stringify(feedBody),
+          signal: AbortSignal.timeout(30_000),
+        });
+      } catch (e) {
+        logger.error(`[WX WorkStats] feed_aggreagate request failed. workId=${work.id} feedId=${objectId}`, e);
+        continue;
+      }
+
+      if (!feedRes.ok) {
+        logger.warn(`[WX WorkStats] feed_aggreagate HTTP ${feedRes.status}. workId=${work.id}`);
+        continue;
+      }
+
+      const feedJson = (await feedRes.json().catch(() => null)) as {
+        errCode?: number;
+        data?: {
+          dataByFanstype?: { dataByTabtype?: FeedAggregateDataByTabType[] }[];
+          feedData?: { dataByTabtype?: FeedAggregateDataByTabType[] }[];
+        };
+      } | null;
+
+      const isTestWork = work.id === 866 || work.id === 867 || work.id === 902 || work.id === 903;
+      if (isTestWork) {
+        logger.info(`[WX WorkStats] feed_aggreagate 原始响应 workId=${work.id} errCode=${feedJson?.errCode} errMsg=${(feedJson as any)?.errMsg} 完整body=${JSON.stringify(feedJson ?? null)}`);
+      }
+
+      if (!feedJson || feedJson.errCode !== 0) {
+        if (isTestWork) {
+          logger.warn(`[WX WorkStats] workId=${work.id} feed_aggreagate 非成功 errCode=${feedJson?.errCode} 跳过`);
+        }
+        continue;
+      }
+
+      const dataByFanstype = feedJson.data?.dataByFanstype ?? [];
+      const firstFans = dataByFanstype[0];
+      const dataByTabtype = firstFans?.dataByTabtype ?? feedJson.data?.feedData?.[0]?.dataByTabtype ?? [];
+      const tabAll = dataByTabtype.find((t) => t.tabTypeName === '全部' || t.tabType === 999);
+      if (isTestWork) {
+        logger.info(
+          `[WX WorkStats] workId=${work.id} dataByTabtype.length=${dataByTabtype.length} tabAll=${!!tabAll} tabAll.tabTypeName=${tabAll?.tabTypeName}`
+        );
+      }
+      if (!tabAll?.data) continue;
+
+      const data = tabAll.data;
+      const browse = data.browse ?? [];
+      const like = data.like ?? [];
+      const comment = data.comment ?? [];
+      if (isTestWork) {
+        logger.info(
+          `[WX WorkStats] workId=${work.id} 「全部」data: browse.length=${browse.length} like.length=${like.length} comment.length=${comment.length}`
+        );
+        logger.info(`[WX WorkStats] workId=${work.id} browse=${JSON.stringify(browse)}`);
+        logger.info(`[WX WorkStats] workId=${work.id} like=${JSON.stringify(like)}`);
+        logger.info(`[WX WorkStats] workId=${work.id} comment=${JSON.stringify(comment)}`);
+      }
+
+      const len = Math.max(browse.length, like.length, comment.length);
+      if (len === 0) continue;
+
+      const patches: Array<{
+        workId: number;
+        recordDate: Date;
+        playCount?: number;
+        likeCount?: number;
+        commentCount?: number;
+      }> = [];
+
+      for (let i = 0; i < len; i++) {
+        const recordDate = new Date(startDate);
+        recordDate.setDate(recordDate.getDate() + i);
+        recordDate.setHours(0, 0, 0, 0);
+        if (recordDate > endDate) break;
+
+        patches.push({
+          workId: work.id,
+          recordDate,
+          playCount: toInt(browse[i], 0),
+          likeCount: toInt(like[i], 0),
+          commentCount: toInt(comment[i], 0),
+        });
+      }
+
+      if (isTestWork) {
+        logger.info(`[WX WorkStats] workId=${work.id} 生成 patches.length=${patches.length} 前3条=${JSON.stringify(patches.slice(0, 3))}`);
+      }
+      if (patches.length) {
+        const result = await this.workDayStatisticsService.saveStatisticsForDateBatch(patches);
+        if (isTestWork) {
+          logger.info(`[WX WorkStats] workId=${work.id} saveStatisticsForDateBatch inserted=${result.inserted} updated=${result.updated}`);
+        }
+        totalInserted += result.inserted;
+        totalUpdated += result.updated;
+      }
+    }
+
+    logger.info(
+      `[WX WorkStats] accountId=${account.id} completed. inserted=${totalInserted} updated=${totalUpdated}`
+    );
+  }
+}

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

@@ -53,10 +53,17 @@ interface PlatformStatItem {
 interface WorkStatisticsItem {
   recordDate: string;
   playCount: number;
+  exposureCount?: number;
   likeCount: number;
   commentCount: number;
   shareCount: number;
   collectCount: number;
+  fansIncrease?: number;
+  totalWatchDuration?: string;
+  avgWatchDuration?: string;
+  coverClickRate?: string;
+  completionRate?: string;
+  twoSecondExitRate?: string;
 }
 
 export class WorkDayStatisticsService {
@@ -596,10 +603,17 @@ export class WorkDayStatisticsService {
       .select('wds.work_id', 'workId')
       .addSelect('wds.record_date', 'recordDate')
       .addSelect('wds.play_count', 'playCount')
+      .addSelect('wds.exposure_count', 'exposureCount')
       .addSelect('wds.like_count', 'likeCount')
       .addSelect('wds.comment_count', 'commentCount')
       .addSelect('wds.share_count', 'shareCount')
       .addSelect('wds.collect_count', 'collectCount')
+      .addSelect('wds.fans_increase', 'fansIncrease')
+      .addSelect('wds.total_watch_duration', 'totalWatchDuration')
+      .addSelect('wds.avg_watch_duration', 'avgWatchDuration')
+      .addSelect('wds.cover_click_rate', 'coverClickRate')
+      .addSelect('wds.completion_rate', 'completionRate')
+      .addSelect('wds.two_second_exit_rate', 'twoSecondExitRate')
       .where('wds.work_id IN (:...workIds)', { workIds })
       .orderBy('wds.work_id', 'ASC')
       .addOrderBy('wds.record_date', 'ASC');
@@ -629,10 +643,17 @@ export class WorkDayStatisticsService {
       groupedData[workId].push({
         recordDate,
         playCount: parseInt(row.playCount) || 0,
+        exposureCount: parseInt(row.exposureCount) || 0,
         likeCount: parseInt(row.likeCount) || 0,
         commentCount: parseInt(row.commentCount) || 0,
         shareCount: parseInt(row.shareCount) || 0,
         collectCount: parseInt(row.collectCount) || 0,
+        fansIncrease: parseInt(row.fansIncrease) || 0,
+        totalWatchDuration: row.totalWatchDuration || '0',
+        avgWatchDuration: row.avgWatchDuration || '0',
+        coverClickRate: row.coverClickRate || '0',
+        completionRate: row.completionRate || '0',
+        twoSecondExitRate: row.twoSecondExitRate || '0',
       });
     }
 

+ 51 - 1
server/src/services/WorkService.ts

@@ -1,4 +1,4 @@
-import { AppDataSource, Work, PlatformAccount, Comment } from '../models/index.js';
+import { AppDataSource, Work, PlatformAccount, Comment, WorkDayStatistics } from '../models/index.js';
 import { AppError } from '../middleware/error.js';
 import { ERROR_CODES, HTTP_STATUS } from '@media-manager/shared';
 import type { PlatformType, Work as WorkType, WorkStats, WorksQueryParams } from '@media-manager/shared';
@@ -6,6 +6,7 @@ import { logger } from '../utils/logger.js';
 import { headlessBrowserService } from './HeadlessBrowserService.js';
 import { CookieManager } from '../automation/cookie.js';
 import { WorkDayStatisticsService } from './WorkDayStatisticsService.js';
+import { XiaohongshuWorkNoteStatisticsImportService } from './XiaohongshuWorkNoteStatisticsImportService.js';
 
 export class WorkService {
   private workRepository = AppDataSource.getRepository(Work);
@@ -446,6 +447,42 @@ export class WorkService {
       logger.error(`[SyncAccountWorks] Failed to save day statistics for account ${account.id}:`, error);
     }
 
+    // 小红书:如果是新作品且 work_day_statistics 中尚无任何记录,则补首批日统计 & works.yesterday_*(不受14天限制)
+    if (platform === 'xiaohongshu') {
+      try {
+        const works = await this.workRepository.find({
+          where: { accountId: account.id, platform },
+          select: ['id'],
+        });
+        const workIds = works.map((w) => w.id);
+        if (workIds.length > 0) {
+          const rows = await AppDataSource.getRepository(WorkDayStatistics)
+            .createQueryBuilder('wds')
+            .select('DISTINCT wds.work_id', 'workId')
+            .where('wds.work_id IN (:...ids)', { ids: workIds })
+            .getRawMany();
+          const hasStats = new Set<number>(rows.map((r: any) => Number(r.workId)));
+          const needInitIds = workIds.filter((id) => !hasStats.has(id));
+
+          if (needInitIds.length > 0) {
+            logger.info(
+              `[SyncAccountWorks] XHS account ${account.id} has ${needInitIds.length} works without statistics, running initial note/base import.`
+            );
+            const svc = new XiaohongshuWorkNoteStatisticsImportService();
+            await svc.importAccountWorksStatistics(account, false, {
+              workIdFilter: needInitIds,
+              ignorePublishTimeLimit: true,
+            });
+          }
+        }
+      } catch (err) {
+        logger.error(
+          `[SyncAccountWorks] Failed to backfill XHS work_day_statistics for account ${account.id}:`,
+          err
+        );
+      }
+    }
+
     return {
       syncedCount,
       worksListLength: accountInfo.worksList?.length || 0,
@@ -704,6 +741,19 @@ export class WorkService {
       commentCount: work.commentCount,
       shareCount: work.shareCount,
       collectCount: work.collectCount,
+      yesterdayPlayCount: work.yesterdayPlayCount,
+      yesterdayLikeCount: work.yesterdayLikeCount,
+      yesterdayCommentCount: work.yesterdayCommentCount,
+      yesterdayShareCount: work.yesterdayShareCount,
+      yesterdayCollectCount: work.yesterdayCollectCount,
+      yesterdayFansIncrease: work.yesterdayFansIncrease,
+      yesterdayCoverClickRate: work.yesterdayCoverClickRate,
+      yesterdayAvgWatchDuration: work.yesterdayAvgWatchDuration,
+      yesterdayTotalWatchDuration: work.yesterdayTotalWatchDuration,
+      yesterdayCompletionRate: work.yesterdayCompletionRate,
+      yesterdayTwoSecondExitRate: work.yesterdayTwoSecondExitRate,
+      yesterdayCompletionRate5s: work.yesterdayCompletionRate5s,
+      yesterdayExposureCount: work.yesterdayExposureCount,
       createdAt: work.createdAt.toISOString(),
       updatedAt: work.updatedAt.toISOString(),
     };

+ 121 - 9
server/src/services/XiaohongshuWorkNoteStatisticsImportService.ts

@@ -5,6 +5,7 @@ import { WorkDayStatisticsService } from './WorkDayStatisticsService.js';
 import { AccountService } from './AccountService.js';
 import type { ProxyConfig } from '@media-manager/shared';
 import { BrowserManager } from '../automation/browser.js';
+import { In } from 'typeorm';
 
 /** 小红书笔记详情页跳转到登录时抛出,用于触发「先刷新登录、再决定是否账号失效」 */
 export class XhsLoginExpiredError extends Error {
@@ -50,6 +51,23 @@ interface NoteDaySection {
 
 interface NoteBaseData {
   day?: NoteDaySection;
+  view_count?: number;
+  like_count?: number;
+  comment_count?: number;
+  share_count?: number;
+  collect_count?: number;
+  rise_fans_count?: number;
+  /** 展现量/曝光数 */
+  impl_count?: number;
+  /** 封面点击率(数值,非百分号字符串) */
+  cover_click_rate?: number;
+  /** 2s 退出率(数值,非百分号字符串) */
+  exit_view2s_rate?: number;
+  /** 完播率(full_view_rate) */
+  full_view_rate?: number;
+  /** 平均观看时长(秒) */
+  view_time_avg_with_double?: number;
+  view_time_avg?: number;
 }
 
 interface DailyWorkStatPatch {
@@ -143,20 +161,43 @@ function toNumber(val: unknown, defaultValue = 0): number {
   return defaultValue;
 }
 
+function toNonNegativeNumber(val: unknown): number | undefined {
+  const n = toNumber(val, NaN);
+  if (!Number.isFinite(n)) return undefined;
+  return n < 0 ? 0 : n;
+}
+
 function toRateString(val: unknown): string | undefined {
   const n = toNumber(val, NaN);
   if (!Number.isFinite(n)) return undefined;
   return n.toString();
 }
 
+/** 比率类(非 0 加 %,0 保持为 "0") */
+function toRatePercentStringFromValue(val: unknown): string | undefined {
+  const n = toNumber(val, NaN);
+  if (!Number.isFinite(n)) return undefined;
+  if (n <= 0) return '0';
+  return `${n}%`;
+}
+
 /** 从 item 取 coun(或 count)转为字符串并追加 "%",用于比率类字段 */
 function toRatePercentString(item: NoteTrendItem): string | undefined {
   const raw = item?.coun ?? item?.count;
   const n = toNumber(raw, NaN);
   if (!Number.isFinite(n)) return undefined;
+  if (n === 0) return '0';
   return `${n}%`;
 }
 
+/** 平均时长等:保留两位小数,四舍五入 */
+function toFixed2String(val: unknown): string | undefined {
+  const n = toNonNegativeNumber(val);
+  if (n == null) return undefined;
+  const rounded = Math.round(n * 100) / 100;
+  return rounded.toFixed(2);
+}
+
 export class XiaohongshuWorkNoteStatisticsImportService {
   private accountRepository = AppDataSource.getRepository(PlatformAccount);
   private workRepository = AppDataSource.getRepository(Work);
@@ -196,19 +237,31 @@ export class XiaohongshuWorkNoteStatisticsImportService {
    * 按账号同步作品日统计。检测到登录失效时:先尝试刷新登录一次;刷新仍失效则执行账号失效,刷新成功则用新 cookie 重试一次。
    * @param isRetry 是否为「刷新登录后的重试」,避免无限递归
    */
-  private async importAccountWorksStatistics(account: PlatformAccount, isRetry = false): Promise<void> {
+  /**
+   * 按账号同步作品日统计
+   * @param options.workIdFilter 仅处理指定 workId(用于「同步作品后为新作品补首批日统计」)
+   * @param options.ignorePublishTimeLimit 是否忽略「发布日期+14天」限制(用于首批补数据)
+   */
+  async importAccountWorksStatistics(
+    account: PlatformAccount,
+    isRetry = false,
+    options?: { workIdFilter?: number[]; ignorePublishTimeLimit?: boolean }
+  ): Promise<void> {
     const cookies = parseCookiesFromAccount(account.cookieData);
     if (!cookies.length) {
       logger.warn(`[XHS WorkStats] accountId=${account.id} cookieData 为空或无法解析,跳过`);
       return;
     }
 
-    const works = await this.workRepository.find({
-      where: {
-        accountId: account.id,
-        platform: 'xiaohongshu' as any,
-      },
-    });
+    const where: any = {
+      accountId: account.id,
+      platform: 'xiaohongshu' as any,
+    };
+    if (options?.workIdFilter && options.workIdFilter.length > 0) {
+      where.id = In(options.workIdFilter);
+    }
+
+    const works = await this.workRepository.find({ where });
 
     if (!works.length) {
       logger.info(`[XHS WorkStats] accountId=${account.id} 没有作品,跳过`);
@@ -242,7 +295,23 @@ export class XiaohongshuWorkNoteStatisticsImportService {
           const data = await this.fetchNoteBaseData(page, noteId);
           if (!data) continue;
 
-          const patches = this.buildDailyStatisticsFromNoteData(work.id, data);
+          // 同步 base 顶层“汇总指标”到 works 表(用于作品列表/总览等按 work 累计口径展示)
+          await this.applyWorkSnapshotFromBaseData(work.id, data).catch((e) => {
+            logger.warn(
+              `[XHS WorkStats] Failed to update works snapshot from base data. workId=${work.id} noteId=${noteId}`,
+              e
+            );
+          });
+
+          let patches = this.buildDailyStatisticsFromNoteData(work.id, data);
+          // 默认:只保留「作品发布后 14 天内」的日统计;首批补数(ignorePublishTimeLimit=true)时不过滤
+          if (!options?.ignorePublishTimeLimit && work.publishTime) {
+            const publishDay = new Date(work.publishTime);
+            publishDay.setHours(0, 0, 0, 0);
+            const lastAllowed = new Date(publishDay);
+            lastAllowed.setDate(lastAllowed.getDate() + 13); // 发布当日 + 13 天 = 共 14 天
+            patches = patches.filter((p) => p.recordDate.getTime() <= lastAllowed.getTime());
+          }
           if (!patches.length) continue;
 
           const result = await this.workDayStatisticsService.saveStatisticsForDateBatch(
@@ -291,7 +360,7 @@ export class XiaohongshuWorkNoteStatisticsImportService {
                   const refreshed = await this.accountRepository.findOne({ where: { id: account.id } });
                   if (refreshed) {
                     logger.info(`[XHS WorkStats] accountId=${account.id} 刷新成功,重新同步数据`);
-                    return this.importAccountWorksStatistics(refreshed, true);
+                    return this.importAccountWorksStatistics(refreshed, true, options);
                   }
                 } catch (refreshErr) {
                   logger.error(`[XHS WorkStats] accountId=${account.id} 刷新登录失败`, refreshErr);
@@ -388,6 +457,49 @@ export class XiaohongshuWorkNoteStatisticsImportService {
     return data;
   }
 
+  private async applyWorkSnapshotFromBaseData(workId: number, data: NoteBaseData): Promise<void> {
+    // base 接口字段:以 note/base 的 data 顶层为准
+    const patch: Partial<Work> = {};
+
+    const viewCount = toNonNegativeNumber((data as any).view_count);
+    if (viewCount != null) (patch as any).yesterdayPlayCount = Math.trunc(viewCount);
+
+    const likeCount = toNonNegativeNumber((data as any).like_count);
+    if (likeCount != null) (patch as any).yesterdayLikeCount = Math.trunc(likeCount);
+
+    const commentCount = toNonNegativeNumber((data as any).comment_count);
+    if (commentCount != null) (patch as any).yesterdayCommentCount = Math.trunc(commentCount);
+
+    const shareCount = toNonNegativeNumber((data as any).share_count);
+    if (shareCount != null) (patch as any).yesterdayShareCount = Math.trunc(shareCount);
+
+    const collectCount = toNonNegativeNumber((data as any).collect_count);
+    if (collectCount != null) (patch as any).yesterdayCollectCount = Math.trunc(collectCount);
+
+    const fansIncrease = toNonNegativeNumber((data as any).rise_fans_count);
+    if (fansIncrease != null) (patch as any).yesterdayFansIncrease = Math.trunc(fansIncrease);
+
+    const exposureCount = toNonNegativeNumber((data as any).impl_count);
+    if (exposureCount != null) (patch as any).yesterdayExposureCount = Math.trunc(exposureCount);
+
+    const coverClickRateStr = toRatePercentStringFromValue((data as any).cover_click_rate);
+    if (coverClickRateStr != null) (patch as any).yesterdayCoverClickRate = coverClickRateStr;
+
+    const avgWatch = (data as any).view_time_avg_with_double ?? (data as any).view_time_avg;
+    const avgWatchStr = toFixed2String(avgWatch);
+    if (avgWatchStr != null) (patch as any).yesterdayAvgWatchDuration = avgWatchStr;
+
+    const completionRateStr = toRatePercentStringFromValue((data as any).full_view_rate);
+    if (completionRateStr != null) (patch as any).yesterdayCompletionRate = completionRateStr;
+
+    const twoSecondExitRateStr = toRatePercentStringFromValue((data as any).exit_view2s_rate);
+    if (twoSecondExitRateStr != null) (patch as any).yesterdayTwoSecondExitRate = twoSecondExitRateStr;
+
+    // 没有任何字段可更新就跳过
+    if (Object.keys(patch).length === 0) return;
+    await this.workRepository.update(workId, patch as any);
+  }
+
   private buildDailyStatisticsFromNoteData(workId: number, data: NoteBaseData): DailyWorkStatPatch[] {
     const day = data.day;
     if (!day) return [];

+ 17 - 0
shared/src/types/work.ts

@@ -25,6 +25,23 @@ export interface Work {
   commentCount: number;
   shareCount: number;
   collectCount?: number;
+
+  // ===== 昨日数据快照(yesterday_*)=====
+  yesterdayPlayCount?: number;
+  yesterdayLikeCount?: number;
+  yesterdayCommentCount?: number;
+  yesterdayShareCount?: number;
+  yesterdayCollectCount?: number;
+  yesterdayFansIncrease?: number;
+  yesterdayCoverClickRate?: string;
+  yesterdayAvgWatchDuration?: string;
+  yesterdayTotalWatchDuration?: string;
+  yesterdayCompletionRate?: string;
+  yesterdayTwoSecondExitRate?: string;
+  /** 5秒完播率 */
+  yesterdayCompletionRate5s?: string;
+  /** 曝光数 */
+  yesterdayExposureCount?: number;
   createdAt: string;
   updatedAt: string;
 }

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.