Prechádzať zdrojové kódy

feat: integrate CloakBrowser browser backend

ethanfly 3 týždňov pred
rodič
commit
31c184afa2
36 zmenil súbory, kde vykonal 814 pridanie a 237 odobranie
  1. 8 5
      client/electron-builder.json
  2. 147 7
      client/electron/local-services.ts
  3. 4 1
      client/electron/main.ts
  4. 14 0
      client/electron/preload.ts
  5. 0 1
      client/package.json
  6. 8 23
      client/scripts/build-server.js
  7. 1 0
      client/src/components.d.ts
  8. 161 0
      client/src/components/PlaywrightInstallDialog.vue
  9. 4 2
      client/src/layouts/MainLayout.vue
  10. 6 0
      client/src/main.ts
  11. 173 7
      client/src/styles/index.scss
  12. 45 9
      client/src/styles/variables.scss
  13. 9 0
      client/src/types/global.d.ts
  14. 20 28
      client/src/views/Analytics/Account/index.vue
  15. 3 2
      client/src/views/Analytics/Overview/index.vue
  16. 0 11
      client/src/views/Analytics/Platform/index.vue
  17. 3 6
      client/src/views/Analytics/Work/index.vue
  18. 12 30
      client/src/views/Publish/index.vue
  19. 10 25
      client/src/views/Works/index.vue
  20. 68 41
      pnpm-lock.yaml
  21. 1 0
      server/package.json
  22. 4 3
      server/src/automation/browser.ts
  23. 65 0
      server/src/automation/browserProvider.ts
  24. 2 2
      server/src/scripts/check-xhs-cookie.ts
  25. 3 2
      server/src/services/BaijiahaoContentOverviewImportService.ts
  26. 4 3
      server/src/services/BaijiahaoWorkDailyStatisticsImportService.ts
  27. 3 2
      server/src/services/BrowserLoginService.ts
  28. 3 2
      server/src/services/DouyinAccountOverviewImportService.ts
  29. 3 2
      server/src/services/DouyinWorkStatisticsImportService.ts
  30. 12 11
      server/src/services/HeadlessBrowserService.ts
  31. 3 2
      server/src/services/WeixinAutoReplyService.ts
  32. 3 2
      server/src/services/WeixinVideoDataCenterImportService.ts
  33. 3 2
      server/src/services/WeixinVideoWorkStatisticsImportService.ts
  34. 3 2
      server/src/services/XiaohongshuAccountOverviewImportService.ts
  35. 3 2
      server/src/services/XiaohongshuWorkNoteStatisticsImportService.ts
  36. 3 2
      server/src/services/login/BaseLoginService.ts

+ 8 - 5
client/electron-builder.json

@@ -8,8 +8,15 @@
   },
   "files": [
     "dist/**/*",
-    "dist-electron/**/*"
+    "dist-electron/**/*",
+    "!**/*.map",
+    "!**/*.md",
+    "!**/{.eslintrc,.eslintrc.cjs,.prettierrc,.editorconfig,tsconfig*.json}",
+    "!**/{__tests__,test,tests,docs,example,examples,demo}/**"
   ],
+  "asar": true,
+  "compression": "maximum",
+  "electronLanguages": ["zh-CN"],
   "extraResources": [
     {
       "from": "public/icons",
@@ -22,10 +29,6 @@
     {
       "from": "_bundle/shared",
       "to": "shared"
-    },
-    {
-      "from": "_bundle/playwright",
-      "to": "playwright"
     }
   ],
   "win": {

+ 147 - 7
client/electron/local-services.ts

@@ -68,10 +68,140 @@ function getServerDir(): string {
   return path.resolve(__dirname, '..', '..', 'server');
 }
 
-function getPlaywrightBrowsersPath(): string | null {
-  if (!isPackaged()) return null;
-  const bundled = path.join((process as any).resourcesPath, 'playwright');
-  return fs.existsSync(bundled) ? bundled : null;
+// 用户目录下的 Playwright 浏览器存放位置
+function getUserPlaywrightBrowsersPath(): string {
+  return path.join(app.getPath('userData'), 'playwright-browsers');
+}
+
+function getPlaywrightBrowsersPath(): string {
+  // 1) 已打包:固定使用用户目录,避免污染系统缓存;
+  // 2) 开发环境:返回用户目录或系统默认(Playwright 会自动 fallback)。
+  return getUserPlaywrightBrowsersPath();
+}
+
+function getCloakBrowserCacheDir(): string {
+  return path.join(app.getPath('userData'), 'cloakbrowser');
+}
+
+// 检查 Chromium 是否已下载到用户目录
+function isChromiumInstalled(): boolean {
+  const dir = getUserPlaywrightBrowsersPath();
+  if (!fs.existsSync(dir)) return false;
+  try {
+    const entries = fs.readdirSync(dir);
+    return entries.some((name: string) => name.startsWith('chromium'));
+  } catch {
+    return false;
+  }
+}
+
+// 进度回调:phase 取值:
+//   'start' 开始下载
+//   'progress' 下载进度(percent 0-100)
+//   'done' 完成(ok 表示成功与否)
+export type PlaywrightInstallProgress =
+  | { phase: 'start'; message?: string }
+  | { phase: 'progress'; percent: number; message?: string }
+  | { phase: 'done'; ok: boolean; message?: string };
+
+let onPlaywrightProgress: ((p: PlaywrightInstallProgress) => void) | null = null;
+function setPlaywrightProgressHandler(handler: (p: PlaywrightInstallProgress) => void): void {
+  onPlaywrightProgress = handler;
+}
+function emitProgress(p: PlaywrightInstallProgress): void {
+  try {
+    onPlaywrightProgress?.(p);
+  } catch {
+    // ignore handler errors
+  }
+}
+
+// 解析 playwright CLI 输出中的下载百分比,例如:
+// "|████████████████        | 65% of 130.5 MiB"
+function parsePercent(line: string): number | null {
+  const m = line.match(/(\d{1,3})%/);
+  if (!m) return null;
+  const v = Number(m[1]);
+  if (Number.isNaN(v) || v < 0 || v > 100) return null;
+  return v;
+}
+
+// 触发 Node 子进程下载 Chromium
+function installChromiumOnFirstRun(): Promise<boolean> {
+  return new Promise((resolve) => {
+    if (isChromiumInstalled()) {
+      log('INFO', 'Playwright Chromium 已存在,跳过下载');
+      resolve(true);
+      return;
+    }
+
+    const serverDir = getServerDir();
+    const browsersPath = getUserPlaywrightBrowsersPath();
+    fs.mkdirSync(browsersPath, { recursive: true });
+
+    const cliEntry = path.join(serverDir, 'node_modules', 'playwright', 'cli.js');
+    if (!fs.existsSync(cliEntry)) {
+      log('ERROR', 'playwright/cli.js not found, cannot install chromium');
+      emitProgress({ phase: 'done', ok: false, message: '未找到 playwright CLI' });
+      resolve(false);
+      return;
+    }
+
+    log('INFO', '首次启动:开始下载 Playwright Chromium 到', browsersPath);
+    emitProgress({ phase: 'start', message: '开始下载 Chromium 浏览器...' });
+
+    const child = spawn(process.execPath, [cliEntry, 'install', 'chromium'], {
+      cwd: serverDir,
+      env: {
+        ...(process.env as Record<string, string>),
+        ELECTRON_RUN_AS_NODE: '1',
+        PLAYWRIGHT_BROWSERS_PATH: browsersPath,
+        // 让 playwright 输出更详细的进度
+        DEBUG: process.env.DEBUG || '',
+      },
+      stdio: ['ignore', 'pipe', 'pipe'],
+      windowsHide: true,
+    });
+
+    let lastPercent = -1;
+    const handleLine = (raw: string): void => {
+      const line = raw.trim();
+      if (!line) return;
+      log('PW', line);
+      const percent = parsePercent(line);
+      if (percent !== null && percent !== lastPercent) {
+        lastPercent = percent;
+        emitProgress({ phase: 'progress', percent, message: line });
+      }
+    };
+
+    child.stdout?.on('data', (chunk: Buffer) => chunk.toString().split(/\r?\n/).forEach(handleLine));
+    child.stderr?.on('data', (chunk: Buffer) => {
+      // playwright 通常把进度写到 stderr
+      chunk.toString().split(/\r?\n/).forEach((l: string) => {
+        const line = l.trim();
+        if (!line) return;
+        log('PW-ERR', line);
+        const percent = parsePercent(line);
+        if (percent !== null && percent !== lastPercent) {
+          lastPercent = percent;
+          emitProgress({ phase: 'progress', percent, message: line });
+        }
+      });
+    });
+
+    child.on('exit', (code: number | null) => {
+      const ok = code === 0 && isChromiumInstalled();
+      log(ok ? 'INFO' : 'ERROR', `Playwright Chromium install exit code=${code}`);
+      emitProgress({ phase: 'done', ok, message: ok ? '浏览器下载完成' : `下载失败 (exit ${code})` });
+      resolve(ok);
+    });
+    child.on('error', (err: Error) => {
+      log('ERROR', 'Playwright install spawn error', err.message);
+      emitProgress({ phase: 'done', ok: false, message: err.message });
+      resolve(false);
+    });
+  });
 }
 
 function findNodeRunner(): { cmd: string; args: string[]; useElectronAsNode?: boolean } {
@@ -155,10 +285,10 @@ function startNodeServer(): void {
     env.UPLOAD_PATH = uploadsDir;
   }
 
-  const playwrightPath = getPlaywrightBrowsersPath();
-  if (playwrightPath) {
-    env.PLAYWRIGHT_BROWSERS_PATH = playwrightPath;
+  if (isPackaged()) {
+    env.PLAYWRIGHT_BROWSERS_PATH = getPlaywrightBrowsersPath();
   }
+  env.CLOAKBROWSER_CACHE_DIR = getCloakBrowserCacheDir();
 
   log('INFO', 'Starting node service', cmd, args.join(' '));
   const child = spawn(cmd, args, {
@@ -211,6 +341,15 @@ function startNodeServer(): void {
 }
 
 async function startLocalServices(): Promise<{ nodeOk: boolean }> {
+  // 在 packaged 模式下,首次启动时按需下载 Chromium
+  if (isPackaged()) {
+    try {
+      await installChromiumOnFirstRun();
+    } catch (err) {
+      log('ERROR', 'installChromiumOnFirstRun failed', err);
+    }
+  }
+
   if (!services.node.process) {
     startNodeServer();
   }
@@ -250,4 +389,5 @@ export {
   stopLocalServices,
   getServiceStatus,
   getLogPath,
+  setPlaywrightProgressHandler,
 };

+ 4 - 1
client/electron/main.ts

@@ -5,7 +5,7 @@ const fs = require('fs');
 const http = require('http');
 const https = require('https');
 const os = require('os');
-import { startLocalServices, stopLocalServices, getServiceStatus, LOCAL_NODE_URL, getLogPath } from './local-services';
+import { startLocalServices, stopLocalServices, getServiceStatus, LOCAL_NODE_URL, getLogPath, setPlaywrightProgressHandler } from './local-services';
 
 let mainWindow: typeof BrowserWindow.prototype | null = null;
 
@@ -321,6 +321,9 @@ if (!gotTheLock) {
 
   app.whenReady().then(async () => {
     logMemory('AppReady');
+    setPlaywrightProgressHandler((p) => {
+      mainWindow?.webContents.send('playwright-install-progress', p);
+    });
     // 鍏堝垱寤虹獥鍙f樉绀?splash screen
     createWindow();
     createTray();

+ 14 - 0
client/electron/preload.ts

@@ -77,6 +77,20 @@ contextBridge.exposeInMainWorld('electronAPI', {
   removeServicesStatusListener: () => {
     ipcRenderer.removeAllListeners('services-status-changed');
   },
+
+  onPlaywrightInstallProgress: (
+    callback: (data: {
+      phase: 'start' | 'progress' | 'done';
+      percent?: number;
+      ok?: boolean;
+      message?: string;
+    }) => void,
+  ) => {
+    ipcRenderer.on('playwright-install-progress', (_event: unknown, data: any) => callback(data));
+  },
+  removePlaywrightInstallProgressListener: () => {
+    ipcRenderer.removeAllListeners('playwright-install-progress');
+  },
 });
 
 export {};

+ 0 - 1
client/package.json

@@ -25,7 +25,6 @@
     "pinia": "^2.1.7",
     "vue": "^3.4.15",
     "vue-echarts": "^6.6.8",
-    "vue-i18n": "^9.9.0",
     "vue-router": "^4.2.5",
     "xlsx": "^0.18.5"
   },

+ 8 - 23
client/scripts/build-server.js

@@ -22,7 +22,8 @@ const SHARED_DIR = path.join(ROOT, 'shared');
 const BUNDLE_DIR = path.join(CLIENT_DIR, '_bundle');
 const SERVER_BUNDLE = path.join(BUNDLE_DIR, 'server');
 const SHARED_BUNDLE = path.join(BUNDLE_DIR, 'shared');
-const PLAYWRIGHT_DEST = path.join(BUNDLE_DIR, 'playwright');
+
+// Playwright 浏览器不再打包,改由首次运行时下载到用户目录
 
 const isWin = process.platform === 'win32';
 
@@ -100,7 +101,8 @@ function dirSizeMB(dir) {
   }
 }
 
-function findPlaywrightBrowsersDir() {
+// 已不再使用,仅保留以兼容旧调用
+function _unused_findPlaywrightBrowsersDir() {
   if (process.env.PLAYWRIGHT_BROWSERS_PATH && fs.existsSync(process.env.PLAYWRIGHT_BROWSERS_PATH)) {
     return process.env.PLAYWRIGHT_BROWSERS_PATH;
   }
@@ -168,31 +170,14 @@ copyFile(path.join(SERVER_DIR, '.env'), path.join(SERVER_BUNDLE, '.env'));
 fs.mkdirSync(path.join(SERVER_BUNDLE, 'uploads'), { recursive: true });
 copyFile(path.join(SERVER_DIR, 'uploads', '.gitkeep'), path.join(SERVER_BUNDLE, 'uploads', '.gitkeep'));
 
-step('5/6 Bundle Playwright browsers');
-const browserCacheDir = findPlaywrightBrowsersDir();
-if (!browserCacheDir) {
-  throw new Error('Playwright browser cache not found. Run `npx playwright install chromium` first.');
-}
-
-cleanDir(PLAYWRIGHT_DEST);
-const browserEntries = fs.readdirSync(browserCacheDir).filter((name) => name.startsWith('chromium') || name.startsWith('ffmpeg'));
-if (browserEntries.length === 0) {
-  throw new Error('No Chromium Playwright browser binaries were found.');
-}
-
-for (const dir of browserEntries) {
-  console.log(`  copy ${dir}`);
-  copyDir(path.join(browserCacheDir, dir), path.join(PLAYWRIGHT_DEST, dir));
-}
-console.log(`    playwright size: ${dirSizeMB(PLAYWRIGHT_DEST)} MB`);
+step('5/6 Skip Playwright bundling');
+console.log('  Playwright 浏览器不再打包到安装包,将在首次启动时由客户端按需下载到用户目录');
 
 step('6/6 Verify bundle assets');
 verify('server dist/app.js', path.join(SERVER_BUNDLE, 'dist', 'app.js'));
 verify('server node_modules', path.join(SERVER_BUNDLE, 'node_modules'));
 verify('shared dist', path.join(SHARED_BUNDLE, 'dist'));
-verify('playwright', PLAYWRIGHT_DEST);
 
 step('Bundle ready');
-console.log(`  server:     ${SERVER_BUNDLE}`);
-console.log(`  shared:     ${SHARED_BUNDLE}`);
-console.log(`  playwright: ${PLAYWRIGHT_DEST}`);
+console.log(`  server: ${SERVER_BUNDLE}`);
+console.log(`  shared: ${SHARED_BUNDLE}`);

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

@@ -55,6 +55,7 @@ declare module 'vue' {
     FilterBar: typeof import('./components/FilterBar.vue')['default']
     Icons: typeof import('./components/icons/index.vue')['default']
     PageHeader: typeof import('./components/PageHeader.vue')['default']
+    PlaywrightInstallDialog: typeof import('./components/PlaywrightInstallDialog.vue')['default']
     QuickDatePicker: typeof import('./components/QuickDatePicker.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']

+ 161 - 0
client/src/components/PlaywrightInstallDialog.vue

@@ -0,0 +1,161 @@
+<template>
+  <el-dialog
+    v-model="visible"
+    title="首次启动初始化"
+    width="460px"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    :show-close="phase === 'done'"
+    align-center
+  >
+    <div class="pw-install">
+      <div class="pw-install__head">
+        <el-icon class="pw-install__icon" :class="{ spin: phase !== 'done' }">
+          <component :is="phase === 'done' ? (ok ? CircleCheck : CircleClose) : Loading" />
+        </el-icon>
+        <div class="pw-install__title">
+          {{ phaseTitle }}
+        </div>
+      </div>
+
+      <p class="pw-install__desc">
+        {{ description }}
+      </p>
+
+      <el-progress
+        v-if="phase !== 'done'"
+        :percentage="displayPercent"
+        :stroke-width="10"
+        :show-text="true"
+        :status="phase === 'done' && !ok ? 'exception' : ''"
+      />
+
+      <p v-if="latestMessage" class="pw-install__log">{{ latestMessage }}</p>
+    </div>
+
+    <template #footer>
+      <el-button v-if="phase === 'done'" type="primary" @click="visible = false">
+        {{ ok ? '开始使用' : '关闭' }}
+      </el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, onUnmounted, ref } from 'vue';
+import { CircleCheck, CircleClose, Loading } from '@element-plus/icons-vue';
+
+type Phase = 'idle' | 'start' | 'progress' | 'done';
+
+const visible = ref(false);
+const phase = ref<Phase>('idle');
+const percent = ref(0);
+const ok = ref(true);
+const latestMessage = ref('');
+
+const displayPercent = computed(() => Math.max(0, Math.min(100, Math.round(percent.value))));
+
+const phaseTitle = computed(() => {
+  if (phase.value === 'done') return ok.value ? '初始化完成' : '初始化失败';
+  if (phase.value === 'start') return '准备下载浏览器组件';
+  return '正在下载浏览器组件';
+});
+
+const description = computed(() => {
+  if (phase.value === 'done') {
+    return ok.value
+      ? '浏览器组件已就绪,您可以正常使用所有自动化功能。'
+      : '浏览器组件下载失败,部分自动化功能将不可用。请检查网络后重启应用重试。';
+  }
+  return '首次启动需下载约 200MB 的浏览器组件到本机用户目录,下载完成后无需重复下载。';
+});
+
+function handleProgress(data: {
+  phase: 'start' | 'progress' | 'done';
+  percent?: number;
+  ok?: boolean;
+  message?: string;
+}) {
+  if (data.message) latestMessage.value = data.message;
+
+  if (data.phase === 'start') {
+    phase.value = 'start';
+    percent.value = 0;
+    visible.value = true;
+    return;
+  }
+  if (data.phase === 'progress') {
+    phase.value = 'progress';
+    if (typeof data.percent === 'number') percent.value = data.percent;
+    visible.value = true;
+    return;
+  }
+  if (data.phase === 'done') {
+    phase.value = 'done';
+    ok.value = data.ok !== false;
+    if (ok.value) percent.value = 100;
+    visible.value = true;
+  }
+}
+
+onMounted(() => {
+  window.electronAPI?.onPlaywrightInstallProgress?.(handleProgress);
+});
+
+onUnmounted(() => {
+  window.electronAPI?.removePlaywrightInstallProgressListener?.();
+});
+</script>
+
+<style lang="scss" scoped>
+@use '@/styles/variables.scss' as *;
+
+.pw-install {
+  display: flex;
+  flex-direction: column;
+  gap: $spacing-base;
+}
+
+.pw-install__head {
+  display: flex;
+  align-items: center;
+  gap: $spacing-md;
+}
+
+.pw-install__icon {
+  font-size: 28px;
+  color: $primary-color;
+
+  &.spin {
+    animation: pw-spin 1s linear infinite;
+  }
+}
+
+.pw-install__title {
+  font-size: 16px;
+  font-weight: 600;
+  color: $text-primary;
+}
+
+.pw-install__desc {
+  margin: 0;
+  font-size: 13px;
+  color: $text-secondary;
+  line-height: 1.6;
+}
+
+.pw-install__log {
+  margin: 0;
+  padding: 8px 12px;
+  background: $surface-secondary;
+  border-radius: $radius-sm;
+  font-family: 'JetBrains Mono', Consolas, monospace;
+  font-size: 12px;
+  color: $text-secondary;
+  word-break: break-all;
+}
+
+@keyframes pw-spin {
+  to { transform: rotate(360deg); }
+}
+</style>

+ 4 - 2
client/src/layouts/MainLayout.vue

@@ -189,6 +189,7 @@
     </transition>
 
     <TaskProgressDialog />
+    <PlaywrightInstallDialog />
     <CaptchaDialog
       v-model="taskStore.showCaptchaDialog"
       :captcha-task-id="taskStore.captchaTaskId"
@@ -235,6 +236,7 @@ import TaskProgressDialog from '@/components/TaskProgressDialog.vue';
 import CaptchaDialog from '@/components/CaptchaDialog.vue';
 import BrowserTab from '@/components/BrowserTab.vue';
 import AppIconMark from '@/components/AppIconMark.vue';
+import PlaywrightInstallDialog from '@/components/PlaywrightInstallDialog.vue';
 
 const PAGE_META: Record<string, { title: string; kicker: string }> = {
   '/': { title: '数据看板', kicker: 'Dashboard' },
@@ -720,7 +722,7 @@ onUnmounted(() => {
 
 .main-content {
   flex: 1;
-  padding: 32px;
+  padding: 20px 24px;
   min-height: 0;
   overflow: auto;
 }
@@ -732,7 +734,7 @@ onUnmounted(() => {
 
 .app-footer {
   flex-shrink: 0;
-  height: 36px;
+  height: 28px;
   padding: 0 16px;
   display: flex;
   align-items: center;

+ 6 - 0
client/src/main.ts

@@ -1,5 +1,11 @@
 import { createApp } from 'vue';
 import { createPinia } from 'pinia';
+// 显式引入 ElMessage / ElMessageBox / ElNotification 等命令式 API 的样式
+// (unplugin-vue-components 只能为 template 中的组件按需引入样式)
+import 'element-plus/theme-chalk/el-message.css';
+import 'element-plus/theme-chalk/el-message-box.css';
+import 'element-plus/theme-chalk/el-notification.css';
+import 'element-plus/theme-chalk/el-loading.css';
 import './styles/index.scss';
 
 import App from './App.vue';

+ 173 - 7
client/src/styles/index.scss

@@ -196,23 +196,24 @@ body {
 
   .cell {
     min-width: 0;
-    padding: 0 8px;
+    padding: 0 10px;
     line-height: 20px;
+    word-break: break-word;
   }
 
   th.el-table__cell:first-child .cell,
   td.el-table__cell:first-child .cell {
-    padding-left: 20px;
+    padding-left: 16px;
   }
 
   th.el-table__cell:last-child .cell,
   td.el-table__cell:last-child .cell {
-    padding-right: 20px;
+    padding-right: 16px;
   }
 
   th.el-table__cell {
-    height: 44px;
-    padding: 12px 0;
+    height: 40px;
+    padding: 8px 0;
     background: $primary-color !important;
     color: #ffffff;
     font-size: 12px;
@@ -220,8 +221,8 @@ body {
   }
 
   td.el-table__cell {
-    height: 52px;
-    padding: 12px 0;
+    height: 48px;
+    padding: 8px 0;
     color: $text-primary;
     border-bottom-color: $border-light !important;
   }
@@ -231,6 +232,171 @@ body {
   background: rgba(255, 92, 0, 0.04) !important;
 }
 
+// ============ 全局通用布局类(避免每页重复造样式) ============
+
+// 页面外层容器:让所有页面具有一致的内边距和最小列宽
+.page-stage > * {
+  min-width: 0;
+}
+
+// 列表卡片:包裹 el-table 时让表格随容器自适应、不撑破布局
+.table-card {
+  background: $surface-primary;
+  border-radius: $radius-lg;
+  border: 1px solid $border-light;
+  box-shadow: $shadow-sm;
+  padding: 0;
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+  min-width: 0;
+
+  > .el-table,
+  > :deep(.el-table) {
+    width: 100% !important;
+  }
+
+  // 列表内分页器统一间距
+  .el-pagination,
+  > .pagination,
+  > .table-pagination {
+    padding: 12px 16px;
+    justify-content: flex-end;
+    border-top: 1px solid $border-light;
+  }
+}
+
+// 全局让 el-table 的横向滚动条出现在合适的位置(防止「列表显示不全」)
+.el-table {
+  width: 100%;
+}
+.el-table .el-table__body-wrapper {
+  overflow-x: auto;
+}
+
+// 通用页头
+.page-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: $spacing-base;
+  margin-bottom: $spacing-lg;
+  flex-wrap: wrap;
+
+  .page-copy h2 {
+    margin: 0;
+    font-size: $font-size-h1;
+    font-weight: 700;
+    color: $text-primary;
+    line-height: 1.2;
+  }
+
+  .page-copy p {
+    margin: 4px 0 0;
+    font-size: $font-size-body;
+    color: $text-secondary;
+  }
+
+  .header-actions {
+    display: flex;
+    align-items: center;
+    gap: $spacing-sm;
+    flex-shrink: 0;
+  }
+}
+
+// 通用筛选栏:自动换行、最后一组靠右、每个控件最小宽度
+.filter-bar {
+  display: flex;
+  flex-wrap: wrap;
+  gap: $spacing-md;
+  align-items: center;
+  background: $surface-primary;
+  border: 1px solid $border-light;
+  border-radius: $radius-lg;
+  padding: 12px $spacing-base;
+  box-shadow: $shadow-sm;
+  margin-bottom: $spacing-base;
+
+  > .el-select,
+  > .el-input,
+  > .el-date-editor {
+    min-width: 140px;
+  }
+
+  .filter-keyword {
+    width: 240px;
+    max-width: 100%;
+  }
+
+  .filter-actions {
+    display: flex;
+    gap: $spacing-sm;
+    margin-left: auto;
+    flex-wrap: wrap;
+  }
+}
+
+// 主面板:让页面内容自适应高度并允许内部滚动
+.main-panel {
+  display: flex;
+  flex-direction: column;
+  gap: $spacing-base;
+  min-width: 0;
+}
+
+// hover-lift 通用上浮过渡(StatCard 等已使用)
+.hover-lift {
+  transition: transform $transition-base ease, box-shadow $transition-base ease;
+  &:hover {
+    transform: translateY(-2px);
+    box-shadow: $shadow-md;
+  }
+}
+
+// 文本工具类
+.text-primary { color: $text-primary; }
+.text-secondary { color: $text-secondary; }
+.text-placeholder { color: $text-placeholder; }
+.text-orange { color: $primary-color; }
+
+// 修复 el-card body 的内边距与筛选栏不一致
+.el-card__body {
+  padding: $spacing-base;
+}
+
+// 让默认按钮在主题色下也保持可读
+.el-button:not(.el-button--primary):not(.el-button--success):not(.el-button--warning):not(.el-button--danger):not(.el-button--info):not(.is-link):not(.is-text) {
+  --el-button-hover-text-color: #{$primary-color};
+  --el-button-hover-border-color: #{$primary-color};
+  --el-button-hover-bg-color: #{$primary-color-light};
+}
+
+// 分页器主色
+.el-pagination.is-background .el-pager li.is-active {
+  background-color: $primary-color !important;
+  color: #fff !important;
+}
+
+// 让对话框头部更紧凑
+.el-dialog__header {
+  padding: $spacing-base $spacing-xl !important;
+  border-bottom: 1px solid $border-light;
+  margin-right: 0 !important;
+}
+.el-dialog__body {
+  padding: $spacing-xl !important;
+}
+.el-dialog__footer {
+  padding: $spacing-base $spacing-xl !important;
+  border-top: 1px solid $border-light;
+}
+
+// ============ 滚动条优化(深色侧栏 / 浅色内容区) ============
+.sidebar ::-webkit-scrollbar-thumb {
+  background: rgba(255, 255, 255, 0.18);
+}
+
 .el-menu--vertical.el-menu--popup-container {
   .el-menu--popup {
     background: rgba(255, 255, 255, 0.96) !important;

+ 45 - 9
client/src/styles/variables.scss

@@ -1,3 +1,4 @@
+// ===== 主题色 =====
 $primary-color: #ff5c00;
 $primary-color-light: #fff3ed;
 $primary-color-dark: #d94800;
@@ -14,21 +15,32 @@ $gradient-success: linear-gradient(135deg, #4ade80 0%, #22c55e 100%);
 $gradient-info: linear-gradient(135deg, #6b7280 0%, #2d2d2d 100%);
 $gradient-warm: linear-gradient(135deg, #ffb26b 0%, #ff5c00 100%);
 
+// 用于 StatCard 渐变变体的颜色(统一为主题暖色调,保持视觉一致)
+$stat-blue: linear-gradient(135deg, #60a5fa 0%, #2563eb 100%);
+$stat-green: linear-gradient(135deg, #4ade80 0%, #16a34a 100%);
+$stat-pink: linear-gradient(135deg, #f9a8d4 0%, #ec4899 100%);
+$stat-purple: linear-gradient(135deg, #c4b5fd 0%, #7c3aed 100%);
+$stat-orange: linear-gradient(135deg, #ffb26b 0%, #ff5c00 100%);
+
+// ===== 文本 =====
 $text-primary: #1a1a1a;
 $text-regular: #2d2d2d;
 $text-secondary: #666666;
 $text-placeholder: #888888;
 
+// ===== 边框 =====
 $border-base: #ececec;
 $border-light: #f3f4f6;
 $border-lighter: #f7f8fa;
 
+// ===== 背景 =====
 $bg-base: #ffffff;
 $bg-light: #ffffff;
 $bg-page: #f7f8fa;
 $surface-primary: #ffffff;
 $surface-secondary: #f7f8fa;
 
+// ===== 布局尺寸 =====
 $sidebar-width: 240px;
 $sidebar-collapsed-width: 64px;
 $sidebar-bg: #1a1a1a;
@@ -38,18 +50,42 @@ $sidebar-active-text-color: #ffffff;
 
 $header-height: 48px;
 $header-bg: #ffffff;
-$content-padding: 32px;
+$content-padding: 24px;
 
 $tabs-panel-width: 480px;
 $tabs-panel-min-width: 360px;
 $tabs-panel-max-width: 640px;
 
-$shadow-sm: 0 8px 24px rgba(26, 26, 26, 0.04);
-$shadow-base: 0 14px 32px rgba(26, 26, 26, 0.06);
-$shadow-md: 0 24px 48px rgba(26, 26, 26, 0.08);
-$shadow-lg: 0 36px 88px rgba(26, 26, 26, 0.12);
+// ===== 阴影 =====
+$shadow-sm: 0 4px 12px rgba(26, 26, 26, 0.04);
+$shadow-base: 0 8px 20px rgba(26, 26, 26, 0.06);
+$shadow-md: 0 14px 30px rgba(26, 26, 26, 0.08);
+$shadow-lg: 0 24px 56px rgba(26, 26, 26, 0.12);
+
+// ===== 圆角 =====
+$radius-sm: 6px;
+$radius-base: 10px;
+$radius-lg: 14px;
+$radius-xl: 20px;
+
+// ===== 间距(page、组件统一使用) =====
+$spacing-xs: 4px;
+$spacing-sm: 8px;
+$spacing-md: 12px;
+$spacing-base: 16px;
+$spacing-lg: 20px;
+$spacing-xl: 24px;
+$spacing-2xl: 32px;
+
+// ===== 字号 =====
+$font-size-caption: 12px;
+$font-size-body: 13px;
+$font-size-base: 14px;
+$font-size-h3: 16px;
+$font-size-h2: 18px;
+$font-size-h1: 22px;
 
-$radius-sm: 10px;
-$radius-base: 14px;
-$radius-lg: 20px;
-$radius-xl: 28px;
+// ===== 过渡 =====
+$transition-fast: 0.12s;
+$transition-base: 0.18s;
+$transition-slow: 0.28s;

+ 9 - 0
client/src/types/global.d.ts

@@ -50,6 +50,15 @@ declare global {
       openLogFile?: () => Promise<void>;
       onServicesStatusChanged?: (callback: (status: { nodeOk: boolean }) => void) => void;
       removeServicesStatusListener?: () => void;
+      onPlaywrightInstallProgress?: (
+        callback: (data: {
+          phase: 'start' | 'progress' | 'done';
+          percent?: number;
+          ok?: boolean;
+          message?: string;
+        }) => void,
+      ) => void;
+      removePlaywrightInstallProgressListener?: () => void;
     };
   }
 }

+ 20 - 28
client/src/views/Analytics/Account/index.vue

@@ -248,8 +248,8 @@
 
             <!-- 每日数据表格:时间倒序;收益、推荐量暂未接入先注释 -->
             <div class="detail-table-card">
-              <el-table :data="detailDailyData" v-loading="detailLoading" stripe>
-              <el-table-column prop="date" label="时间" width="120" align="center" />
+              <el-table :data="detailDailyData" v-loading="detailLoading" stripe style="width: 100%">
+              <el-table-column prop="date" label="时间" min-width="140" align="center" />
               <!-- 收益与推荐量暂未接入,先隐藏
               <el-table-column prop="income" label="收益" width="90" align="center">
                 <template #default="{ row }">
@@ -262,22 +262,22 @@
                 </template>
               </el-table-column>
               -->
-              <el-table-column prop="viewsCount" label="播放(阅读)量" width="130" align="center">
+              <el-table-column prop="viewsCount" label="播放(阅读)量" min-width="140" align="center">
                 <template #default="{ row }">
                   <span>{{ row.viewsCount ?? 0 }}</span>
                 </template>
               </el-table-column>
-              <el-table-column prop="commentsCount" label="评论量" width="90" align="center">
+              <el-table-column prop="commentsCount" label="评论量" min-width="100" align="center">
                 <template #default="{ row }">
                   <span>{{ row.commentsCount ?? 0 }}</span>
                 </template>
               </el-table-column>
-              <el-table-column prop="likesCount" label="点赞量" width="90" align="center">
+              <el-table-column prop="likesCount" label="点赞量" min-width="100" align="center">
                 <template #default="{ row }">
                   <span>{{ row.likesCount ?? 0 }}</span>
                 </template>
               </el-table-column>
-              <el-table-column prop="fansIncrease" label="涨粉量" width="90" align="center">
+              <el-table-column prop="fansIncrease" label="涨粉量" min-width="100" align="center">
                 <template #default="{ row }">
                   <span>{{ row.fansIncrease ?? 0 }}</span>
                 </template>
@@ -940,22 +940,15 @@ onMounted(() => {
 
 .account-analytics {
   .filter-bar {
-    display: flex;
-    align-items: center;
     justify-content: space-between;
-    gap: 12px;
-    margin-bottom: 20px;
-    padding: 12px;
-    background: $surface-primary;
-    border: 1px solid $border-light;
-    border-radius: 12px;
-    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
 
     .filter-left {
       display: flex;
       align-items: center;
+      flex-wrap: wrap;
       gap: 12px;
       min-height: 36px;
+      flex: 1;
 
       .filter-label {
         font-size: 13px;
@@ -965,31 +958,30 @@ onMounted(() => {
       .quick-btns {
         display: flex;
         gap: 8px;
+        flex-wrap: wrap;
       }
     }
+
+    .filter-right {
+      flex-shrink: 0;
+    }
   }
 
   .stats-row {
     display: grid;
-    grid-template-columns: repeat(7, 1fr);
-    gap: 0;
+    grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+    gap: 12px;
     margin-bottom: 20px;
-    background: $surface-primary;
-    border: 1px solid $border-light;
-    border-radius: 12px;
-    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
-    overflow: hidden;
 
     .stat-card {
-      padding: 20px 16px;
+      background: $surface-primary;
+      border: 1px solid $border-light;
+      border-radius: 12px;
+      box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
+      padding: 18px 18px;
       display: flex;
       align-items: center;
       gap: 12px;
-      border-right: 1px solid $border-light;
-      
-      &:last-child {
-        border-right: none;
-      }
       
       .stat-icon {
         width: 36px;

+ 3 - 2
client/src/views/Analytics/Overview/index.vue

@@ -486,7 +486,7 @@ onMounted(() => {
 
   .stats-row {
     display: grid;
-    grid-template-columns: repeat(7, 1fr);
+    grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
     gap: 0;
     margin-bottom: 20px;
     background: $surface-primary;
@@ -499,7 +499,8 @@ onMounted(() => {
       padding: 20px 16px;
       text-align: center;
       border-right: 1px solid $border-light;
-      
+      border-bottom: 1px solid $border-light;
+
       &:last-child {
         border-right: none;
       }

+ 0 - 11
client/src/views/Analytics/Platform/index.vue

@@ -80,17 +80,6 @@
             <span class="update-time">{{ formatTime(row.updateTime) }}</span>
           </template>
         </el-table-column>
-        <el-table-column label="操作" width="84" align="center">
-          <template #default="{ row }">
-            <el-button 
-              type="primary" 
-              link 
-              @click="handleDetail(row)"
-            >
-              详情
-            </el-button>
-          </template>
-        </el-table-column>
       </el-table>
     </div>
     

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

@@ -16,7 +16,7 @@
       </div>
     </section>
 
-    <section class="page-card filter-bar">
+    <section class="filter-bar">
       <span class="filter-label">开始时间</span>
       <el-date-picker
         v-model="startDate"
@@ -66,9 +66,6 @@
           :value="account.id"
         />
       </el-select>
-    </section>
-
-    <section class="page-card filter-bar secondary">
       <el-select v-model="selectedGroup" placeholder="全部分组" clearable style="width: 150px" @change="loadData">
         <el-option label="全部分组" value="" />
         <el-option v-for="group in accountGroups" :key="group.id" :label="group.name" :value="group.id" />
@@ -94,9 +91,9 @@
       </el-select>
       <el-input
         v-model="searchKeyword"
+        class="filter-keyword"
         placeholder="搜索作品标题"
         clearable
-        style="width: 220px"
         @keyup.enter="loadData"
       >
         <template #prefix>
@@ -829,7 +826,7 @@ onMounted(async () => {
 
 .stats-row {
   display: grid;
-  grid-template-columns: repeat(6, minmax(0, 1fr));
+  grid-template-columns: repeat(auto-fit, minmax(168px, 1fr));
   gap: 12px;
 }
 

+ 12 - 30
client/src/views/Publish/index.vue

@@ -1,11 +1,16 @@
 <template>
   <div class="publish-page">
     <div class="page-header">
-      <h2>发布管理</h2>
-      <el-button type="primary" @click="showCreateDialog = true">
-        <el-icon><Plus /></el-icon>
-        新建发布
-      </el-button>
+      <div class="page-copy">
+        <h2>发布管理</h2>
+        <p>查看和管理多平台一键发布任务</p>
+      </div>
+      <div class="header-actions">
+        <el-button type="primary" @click="showCreateDialog = true">
+          <el-icon><Plus /></el-icon>
+          新建发布
+        </el-button>
+      </div>
     </div>
 
     <div class="filter-bar">
@@ -1207,33 +1212,10 @@ watch(showCreateDialog, (visible) => {
   gap: 20px;
 }
 
-.page-header {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  margin-bottom: 0;
-
-  h2 {
-    margin: 0;
-    font-size: 24px;
-  }
-}
-
-.filter-bar {
-  display: flex;
-  align-items: center;
-  flex-wrap: wrap;
-  gap: 12px;
-  width: 100%;
-  min-height: 36px;
-  margin-bottom: 0;
-  padding: 0;
-  background: transparent;
-}
+// page-header 使用全局样式
 
 .filter-keyword {
-  width: 220px;
-  flex: 0 0 220px;
+  width: 240px;
 }
 
 .table-card {

+ 10 - 25
client/src/views/Works/index.vue

@@ -1,8 +1,11 @@
 <template>
   <div class="works-page">
     <div class="page-header">
-      <h2>作品管理</h2>
-      <div class="header-stats">
+      <div class="page-copy">
+        <h2>作品管理</h2>
+        <p>聚合各平台作品数据,可同步、查看详情与评论</p>
+      </div>
+      <div class="header-actions header-stats">
         <span>总作品: {{ stats.totalCount }}</span>
         <span>总播放: {{ formatNumber(stats.totalPlayCount) }}</span>
         <span>总点赞: {{ formatNumber(stats.totalLikeCount) }}</span>
@@ -975,34 +978,16 @@ onUnmounted(() => {
   gap: 20px;
 }
 
-.page-header {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  margin-bottom: 0;
-
-  h2 { margin: 0; font-size: 24px; }
-
-  .header-stats {
-    display: flex;
-    gap: 20px;
-    color: $text-secondary;
-  }
-}
-
-.filter-bar {
+.page-header .header-stats {
   display: flex;
+  gap: 24px;
+  color: $text-secondary;
+  font-size: 13px;
   align-items: center;
-  flex-wrap: wrap;
-  gap: 12px;
-  min-height: 36px;
-  margin-bottom: 0;
-  padding: 0;
 }
 
-.filter-keyword {
+.filter-bar :deep(.filter-keyword) {
   width: 220px;
-  flex: 0 0 220px;
 }
 
 .table-card {

+ 68 - 41
pnpm-lock.yaml

@@ -44,9 +44,6 @@ importers:
       vue-echarts:
         specifier: ^6.6.8
         version: 6.7.3(@vue/runtime-core@3.5.26)(echarts@5.6.0)(vue@3.5.26(typescript@5.9.3))
-      vue-i18n:
-        specifier: ^9.9.0
-        version: 9.14.5(vue@3.5.26(typescript@5.9.3))
       vue-router:
         specifier: ^4.2.5
         version: 4.6.4(vue@3.5.26(typescript@5.9.3))
@@ -120,6 +117,9 @@ importers:
       bullmq:
         specifier: ^5.66.5
         version: 5.66.5
+      cloakbrowser:
+        specifier: ^0.3.27
+        version: 0.3.27(playwright-core@1.57.0)
       compression:
         specifier: ^1.7.4
         version: 1.8.1
@@ -803,18 +803,6 @@ packages:
     cpu: [x64]
     os: [win32]
 
-  '@intlify/core-base@9.14.5':
-    resolution: {integrity: sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==}
-    engines: {node: '>= 16'}
-
-  '@intlify/message-compiler@9.14.5':
-    resolution: {integrity: sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==}
-    engines: {node: '>= 16'}
-
-  '@intlify/shared@9.14.5':
-    resolution: {integrity: sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==}
-    engines: {node: '>= 16'}
-
   '@ioredis/commands@1.5.0':
     resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==}
 
@@ -822,6 +810,10 @@ packages:
     resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
     engines: {node: '>=12'}
 
+  '@isaacs/fs-minipass@4.0.1':
+    resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
+    engines: {node: '>=18.0.0'}
+
   '@jridgewell/sourcemap-codec@1.5.5':
     resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
 
@@ -1682,6 +1674,10 @@ packages:
     resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
     engines: {node: '>=10'}
 
+  chownr@3.0.0:
+    resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
+    engines: {node: '>=18'}
+
   chromium-pickle-js@0.2.0:
     resolution: {integrity: sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==}
 
@@ -1697,6 +1693,25 @@ packages:
     resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
     engines: {node: '>=12'}
 
+  cloakbrowser@0.3.27:
+    resolution: {integrity: sha512-EjTI+Ux8XaCDHKDOLFOt6Tsv2g6AhQPQjIL1GqAazcxk+BAg8FfFxtSHYdVcvldsXSW8RpPEz8N3AX54vEjzBg==}
+    engines: {node: '>=20.0.0'}
+    hasBin: true
+    peerDependencies:
+      mmdb-lib: '>=2.0.0'
+      playwright-core: '>=1.40.0'
+      puppeteer-core: '>=21.0.0'
+      socks-proxy-agent: '>=10.0.0'
+    peerDependenciesMeta:
+      mmdb-lib:
+        optional: true
+      playwright-core:
+        optional: true
+      puppeteer-core:
+        optional: true
+      socks-proxy-agent:
+        optional: true
+
   clone-response@1.0.3:
     resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==}
 
@@ -2755,6 +2770,10 @@ packages:
     resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==}
     engines: {node: '>= 8'}
 
+  minizlib@3.1.0:
+    resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
+    engines: {node: '>= 18'}
+
   mkdirp@1.0.4:
     resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
     engines: {node: '>=10'}
@@ -3378,6 +3397,10 @@ packages:
     resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
     engines: {node: '>=10'}
 
+  tar@7.5.15:
+    resolution: {integrity: sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==}
+    engines: {node: '>=18'}
+
   temp-file@3.4.0:
     resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==}
 
@@ -3689,13 +3712,6 @@ packages:
     peerDependencies:
       eslint: '>=6.0.0'
 
-  vue-i18n@9.14.5:
-    resolution: {integrity: sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==}
-    engines: {node: '>= 16'}
-    deprecated: v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html
-    peerDependencies:
-      vue: ^3.0.0
-
   vue-router@4.6.4:
     resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
     peerDependencies:
@@ -3803,6 +3819,10 @@ packages:
   yallist@4.0.0:
     resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
 
+  yallist@5.0.0:
+    resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
+    engines: {node: '>=18'}
+
   yargs-parser@21.1.1:
     resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
     engines: {node: '>=12'}
@@ -4210,18 +4230,6 @@ snapshots:
   '@img/sharp-win32-x64@0.34.5':
     optional: true
 
-  '@intlify/core-base@9.14.5':
-    dependencies:
-      '@intlify/message-compiler': 9.14.5
-      '@intlify/shared': 9.14.5
-
-  '@intlify/message-compiler@9.14.5':
-    dependencies:
-      '@intlify/shared': 9.14.5
-      source-map-js: 1.2.1
-
-  '@intlify/shared@9.14.5': {}
-
   '@ioredis/commands@1.5.0': {}
 
   '@isaacs/cliui@8.0.2':
@@ -4233,6 +4241,10 @@ snapshots:
       wrap-ansi: 8.1.0
       wrap-ansi-cjs: wrap-ansi@7.0.0
 
+  '@isaacs/fs-minipass@4.0.1':
+    dependencies:
+      minipass: 7.1.2
+
   '@jridgewell/sourcemap-codec@1.5.5': {}
 
   '@malept/cross-spawn-promise@1.1.1':
@@ -5166,6 +5178,8 @@ snapshots:
 
   chownr@2.0.0: {}
 
+  chownr@3.0.0: {}
+
   chromium-pickle-js@0.2.0: {}
 
   ci-info@3.9.0: {}
@@ -5182,6 +5196,12 @@ snapshots:
       strip-ansi: 6.0.1
       wrap-ansi: 7.0.0
 
+  cloakbrowser@0.3.27(playwright-core@1.57.0):
+    dependencies:
+      tar: 7.5.15
+    optionalDependencies:
+      playwright-core: 1.57.0
+
   clone-response@1.0.3:
     dependencies:
       mimic-response: 1.0.1
@@ -6394,6 +6414,10 @@ snapshots:
       minipass: 3.3.6
       yallist: 4.0.0
 
+  minizlib@3.1.0:
+    dependencies:
+      minipass: 7.1.2
+
   mkdirp@1.0.4: {}
 
   mlly@1.8.0:
@@ -7090,6 +7114,14 @@ snapshots:
       mkdirp: 1.0.4
       yallist: 4.0.0
 
+  tar@7.5.15:
+    dependencies:
+      '@isaacs/fs-minipass': 4.0.1
+      chownr: 3.0.0
+      minipass: 7.1.2
+      minizlib: 3.1.0
+      yallist: 5.0.0
+
   temp-file@3.4.0:
     dependencies:
       async-exit-hook: 2.0.1
@@ -7332,13 +7364,6 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
-  vue-i18n@9.14.5(vue@3.5.26(typescript@5.9.3)):
-    dependencies:
-      '@intlify/core-base': 9.14.5
-      '@intlify/shared': 9.14.5
-      '@vue/devtools-api': 6.6.4
-      vue: 3.5.26(typescript@5.9.3)
-
   vue-router@4.6.4(vue@3.5.26(typescript@5.9.3)):
     dependencies:
       '@vue/devtools-api': 6.6.4
@@ -7451,6 +7476,8 @@ snapshots:
 
   yallist@4.0.0: {}
 
+  yallist@5.0.0: {}
+
   yargs-parser@21.1.1: {}
 
   yargs@17.7.2:

+ 1 - 0
server/package.json

@@ -34,6 +34,7 @@
     "ioredis": "^5.9.2",
     "jsonwebtoken": "^9.0.2",
     "morgan": "^1.10.0",
+    "cloakbrowser": "^0.3.27",
     "multer": "^2.1.1",
     "mysql2": "^3.6.5",
     "node-schedule": "^2.1.1",

+ 4 - 3
server/src/automation/browser.ts

@@ -1,4 +1,5 @@
-import { chromium, type Browser } from 'playwright';
+import type { Browser } from 'playwright';
+import { launchBrowser } from './browserProvider.js';
 import { logger } from '../utils/logger.js';
 
 export interface BrowserOptions {
@@ -62,7 +63,7 @@ export class BrowserManager {
     try {
       logger.info('Launching headful browser...');
 
-      this.headfulBrowser = await chromium.launch({
+      this.headfulBrowser = await launchBrowser({
         headless: false,
         args: [
           '--no-sandbox',
@@ -117,7 +118,7 @@ export class BrowserManager {
     try {
       logger.info('Launching headless browser...');
 
-      this.headlessBrowser = await chromium.launch({
+      this.headlessBrowser = await launchBrowser({
         headless: true,
         args: [
           '--no-sandbox',

+ 65 - 0
server/src/automation/browserProvider.ts

@@ -0,0 +1,65 @@
+import { chromium } from 'playwright';
+import type { Browser } from 'playwright';
+import { logger } from '../utils/logger.js';
+
+type PlaywrightLaunchOptions = Parameters<typeof chromium.launch>[0];
+
+type CloakLaunchOptions = {
+  headless?: boolean;
+  proxy?: PlaywrightLaunchOptions extends { proxy?: infer P } ? P : unknown;
+  args?: string[];
+  timezone?: string;
+  locale?: string;
+  humanize?: boolean;
+  humanPreset?: 'default' | 'careful';
+  launchOptions?: Record<string, unknown>;
+};
+
+const CLOAK_INCOMPATIBLE_ARGS = new Set([
+  '--disable-accelerated-2d-canvas',
+  '--disable-blink-features',
+  '--disable-gpu',
+]);
+
+export function isCloakBrowserEnabled(): boolean {
+  if (process.env.BROWSER_BACKEND) {
+    return process.env.BROWSER_BACKEND === 'cloakbrowser';
+  }
+  return process.env.USE_CLOAKBROWSER !== '0';
+}
+
+function isCloakBrowserRuntimeSupported(): boolean {
+  return Number(process.versions.node.split('.')[0]) >= 20;
+}
+
+function normalizeCloakArgs(args?: string[]): string[] | undefined {
+  if (!args || process.env.CLOAKBROWSER_KEEP_LEGACY_ARGS === '1') return args;
+  return args.filter((arg) => !CLOAK_INCOMPATIBLE_ARGS.has(arg.split('=')[0]));
+}
+
+export async function launchBrowser(options: PlaywrightLaunchOptions = {}): Promise<Browser> {
+  if (!isCloakBrowserEnabled()) {
+    return chromium.launch(options);
+  }
+
+  if (!isCloakBrowserRuntimeSupported()) {
+    logger.warn(`[BrowserProvider] CloakBrowser requires Node.js >= 20, current=${process.versions.node}; falling back to Playwright`);
+    return chromium.launch(options);
+  }
+
+  const { headless, proxy, args, ...launchOptions } = options;
+  const cloakOptions: CloakLaunchOptions = {
+    headless: headless ?? true,
+    proxy,
+    args: normalizeCloakArgs(args),
+    timezone: process.env.CLOAKBROWSER_TIMEZONE || 'Asia/Shanghai',
+    locale: process.env.CLOAKBROWSER_LOCALE || 'zh-CN',
+    humanize: process.env.CLOAKBROWSER_HUMANIZE === '1',
+    humanPreset: process.env.CLOAKBROWSER_HUMAN_PRESET === 'careful' ? 'careful' : 'default',
+    launchOptions: launchOptions as Record<string, unknown>,
+  };
+
+  logger.info('[BrowserProvider] Launching CloakBrowser');
+  const { launch } = await import('cloakbrowser');
+  return (await launch(cloakOptions as never)) as unknown as Browser;
+}

+ 2 - 2
server/src/scripts/check-xhs-cookie.ts

@@ -7,7 +7,7 @@ import { PlatformAccount } from '../models/entities/PlatformAccount.js';
 import { AppDataSource } from '../models/index.js';
 import { CookieManager } from '../automation/cookie.js';
 import { logger } from '../utils/logger.js';
-import { chromium } from 'playwright';
+import { launchBrowser } from '../automation/browserProvider.js';
 
 async function main() {
   logger.info('========================================');
@@ -78,7 +78,7 @@ async function main() {
   logger.info('使用浏览器检查 Cookie 有效性...');
   logger.info('========================================\n');
 
-  const browser = await chromium.launch({ headless: true });
+  const browser = await launchBrowser({ headless: true });
   const context = await browser.newContext({
     userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
   });

+ 3 - 2
server/src/services/BaijiahaoContentOverviewImportService.ts

@@ -1,6 +1,6 @@
 import fs from 'node:fs/promises';
 import path from 'node:path';
-import { chromium, type Browser, type BrowserContext } from 'playwright';
+import type { Browser, BrowserContext } from 'playwright';
 import * as XLSXNS from 'xlsx';
 import { AppDataSource, PlatformAccount } from '../models/index.js';
 import { BrowserManager } from '../automation/browser.js';
@@ -10,6 +10,7 @@ import { AccountService } from './AccountService.js';
 import type { ProxyConfig } from '@media-manager/shared';
 import { WS_EVENTS } from '@media-manager/shared';
 import { wsManager } from '../websocket/index.js';
+import { launchBrowser } from '../automation/browserProvider.js';
 
 // xlsx 在 ESM 下可能挂在 default 上;这里做一次兼容兜底
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -141,7 +142,7 @@ async function createBrowserForAccount(proxy: ProxyConfig | null): Promise<{ bro
   const headless = !allowHeadfulForBootstrap;
   if (proxy?.enabled) {
     const server = `${proxy.type}://${proxy.host}:${proxy.port}`;
-    const browser = await chromium.launch({
+    const browser = await launchBrowser({
       headless,
       proxy: {
         server,

+ 4 - 3
server/src/services/BaijiahaoWorkDailyStatisticsImportService.ts

@@ -1,11 +1,12 @@
 import fs from 'node:fs/promises';
 import path from 'node:path';
-import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
+import type { Browser, BrowserContext, Page } from 'playwright';
 import { AppDataSource, PlatformAccount, Work } from '../models/index.js';
 import { logger } from '../utils/logger.js';
 import { WorkDayStatisticsService } from './WorkDayStatisticsService.js';
 import type { ProxyConfig } from '@media-manager/shared';
 import { AccountService } from './AccountService.js';
+import { launchBrowser } from '../automation/browserProvider.js';
 
 type PlaywrightCookie = {
   name: string;
@@ -132,7 +133,7 @@ async function createBrowserForAccount(proxy: ProxyConfig | null): Promise<{ bro
   const headless = true;
   if (proxy?.enabled) {
     const server = `${proxy.type}://${proxy.host}:${proxy.port}`;
-    const browser = await chromium.launch({
+    const browser = await launchBrowser({
       headless,
       proxy: {
         server,
@@ -144,7 +145,7 @@ async function createBrowserForAccount(proxy: ProxyConfig | null): Promise<{ bro
     return { browser, shouldClose: true };
   }
 
-  const browser = await chromium.launch({
+  const browser = await launchBrowser({
     headless,
     args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--window-size=1920,1080'],
   });

+ 3 - 2
server/src/services/BrowserLoginService.ts

@@ -1,3 +1,4 @@
+import { launchBrowser } from '../automation/browserProvider.js';
 /**
  * @deprecated 此服务已弃用,请使用新的登录服务架构
  * 
@@ -28,7 +29,7 @@
  */
 
 /// <reference lib="dom" />
-import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
+import type { Browser, BrowserContext, Page } from 'playwright';
 import { EventEmitter } from 'events';
 import { logger } from '../utils/logger.js';
 import { CookieManager } from '../automation/cookie.js';
@@ -155,7 +156,7 @@ class BrowserLoginService extends EventEmitter {
 
     try {
       // 启动可见浏览器(非无头模式)
-      const browser = await chromium.launch({
+      const browser = await launchBrowser({
         headless: false,
         args: [
           '--disable-blink-features=AutomationControlled',

+ 3 - 2
server/src/services/DouyinAccountOverviewImportService.ts

@@ -1,6 +1,6 @@
 import fs from 'node:fs/promises';
 import path from 'node:path';
-import { chromium, type Browser } from 'playwright';
+import type { Browser } from 'playwright';
 import { AppDataSource, PlatformAccount } from '../models/index.js';
 import { BrowserManager } from '../automation/browser.js';
 import { logger } from '../utils/logger.js';
@@ -9,6 +9,7 @@ import { AccountService } from './AccountService.js';
 import type { ProxyConfig } from '@media-manager/shared';
 import { WS_EVENTS } from '@media-manager/shared';
 import { wsManager } from '../websocket/index.js';
+import { launchBrowser } from '../automation/browserProvider.js';
 
 type PlaywrightCookie = {
   name: string;
@@ -136,7 +137,7 @@ async function createBrowserForAccount(proxy: ProxyConfig | null): Promise<{ bro
   const headless = !allowHeadfulForBootstrap;
   if (proxy?.enabled) {
     const server = `${proxy.type}://${proxy.host}:${proxy.port}`;
-    const browser = await chromium.launch({
+    const browser = await launchBrowser({
       headless,
       proxy: {
         server,

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

@@ -1,5 +1,6 @@
+import { launchBrowser } from '../automation/browserProvider.js';
 /// <reference lib="dom" />
-import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
+import type { Browser, BrowserContext, Page } from 'playwright';
 import { AppDataSource, PlatformAccount, Work } from '../models/index.js';
 import { logger } from '../utils/logger.js';
 import { WorkDayStatisticsService } from './WorkDayStatisticsService.js';
@@ -126,7 +127,7 @@ async function createBrowserForAccount(proxy: ProxyConfig | null): Promise<{ bro
   const headless = true;
   if (proxy?.enabled) {
     const server = `${proxy.type}://${proxy.host}:${proxy.port}`;
-    const browser = await chromium.launch({
+    const browser = await launchBrowser({
       headless,
       proxy: {
         server,

+ 12 - 11
server/src/services/HeadlessBrowserService.ts

@@ -1,5 +1,6 @@
 /// <reference lib="dom" />
-import { chromium, type BrowserContext, type Page } from 'playwright';
+import type { BrowserContext, Page } from 'playwright';
+import { launchBrowser } from '../automation/browserProvider.js';
 import { logger, safeStringify } from '../utils/logger.js';
 import {
   extractDeclaredNotesCountFromPostedResponse,
@@ -380,7 +381,7 @@ class HeadlessBrowserService {
       return this.checkDouyinLoginStatusByApi(cookies);
     }
 
-    const browser = await chromium.launch({ headless: true });
+    const browser = await launchBrowser({ headless: true });
 
     try {
       const context = await browser.newContext({
@@ -467,7 +468,7 @@ class HeadlessBrowserService {
    * 访问创作者首页,监听 check/user 接口返回的 result 字段判断登录状态
    */
   private async checkDouyinLoginStatusByApi(cookies: CookieData[]): Promise<CookieCheckResult> {
-    const browser = await chromium.launch({ headless: true });
+    const browser = await launchBrowser({ headless: true });
     let isLoggedIn = false;
     let checkCompleted = false;
     let isRiskControl = false;
@@ -677,7 +678,7 @@ class HeadlessBrowserService {
    * @returns Base64 编码的截图,失败返回 null
    */
   async capturePageScreenshot(platform: PlatformType, cookies: CookieData[]): Promise<string | null> {
-    const browser = await chromium.launch({ headless: true });
+    const browser = await launchBrowser({ headless: true });
 
     try {
       const context = await browser.newContext({
@@ -773,7 +774,7 @@ class HeadlessBrowserService {
   }
 
   private async fetchAccountInfoWithPlaywright(platform: PlatformType, cookies: CookieData[]): Promise<AccountInfo> {
-    const browser = await chromium.launch({ headless: true });
+    const browser = await launchBrowser({ headless: true });
 
     try {
       const context = await browser.newContext({
@@ -3159,7 +3160,7 @@ class HeadlessBrowserService {
    * 获取抖音评论 - 逐个选择作品获取评论
    */
   async fetchDouyinComments(cookies: CookieData[]): Promise<WorkComments[]> {
-    const browser = await chromium.launch({
+    const browser = await launchBrowser({
       headless: true,
       args: ['--no-sandbox', '--disable-setuid-sandbox'],
     });
@@ -3494,7 +3495,7 @@ class HeadlessBrowserService {
    * 更稳定、更高效
    */
   async fetchDouyinCommentsByApiInterception(cookies: CookieData[]): Promise<WorkComments[]> {
-    const browser = await chromium.launch({
+    const browser = await launchBrowser({
       headless: true, // 无头模式
       args: ['--no-sandbox', '--disable-setuid-sandbox'],
     });
@@ -4388,7 +4389,7 @@ class HeadlessBrowserService {
 
     logger.info('[Fallback] Using DOM parsing method...');
 
-    const browser = await chromium.launch({
+    const browser = await launchBrowser({
       headless: true, // 改为无头模式
       args: ['--no-sandbox', '--disable-setuid-sandbox'],
     });
@@ -4880,7 +4881,7 @@ class HeadlessBrowserService {
    * 通过 Node ????? 获取小红书评论 - 一次性获取所有作品的评论
    */
   async fetchXiaohongshuCommentsViaApi(cookies: CookieData[]): Promise<WorkComments[]> {
-    const browser = await chromium.launch({
+    const browser = await launchBrowser({
       headless: true,
       args: ['--no-sandbox', '--disable-setuid-sandbox'],
     });
@@ -5102,7 +5103,7 @@ class HeadlessBrowserService {
    * 获取百家号评论
    */
   private async fetchBaijiahaoCommentsByBrowser(cookies: CookieData[]): Promise<WorkComments[]> {
-    const browser = await chromium.launch({
+    const browser = await launchBrowser({
       headless: true,
       args: ['--no-sandbox', '--disable-setuid-sandbox'],
     });
@@ -5236,7 +5237,7 @@ class HeadlessBrowserService {
    * 获取微信视频号评论
    */
   async fetchWeixinVideoCommentsViaApi(cookies: CookieData[]): Promise<WorkComments[]> {
-    const browser = await chromium.launch({
+    const browser = await launchBrowser({
       headless: true,
       args: ['--no-sandbox', '--disable-setuid-sandbox'],
     });

+ 3 - 2
server/src/services/WeixinAutoReplyService.ts

@@ -1,6 +1,7 @@
-import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
+import type { Browser, BrowserContext, Page } from 'playwright';
 import { config } from '../config/index.js';
 import { logger } from '../utils/logger.js';
+import { launchBrowser } from '../automation/browserProvider.js';
 
 type CookieLike = {
   name: string;
@@ -94,7 +95,7 @@ export class WeixinAutoReplyService {
     let page: Page | null = null;
 
     try {
-      browser = await chromium.launch({
+      browser = await launchBrowser({
         headless: true,
         args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
       });

+ 3 - 2
server/src/services/WeixinVideoDataCenterImportService.ts

@@ -1,6 +1,6 @@
 import fs from 'node:fs/promises';
 import path from 'node:path';
-import { chromium, type Browser } from 'playwright';
+import type { Browser } from 'playwright';
 import * as XLSXNS from 'xlsx';
 import { AppDataSource, PlatformAccount } from '../models/index.js';
 import { BrowserManager } from '../automation/browser.js';
@@ -8,6 +8,7 @@ import { logger } from '../utils/logger.js';
 import { UserDayStatisticsService } from './UserDayStatisticsService.js';
 import { AccountService } from './AccountService.js';
 import type { ProxyConfig } from '@media-manager/shared';
+import { launchBrowser } from '../automation/browserProvider.js';
 
 // xlsx 在 ESM 下可能挂在 default 上;这里做一次兼容兜底
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -142,7 +143,7 @@ async function createBrowserForAccount(proxy: ProxyConfig | null): Promise<{ bro
   const headless = process.env.WX_IMPORT_HEADLESS === '0' ? false : true;
   if (proxy?.enabled) {
     const server = `${proxy.type}://${proxy.host}:${proxy.port}`;
-    const browser = await chromium.launch({
+    const browser = await launchBrowser({
       headless,
       proxy: {
         server,

+ 3 - 2
server/src/services/WeixinVideoWorkStatisticsImportService.ts

@@ -1,3 +1,4 @@
+import { launchBrowser } from '../automation/browserProvider.js';
 /**
  * 视频号:作品维度「纯浏览器自动化」→ 导入 work_day_statistics
  *
@@ -8,7 +9,7 @@
  * 4. 解析「全部」tab 的 browse/like/comment/forward/fav/follow,写入 work_day_statistics
  */
 
-import { chromium, type Browser } from 'playwright';
+import type { Browser } from 'playwright';
 import { AppDataSource, PlatformAccount, Work } from '../models/index.js';
 import { BrowserManager } from '../automation/browser.js';
 import { logger } from '../utils/logger.js';
@@ -99,7 +100,7 @@ async function createBrowserForAccount(proxy: ProxyConfig | null, headless: bool
 }> {
   if (proxy?.enabled) {
     const server = `${proxy.type}://${proxy.host}:${proxy.port}`;
-    const browser = await chromium.launch({
+    const browser = await launchBrowser({
       headless,
       proxy: {
         server,

+ 3 - 2
server/src/services/XiaohongshuAccountOverviewImportService.ts

@@ -1,6 +1,7 @@
+import { launchBrowser } from '../automation/browserProvider.js';
 import fs from 'node:fs/promises';
 import path from 'node:path';
-import { chromium, type Browser, type Page, type BrowserContext } from 'playwright';
+import type { Browser, Page, BrowserContext } from 'playwright';
 import * as XLSXNS from 'xlsx';
 import { AppDataSource, PlatformAccount } from '../models/index.js';
 import { BrowserManager } from '../automation/browser.js';
@@ -183,7 +184,7 @@ async function createBrowserForAccount(proxy: ProxyConfig | null): Promise<{ bro
   const headless = !allowHeadfulForBootstrap;
   if (proxy?.enabled) {
     const server = `${proxy.type}://${proxy.host}:${proxy.port}`;
-    const browser = await chromium.launch({
+    const browser = await launchBrowser({
       headless,
       proxy: {
         server,

+ 3 - 2
server/src/services/XiaohongshuWorkNoteStatisticsImportService.ts

@@ -1,4 +1,4 @@
-import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
+import type { Browser, BrowserContext, Page } from 'playwright';
 import { AppDataSource, PlatformAccount, Work } from '../models/index.js';
 import { logger } from '../utils/logger.js';
 import { WorkDayStatisticsService } from './WorkDayStatisticsService.js';
@@ -6,6 +6,7 @@ import { AccountService } from './AccountService.js';
 import type { ProxyConfig } from '@media-manager/shared';
 import { BrowserManager } from '../automation/browser.js';
 import { In } from 'typeorm';
+import { launchBrowser } from '../automation/browserProvider.js';
 
 /** 小红书笔记详情页跳转到登录时抛出,用于触发「先刷新登录、再决定是否账号失效」 */
 export class XhsLoginExpiredError extends Error {
@@ -151,7 +152,7 @@ async function createBrowserForAccount(proxy: ProxyConfig | null): Promise<{ bro
   const headless = true;
   if (proxy?.enabled) {
     const server = `${proxy.type}://${proxy.host}:${proxy.port}`;
-    const browser = await chromium.launch({
+    const browser = await launchBrowser({
       headless,
       proxy: {
         server,

+ 3 - 2
server/src/services/login/BaseLoginService.ts

@@ -1,3 +1,4 @@
+import { launchBrowser } from '../../automation/browserProvider.js';
 /**
  * 多平台登录服务 - 抽象基类
  * @module services/login/BaseLoginService
@@ -9,7 +10,7 @@
  * 4. 所有信息获取完成后,才发送成功事件,前端显示保存按钮
  */
 
-import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
+import type { Browser, BrowserContext, Page } from 'playwright';
 import { EventEmitter } from 'events';
 import { logger } from '../../utils/logger.js';
 import { CookieManager } from '../../automation/cookie.js';
@@ -71,7 +72,7 @@ export abstract class BaseLoginService extends EventEmitter {
 
     try {
       // 1. 启动浏览器
-      const browser = await chromium.launch({
+      const browser = await launchBrowser({
         headless: false,
         args: ['--disable-blink-features=AutomationControlled', '--window-size=1300,900'],
       });