Procházet zdrojové kódy

Add favicon support and enhance Electron app with tray functionality

- Added favicon link to the HTML file for better branding.
- Implemented system tray functionality in Electron, including a custom tray icon and context menu for window management.
- Enhanced IPC communication for window state management and cookie handling in the Electron main process.
- Updated Vite configuration to automatically resolve Element Plus icon components.
- Improved UI components with new styles and layout adjustments for better user experience.
- Added new API endpoint for verifying login status using cookies.
Ethanfly před 1 měsícem
rodič
revize
b827628ed4
34 změnil soubory, kde provedl 4168 přidání a 868 odebrání
  1. 132 7
      client/dist-electron/main.js
  2. 0 0
      client/dist-electron/main.js.map
  3. 10 1
      client/dist-electron/preload.js
  4. 39 0
      client/electron-builder.json
  5. 172 6
      client/electron/main.ts
  6. 16 0
      client/electron/preload.ts
  7. 1 0
      client/index.html
  8. 43 0
      client/public/favicon.svg
  9. 24 1
      client/src/api/accounts.ts
  10. 7 0
      client/src/components.d.ts
  11. 769 0
      client/src/components/BrowserTab.vue
  12. 63 39
      client/src/components/TaskProgressDialog.vue
  13. 69 0
      client/src/components/icons/index.vue
  14. 915 129
      client/src/layouts/MainLayout.vue
  15. 4 4
      client/src/main.ts
  16. 266 0
      client/src/stores/tabs.ts
  17. 9 0
      client/src/stores/taskQueue.ts
  18. 68 18
      client/src/styles/index.scss
  19. 52 21
      client/src/styles/variables.scss
  20. 164 377
      client/src/views/Accounts/index.vue
  21. 332 111
      client/src/views/Dashboard/index.vue
  22. 143 12
      client/src/views/Login/index.vue
  23. 112 6
      client/src/views/Publish/index.vue
  24. 9 6
      client/src/views/Works/index.vue
  25. 21 1
      client/vite.config.ts
  26. 51 2
      server/src/automation/platforms/base.ts
  27. 4 0
      server/src/automation/platforms/bilibili.ts
  28. 15 9
      server/src/automation/platforms/douyin.ts
  29. 4 0
      server/src/automation/platforms/kuaishou.ts
  30. 40 0
      server/src/routes/accounts.ts
  31. 155 8
      server/src/services/AccountService.ts
  32. 54 3
      server/src/services/CommentService.ts
  33. 326 103
      server/src/services/HeadlessBrowserService.ts
  34. 79 4
      server/src/services/WorkService.ts

+ 132 - 7
client/dist-electron/main.js

@@ -1,25 +1,98 @@
 "use strict";
-const { app, BrowserWindow, ipcMain, shell } = require("electron");
+const { app, BrowserWindow, ipcMain, shell, session, Menu, Tray, nativeImage } = 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/favicon.svg") : join(__dirname, "../dist/favicon.svg");
+}
+function createTrayIcon() {
+  const iconSize = 16;
+  const canvas = `
+    <svg xmlns="http://www.w3.org/2000/svg" width="${iconSize}" height="${iconSize}" viewBox="0 0 512 512">
+      <rect x="32" y="32" width="448" height="448" rx="96" fill="#4f8cff"/>
+      <circle cx="256" cy="256" r="100" fill="#fff"/>
+      <path d="M 228 190 L 228 322 L 330 256 Z" fill="#4f8cff"/>
+    </svg>
+  `;
+  const dataUrl = `data:image/svg+xml;base64,${Buffer.from(canvas).toString("base64")}`;
+  return nativeImage.createFromDataURL(dataUrl);
+}
+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
+      contextIsolation: true,
+      webviewTag: true
+      // 启用 webview 标签
     },
-    titleBarStyle: "hiddenInset",
-    frame: process.platform !== "darwin",
+    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);
@@ -31,6 +104,13 @@ function createWindow() {
     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;
   });
@@ -41,22 +121,32 @@ if (!gotTheLock) {
 } else {
   app.on("second-instance", () => {
     if (mainWindow) {
+      mainWindow.show();
       if (mainWindow.isMinimized()) mainWindow.restore();
       mainWindow.focus();
     }
   });
   app.whenReady().then(() => {
+    createTray();
     createWindow();
     app.on("activate", () => {
       if (BrowserWindow.getAllWindows().length === 0) {
         createWindow();
+      } else if (mainWindow) {
+        mainWindow.show();
       }
     });
   });
 }
 app.on("window-all-closed", () => {
-  if (process.platform !== "darwin") {
-    app.quit();
+});
+app.on("before-quit", () => {
+  isQuitting = true;
+});
+app.on("quit", () => {
+  if (tray) {
+    tray.destroy();
+    tray = null;
   }
 });
 ipcMain.handle("get-app-version", () => {
@@ -76,6 +166,41 @@ ipcMain.on("window-maximize", () => {
   }
 });
 ipcMain.on("window-close", () => {
-  mainWindow == null ? void 0 : mainWindow.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;
+  }
 });
 //# sourceMappingURL=main.js.map

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
client/dist-electron/main.js.map


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 10 - 1
client/dist-electron/preload.js


+ 39 - 0
client/electron-builder.json

@@ -0,0 +1,39 @@
+{
+  "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
+  "appId": "com.mediamanager.app",
+  "productName": "多平台媒体管理系统",
+  "directories": {
+    "output": "release"
+  },
+  "files": [
+    "dist/**/*",
+    "dist-electron/**/*"
+  ],
+  "win": {
+    "target": [
+      {
+        "target": "nsis",
+        "arch": ["x64"]
+      }
+    ],
+    "icon": "build/icon.ico",
+    "artifactName": "${productName}-${version}-${arch}.${ext}"
+  },
+  "nsis": {
+    "oneClick": false,
+    "allowToChangeInstallationDirectory": true,
+    "createDesktopShortcut": true,
+    "createStartMenuShortcut": true,
+    "shortcutName": "媒体管理系统"
+  },
+  "mac": {
+    "target": ["dmg"],
+    "icon": "build/icon.icns",
+    "artifactName": "${productName}-${version}.${ext}"
+  },
+  "linux": {
+    "target": ["AppImage"],
+    "icon": "build/icon.png",
+    "artifactName": "${productName}-${version}.${ext}"
+  }
+}

+ 172 - 6
client/electron/main.ts

@@ -1,30 +1,121 @@
 // 使用 CommonJS 格式
-const { app, BrowserWindow, ipcMain, shell } = require('electron');
+const { app, BrowserWindow, ipcMain, shell, session, Menu, Tray, nativeImage } = require('electron');
 const { join } = require('path');
+const fs = require('fs');
 
 let mainWindow: typeof BrowserWindow.prototype | null = null;
+let tray: typeof Tray.prototype | null = 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/favicon.svg')
+    : join(__dirname, '../dist/favicon.svg');
+}
+
+// 创建托盘图标
+function createTrayIcon(): typeof nativeImage.prototype {
+  // 创建一个简单的蓝色图标作为托盘图标
+  // 由于 SVG 在某些系统上可能不支持,这里使用 data URL 创建图标
+  const iconSize = 16;
+  const canvas = `
+    <svg xmlns="http://www.w3.org/2000/svg" width="${iconSize}" height="${iconSize}" viewBox="0 0 512 512">
+      <rect x="32" y="32" width="448" height="448" rx="96" fill="#4f8cff"/>
+      <circle cx="256" cy="256" r="100" fill="#fff"/>
+      <path d="M 228 190 L 228 322 L 330 256 Z" fill="#4f8cff"/>
+    </svg>
+  `;
+  
+  const dataUrl = `data:image/svg+xml;base64,${Buffer.from(canvas).toString('base64')}`;
+  return nativeImage.createFromDataURL(dataUrl);
+}
+
+// 创建系统托盘
+function createTray() {
+  const trayIcon = createTrayIcon();
+  tray = new Tray(trayIcon);
+  
+  const contextMenu = Menu.buildFromTemplate([
+    {
+      label: '显示主窗口',
+      click: () => {
+        if (mainWindow) {
+          mainWindow.show();
+          mainWindow.focus();
+        }
+      }
+    },
+    {
+      label: '最小化到托盘',
+      click: () => {
+        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 标签
     },
-    titleBarStyle: 'hiddenInset',
-    frame: process.platform !== 'darwin',
+    frame: false, // 无边框窗口,自定义标题栏
+    transparent: false,
+    backgroundColor: '#f0f2f5',
     show: false,
   });
 
   // 窗口准备好后再显示,避免白屏
   mainWindow.once('ready-to-show', () => {
     mainWindow?.show();
+    setupWindowEvents();
   });
 
   // 加载页面
@@ -41,6 +132,19 @@ function createWindow() {
     return { action: 'deny' };
   });
 
+  // 关闭按钮默认最小化到托盘
+  mainWindow.on('close', (event: Event) => {
+    if (!isQuitting) {
+      event.preventDefault();
+      mainWindow?.hide();
+      
+      // 显示托盘通知(仅首次)
+      if (tray && !app.isPackaged) {
+        // 开发模式下可以显示通知
+      }
+    }
+  });
+
   mainWindow.on('closed', () => {
     mainWindow = null;
   });
@@ -54,25 +158,41 @@ if (!gotTheLock) {
 } else {
   app.on('second-instance', () => {
     if (mainWindow) {
+      mainWindow.show();
       if (mainWindow.isMinimized()) mainWindow.restore();
       mainWindow.focus();
     }
   });
 
   app.whenReady().then(() => {
+    createTray();
     createWindow();
 
     app.on('activate', () => {
       if (BrowserWindow.getAllWindows().length === 0) {
         createWindow();
+      } else if (mainWindow) {
+        mainWindow.show();
       }
     });
   });
 }
 
+// 阻止默认的 window-all-closed 行为,保持托盘运行
 app.on('window-all-closed', () => {
-  if (process.platform !== 'darwin') {
-    app.quit();
+  // 不退出应用,保持托盘运行
+  // 只有在 isQuitting 为 true 时才真正退出
+});
+
+// 应用退出前清理托盘
+app.on('before-quit', () => {
+  isQuitting = true;
+});
+
+app.on('quit', () => {
+  if (tray) {
+    tray.destroy();
+    tray = null;
   }
 });
 
@@ -98,6 +218,52 @@ ipcMain.on('window-maximize', () => {
   }
 });
 
+// 关闭窗口(最小化到托盘)
 ipcMain.on('window-close', () => {
-  mainWindow?.close();
+  mainWindow?.hide();
+});
+
+// 真正退出应用
+ipcMain.on('app-quit', () => {
+  isQuitting = true;
+  app.quit();
+});
+
+// 获取窗口最大化状态
+ipcMain.handle('window-is-maximized', () => {
+  return mainWindow?.isMaximized() || false;
+});
+
+// 监听窗口最大化/还原事件,通知渲染进程
+function setupWindowEvents() {
+  mainWindow?.on('maximize', () => {
+    mainWindow?.webContents.send('window-maximized', true);
+  });
+  mainWindow?.on('unmaximize', () => {
+    mainWindow?.webContents.send('window-maximized', false);
+  });
+}
+
+// 获取 webview 的 cookies
+ipcMain.handle('get-webview-cookies', async (_event: unknown, partition: string, url: string) => {
+  try {
+    const ses = session.fromPartition(partition);
+    const cookies = await ses.cookies.get({ url });
+    return cookies;
+  } catch (error) {
+    console.error('获取 cookies 失败:', error);
+    return [];
+  }
+});
+
+// 清除 webview 的 cookies
+ipcMain.handle('clear-webview-cookies', async (_event: unknown, partition: string) => {
+  try {
+    const ses = session.fromPartition(partition);
+    await ses.clearStorageData({ storages: ['cookies'] });
+    return true;
+  } catch (error) {
+    console.error('清除 cookies 失败:', error);
+    return false;
+  }
 });

+ 16 - 0
client/electron/preload.ts

@@ -11,6 +11,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
   minimizeWindow: () => ipcRenderer.send('window-minimize'),
   maximizeWindow: () => ipcRenderer.send('window-maximize'),
   closeWindow: () => ipcRenderer.send('window-close'),
+  quitApp: () => ipcRenderer.send('app-quit'), // 真正退出应用
+  isMaximized: () => ipcRenderer.invoke('window-is-maximized'),
+  onMaximizedChange: (callback: (isMaximized: boolean) => void) => {
+    ipcRenderer.on('window-maximized', (_event: unknown, isMaximized: boolean) => callback(isMaximized));
+  },
 
   // 文件操作
   selectFile: (options?: { filters?: { name: string; extensions: string[] }[] }) =>
@@ -20,6 +25,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
   // 通知
   showNotification: (title: string, body: string) =>
     ipcRenderer.send('show-notification', { title, body }),
+
+  // Webview Cookie 操作
+  getWebviewCookies: (partition: string, url: string) =>
+    ipcRenderer.invoke('get-webview-cookies', partition, url),
+  clearWebviewCookies: (partition: string) =>
+    ipcRenderer.invoke('clear-webview-cookies', partition),
 });
 
 // 类型声明
@@ -31,9 +42,14 @@ declare global {
       minimizeWindow: () => void;
       maximizeWindow: () => void;
       closeWindow: () => void;
+      quitApp: () => void;
+      isMaximized: () => Promise<boolean>;
+      onMaximizedChange: (callback: (isMaximized: boolean) => void) => void;
       selectFile: (options?: { filters?: { name: string; extensions: string[] }[] }) => Promise<string | null>;
       selectFolder: () => Promise<string | null>;
       showNotification: (title: string, body: string) => void;
+      getWebviewCookies: (partition: string, url: string) => Promise<Electron.Cookie[]>;
+      clearWebviewCookies: (partition: string) => Promise<boolean>;
     };
   }
 }

+ 1 - 0
client/index.html

@@ -4,6 +4,7 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' http://localhost:* ws://localhost:*" />
+    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
     <title>多平台媒体管理系统</title>
   </head>
   <body>

+ 43 - 0
client/public/favicon.svg

@@ -0,0 +1,43 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
+  <defs>
+    <linearGradient id="bg-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
+      <stop offset="0%" style="stop-color:#4f8cff;stop-opacity:1" />
+      <stop offset="100%" style="stop-color:#6366f1;stop-opacity:1" />
+    </linearGradient>
+    <linearGradient id="play-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
+      <stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
+      <stop offset="100%" style="stop-color:#e0e7ff;stop-opacity:1" />
+    </linearGradient>
+  </defs>
+  
+  <!-- 背景圆角矩形 -->
+  <rect x="32" y="32" width="448" height="448" rx="96" ry="96" fill="url(#bg-gradient)"/>
+  
+  <!-- 多平台网格图案 -->
+  <g opacity="0.15">
+    <rect x="80" y="80" width="160" height="160" rx="24" fill="#fff"/>
+    <rect x="272" y="80" width="160" height="160" rx="24" fill="#fff"/>
+    <rect x="80" y="272" width="160" height="160" rx="24" fill="#fff"/>
+    <rect x="272" y="272" width="160" height="160" rx="24" fill="#fff"/>
+  </g>
+  
+  <!-- 中心播放按钮 -->
+  <circle cx="256" cy="256" r="120" fill="url(#play-gradient)" opacity="0.95"/>
+  
+  <!-- 播放三角形 -->
+  <path d="M 220 180 L 220 332 L 340 256 Z" fill="url(#bg-gradient)"/>
+  
+  <!-- 装饰性连接线 -->
+  <g stroke="#fff" stroke-width="4" stroke-linecap="round" opacity="0.6">
+    <line x1="136" y1="160" x2="180" y2="200"/>
+    <line x1="376" y1="160" x2="332" y2="200"/>
+    <line x1="136" y1="352" x2="180" y2="312"/>
+    <line x1="376" y1="352" x2="332" y2="312"/>
+  </g>
+  
+  <!-- 角落小圆点 -->
+  <circle cx="136" cy="160" r="12" fill="#fff" opacity="0.8"/>
+  <circle cx="376" cy="160" r="12" fill="#fff" opacity="0.8"/>
+  <circle cx="136" cy="352" r="12" fill="#fff" opacity="0.8"/>
+  <circle cx="376" cy="352" r="12" fill="#fff" opacity="0.8"/>
+</svg>

+ 24 - 1
client/src/api/accounts.ts

@@ -36,7 +36,15 @@ export const accountsApi = {
     return request.get(`/api/accounts/${id}`);
   },
 
-  addAccount(data: AddPlatformAccountRequest): Promise<PlatformAccount> {
+  addAccount(data: AddPlatformAccountRequest & {
+    accountInfo?: {
+      accountId?: string;
+      accountName?: string;
+      avatarUrl?: string;
+      fansCount?: number;
+      worksCount?: number;
+    };
+  }): Promise<PlatformAccount> {
     return request.post('/api/accounts', data);
   },
 
@@ -101,4 +109,19 @@ export const accountsApi = {
   confirmBrowserLogin(sessionId: string, platform: string, groupId?: number): Promise<PlatformAccount> {
     return request.post(`/api/accounts/browser-login/${sessionId}/confirm`, { platform, groupId });
   },
+
+  // 验证 Cookie 登录状态(用于内嵌浏览器登录)
+  verifyLoginCookie(platform: string, cookieData: string): Promise<{
+    success: boolean;
+    message?: string;
+    accountInfo?: {
+      accountId: string;
+      accountName: string;
+      avatarUrl: string;
+      fansCount: number;
+      worksCount: number;
+    };
+  }> {
+    return request.post('/api/accounts/verify-cookie', { platform, cookieData });
+  },
 };

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

@@ -7,6 +7,8 @@ export {}
 
 declare module 'vue' {
   export interface GlobalComponents {
+    ArrowDown: typeof import('@element-plus/icons-vue')['ArrowDown']
+    BrowserTab: typeof import('./components/BrowserTab.vue')['default']
     CaptchaDialog: typeof import('./components/CaptchaDialog.vue')['default']
     ElAlert: typeof import('element-plus/es')['ElAlert']
     ElAside: typeof import('element-plus/es')['ElAside']
@@ -15,6 +17,7 @@ declare module 'vue' {
     ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
     ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
     ElButton: typeof import('element-plus/es')['ElButton']
+    ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
     ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
     ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
     ElCol: typeof import('element-plus/es')['ElCol']
@@ -52,7 +55,11 @@ declare module 'vue' {
     ElTabs: typeof import('element-plus/es')['ElTabs']
     ElTag: typeof import('element-plus/es')['ElTag']
     ElText: typeof import('element-plus/es')['ElText']
+    ElTooltip: typeof import('element-plus/es')['ElTooltip']
     ElUpload: typeof import('element-plus/es')['ElUpload']
+    Expand: typeof import('@element-plus/icons-vue')['Expand']
+    Fold: typeof import('@element-plus/icons-vue')['Fold']
+    Icons: typeof import('./components/icons/index.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']
     TaskProgressDialog: typeof import('./components/TaskProgressDialog.vue')['default']

+ 769 - 0
client/src/components/BrowserTab.vue

@@ -0,0 +1,769 @@
+<template>
+  <div class="browser-tab-container">
+    <!-- 工具栏 -->
+    <div class="browser-toolbar">
+      <div class="toolbar-left">
+        <el-button-group size="small">
+          <el-button :icon="ArrowLeft" :disabled="!canGoBack" @click="goBack" />
+          <el-button :icon="ArrowRight" :disabled="!canGoForward" @click="goForward" />
+          <el-button :icon="Refresh" :loading="isLoading" @click="reload" />
+        </el-button-group>
+        <div class="url-bar">
+          <el-icon v-if="isLoading" class="loading-icon"><Loading /></el-icon>
+          <el-icon v-else class="lock-icon"><Lock /></el-icon>
+          <span class="url-text">{{ currentUrl }}</span>
+        </div>
+      </div>
+      <div class="toolbar-right">
+        <el-tag :type="statusType" size="small">
+          {{ statusText }}
+        </el-tag>
+        <el-button 
+          v-if="loginStatus === 'success'"
+          type="primary" 
+          size="small"
+          @click="handleSaveAccount"
+          :loading="saving"
+        >
+          保存账号
+        </el-button>
+        <el-button 
+          size="small"
+          @click="handleCheckLogin"
+          :loading="checking"
+        >
+          检测登录
+        </el-button>
+      </div>
+    </div>
+    
+    <!-- 内嵌浏览器 -->
+    <div class="browser-content">
+      <webview
+        ref="webviewRef"
+        :src="initialUrl"
+        :partition="webviewPartition"
+        class="embedded-browser"
+        allowpopups
+        @did-start-loading="handleStartLoading"
+        @did-stop-loading="handleStopLoading"
+        @did-navigate="handleNavigate"
+        @did-navigate-in-page="handleNavigateInPage"
+        @page-title-updated="handleTitleUpdate"
+        @dom-ready="handleDomReady"
+      />
+      
+      <!-- 登录成功遮罩 -->
+      <div v-if="loginStatus === 'success'" class="success-overlay">
+        <div class="success-content">
+          <el-icon class="success-icon"><CircleCheck /></el-icon>
+          <h3>登录成功!</h3>
+          <div v-if="accountInfo" class="account-preview">
+            <el-avatar :size="56" :src="accountInfo.avatarUrl || undefined">
+              {{ accountInfo.accountName?.[0] }}
+            </el-avatar>
+            <div class="account-preview-info">
+              <div class="account-preview-name">{{ accountInfo.accountName }}</div>
+              <div class="account-preview-id">{{ accountInfo.accountId }}</div>
+            </div>
+          </div>
+          <p>点击"保存账号"完成添加</p>
+          <el-button type="primary" @click="handleSaveAccount" :loading="saving">
+            保存账号
+          </el-button>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
+import { ArrowLeft, ArrowRight, Refresh, Loading, Lock, CircleCheck } from '@element-plus/icons-vue';
+import { ElMessage } from 'element-plus';
+import { accountsApi } from '@/api/accounts';
+import { useTabsStore, type Tab } from '@/stores/tabs';
+import { PLATFORMS } from '@media-manager/shared';
+import type { PlatformType } from '@media-manager/shared';
+
+const props = defineProps<{
+  tab: Tab;
+}>();
+
+const emit = defineEmits<{
+  (e: 'accountSaved'): void;
+  (e: 'close'): void;
+}>();
+
+const tabsStore = useTabsStore();
+
+// Refs
+const webviewRef = ref<Electron.WebviewTag | null>(null);
+const isLoading = ref(true);
+const currentUrl = ref('');
+const canGoBack = ref(false);
+const canGoForward = ref(false);
+const saving = ref(false);
+const checking = ref(false);
+const loginStatus = ref<'pending' | 'checking' | 'success' | 'failed'>('pending');
+const accountInfo = ref<{
+  accountId: string;
+  accountName: string;
+  avatarUrl: string;
+  fansCount: number;
+  worksCount: number;
+} | null>(null);
+const cookieData = ref('');
+
+// 计时器和标志位
+let checkTimer: ReturnType<typeof setInterval> | null = null;
+let isVerifying = false; // 防止重复验证
+let hasShownSuccessMessage = false; // 防止重复显示成功消息
+
+// 计算属性
+const platform = computed(() => props.tab.browserData?.platform as PlatformType);
+
+const initialUrl = computed(() => {
+  const platformInfo = PLATFORMS[platform.value];
+  return platformInfo?.loginUrl || 'about:blank';
+});
+
+// 每个标签页使用独立的 partition 来隔离 cookie
+const webviewPartition = computed(() => `persist:browser-${props.tab.id}`);
+
+const statusType = computed(() => {
+  switch (loginStatus.value) {
+    case 'success': return 'success';
+    case 'failed': return 'danger';
+    case 'checking': return 'warning';
+    default: return 'info';
+  }
+});
+
+const statusText = computed(() => {
+  switch (loginStatus.value) {
+    case 'success': return '已登录';
+    case 'failed': return '未登录';
+    case 'checking': return '检测中...';
+    default: return '等待登录';
+  }
+});
+
+// 方法
+function goBack() {
+  webviewRef.value?.goBack();
+}
+
+function goForward() {
+  webviewRef.value?.goForward();
+}
+
+function reload() {
+  webviewRef.value?.reload();
+}
+
+function handleStartLoading() {
+  isLoading.value = true;
+}
+
+function handleStopLoading() {
+  isLoading.value = false;
+  updateNavigation();
+}
+
+function handleNavigate(event: Electron.DidNavigateEvent) {
+  currentUrl.value = event.url;
+  updateNavigation();
+  
+  // 页面跳转后立即检测一次登录状态(加快响应)
+  if (loginStatus.value !== 'success') {
+    setTimeout(() => checkLoginSilently(), 500);
+  }
+}
+
+function handleNavigateInPage(event: Electron.DidNavigateInPageEvent) {
+  currentUrl.value = event.url;
+  updateNavigation();
+}
+
+function handleTitleUpdate(event: Electron.PageTitleUpdatedEvent) {
+  // 更新标签页标题
+  const platformName = PLATFORMS[platform.value]?.name || platform.value;
+  tabsStore.updateTab(props.tab.id, { 
+    title: `${platformName} - ${event.title}` 
+  });
+}
+
+function handleDomReady() {
+  updateNavigation();
+  currentUrl.value = webviewRef.value?.getURL() || '';
+  
+  // 开始自动检测登录状态
+  startAutoCheck();
+}
+
+function updateNavigation() {
+  if (webviewRef.value) {
+    canGoBack.value = webviewRef.value.canGoBack();
+    canGoForward.value = webviewRef.value.canGoForward();
+  }
+}
+
+// 开始自动检测登录状态
+function startAutoCheck() {
+  stopAutoCheck();
+  
+  // 立即执行一次检测
+  checkLoginSilently();
+  
+  // 每 1.5 秒检测一次(加快检测速度)
+  checkTimer = setInterval(() => {
+    if (loginStatus.value !== 'success' && !isVerifying) {
+      checkLoginSilently();
+    } else if (loginStatus.value === 'success') {
+      stopAutoCheck();
+    }
+  }, 1500);
+}
+
+function stopAutoCheck() {
+  if (checkTimer) {
+    clearInterval(checkTimer);
+    checkTimer = null;
+  }
+}
+
+// 检查当前 URL 是否表明已登录成功
+function isLoggedInByUrl(): boolean {
+  const url = currentUrl.value;
+  
+  // 登录页面特征(这些页面不算已登录)
+  const loginPagePatterns: Record<string, RegExp[]> = {
+    douyin: [
+      /creator\.douyin\.com\/?$/,           // 创作者中心首页(登录页)
+      /creator\.douyin\.com\/login/,        // 登录页
+      /passport/,                           // 护照登录
+      /login/i,
+    ],
+    kuaishou: [/passport/, /login/i],
+    xiaohongshu: [/passport/, /login/i],
+    bilibili: [/passport/, /login/i],
+    toutiao: [/passport/, /login/i],
+    baijiahao: [/passport/, /login/i],
+  };
+  
+  // 如果在登录页面,返回 false
+  const loginPatterns = loginPagePatterns[platform.value] || [/login/i, /passport/];
+  if (loginPatterns.some(pattern => pattern.test(url))) {
+    return false;
+  }
+  
+  // 各平台登录成功后的页面特征(必须是登录后才能访问的页面)
+  const loggedInPatterns: Record<string, RegExp[]> = {
+    douyin: [
+      /summon\.bytedance\.com\/web/,              // 创作服务平台(登录后跳转)
+      /creator\.douyin\.com\/creator-micro/,     // 创作者微服务
+      /creator\.douyin\.com\/content/,           // 内容管理
+      /creator\.douyin\.com\/data/,              // 数据中心
+      /douyin\.com\/user\/self/,                 // 个人主页
+    ],
+    kuaishou: [
+      /cp\.kuaishou\.com\/article/,
+      /cp\.kuaishou\.com\/profile/,
+    ],
+    xiaohongshu: [
+      /creator\.xiaohongshu\.com\/publish/,
+      /creator\.xiaohongshu\.com\/creator/,
+    ],
+    bilibili: [
+      /member\.bilibili\.com\/platform/,
+      /space\.bilibili\.com\/\d+/,
+    ],
+    toutiao: [
+      /mp\.toutiao\.com\/profile/,
+    ],
+    baijiahao: [
+      /baijiahao\.baidu\.com\/builder\/rc/,
+    ],
+  };
+  
+  const patterns = loggedInPatterns[platform.value] || [];
+  return patterns.some(pattern => pattern.test(url));
+}
+
+// 静默检测登录
+async function checkLoginSilently() {
+  // 如果已经登录成功或正在验证中,跳过
+  if (loginStatus.value === 'success' || isVerifying) {
+    return;
+  }
+  
+  try {
+    const cookies = await getCookies();
+    if (cookies && cookies.length > 0) {
+      // 简单判断是否有登录相关的 cookie
+      const hasLoginCookie = checkHasLoginCookie(cookies);
+      
+      if (hasLoginCookie && loginStatus.value !== 'success') {
+        // 检查 URL 是否已经在登录后的页面
+        const urlIndicatesLoggedIn = isLoggedInByUrl();
+        
+        if (urlIndicatesLoggedIn) {
+          // URL 表明已登录,快速响应:先显示成功,再异步获取详情
+          await quickLoginSuccess(cookies);
+        } else {
+          // 还在登录页面,通过服务器验证
+          await verifyLoginWithServer(cookies, true);
+        }
+      }
+    }
+  } catch (error) {
+    // 静默失败,不处理
+  }
+}
+
+// 快速登录成功(URL 已表明登录成功时使用)
+async function quickLoginSuccess(cookies: Electron.Cookie[]) {
+  if (loginStatus.value === 'success' || isVerifying) {
+    return;
+  }
+  
+  isVerifying = true;
+  const cookieString = formatCookies(cookies);
+  cookieData.value = cookieString;
+  
+  // 先设置为成功状态,提升用户体验
+  loginStatus.value = 'success';
+  stopAutoCheck();
+  
+  if (!hasShownSuccessMessage) {
+    hasShownSuccessMessage = true;
+    ElMessage.success('检测到登录成功!');
+  }
+  
+  // 异步获取账号详细信息
+  try {
+    const result = await accountsApi.verifyLoginCookie(platform.value, cookieString);
+    if (result.success && result.accountInfo) {
+      accountInfo.value = result.accountInfo;
+    } else {
+      // 即使获取详情失败,也保持登录成功状态
+      accountInfo.value = {
+        accountId: `${platform.value}_${Date.now()}`,
+        accountName: `${PLATFORMS[platform.value]?.name || platform.value}账号`,
+        avatarUrl: '',
+        fansCount: 0,
+        worksCount: 0,
+      };
+    }
+  } catch (error) {
+    // 获取详情失败,使用默认值
+    accountInfo.value = {
+      accountId: `${platform.value}_${Date.now()}`,
+      accountName: `${PLATFORMS[platform.value]?.name || platform.value}账号`,
+      avatarUrl: '',
+      fansCount: 0,
+      worksCount: 0,
+    };
+  } finally {
+    isVerifying = false;
+  }
+}
+
+// 检查是否有登录 cookie
+function checkHasLoginCookie(cookies: Electron.Cookie[]): boolean {
+  // 根据不同平台检查特定的 cookie
+  const platformCookieNames: Record<string, string[]> = {
+    douyin: ['sessionid', 'passport_csrf_token', 'sid_guard'],
+    kuaishou: ['passToken', 'userId'],
+    xiaohongshu: ['web_session', 'xsecappid'],
+    weixin_video: ['wxuin', 'pass_ticket'],
+    bilibili: ['SESSDATA', 'bili_jct'],
+    toutiao: ['sessionid', 'sso_uid'],
+    baijiahao: ['BAIDUID', 'BDUSS'],
+    qie: ['uin', 'skey'],
+    dayuhao: ['cna', 'login_aliyunid'],
+  };
+  
+  const targetCookies = platformCookieNames[platform.value] || [];
+  const cookieNames = cookies.map(c => c.name);
+  
+  return targetCookies.some(name => cookieNames.includes(name));
+}
+
+// 获取 webview 的 cookies
+async function getCookies(): Promise<Electron.Cookie[]> {
+  if (!window.electronAPI?.getWebviewCookies) {
+    console.warn('electronAPI.getWebviewCookies 不可用');
+    return [];
+  }
+  
+  const platformInfo = PLATFORMS[platform.value];
+  if (!platformInfo) return [];
+  
+  // 获取登录域名的 cookies
+  const url = platformInfo.loginUrl;
+  return await window.electronAPI.getWebviewCookies(webviewPartition.value, url);
+}
+
+// 格式化 cookies 为字符串
+function formatCookies(cookies: Electron.Cookie[]): string {
+  return cookies.map(c => `${c.name}=${c.value}`).join('; ');
+}
+
+// 手动检测登录
+async function handleCheckLogin() {
+  checking.value = true;
+  loginStatus.value = 'checking';
+  
+  try {
+    const cookies = await getCookies();
+    if (cookies && cookies.length > 0) {
+      await verifyLoginWithServer(cookies, false); // 手动检测显示消息
+    } else {
+      loginStatus.value = 'failed';
+      ElMessage.warning('未检测到登录状态,请先完成登录');
+    }
+  } catch (error) {
+    loginStatus.value = 'failed';
+    ElMessage.error('检测登录状态失败');
+  } finally {
+    checking.value = false;
+  }
+}
+
+// 通过服务器验证登录
+async function verifyLoginWithServer(cookies: Electron.Cookie[], silent = false) {
+  // 防止重复验证
+  if (isVerifying || loginStatus.value === 'success') {
+    return;
+  }
+  
+  isVerifying = true;
+  const cookieString = formatCookies(cookies);
+  cookieData.value = cookieString;
+  
+  try {
+    // 调用服务器 API 验证并获取账号信息
+    const result = await accountsApi.verifyLoginCookie(platform.value, cookieString);
+    
+    if (result.success && result.accountInfo) {
+      // 再次检查状态,防止并发问题
+      if (loginStatus.value !== 'success') {
+        loginStatus.value = 'success';
+        accountInfo.value = result.accountInfo;
+        stopAutoCheck();
+        
+        // 只显示一次成功消息
+        if (!hasShownSuccessMessage) {
+          hasShownSuccessMessage = true;
+          ElMessage.success('检测到登录成功!');
+        }
+      }
+    } else {
+      if (!silent && loginStatus.value === 'checking') {
+        loginStatus.value = 'failed';
+        ElMessage.warning(result.message || '登录验证失败');
+      }
+    }
+  } catch (error) {
+    if (!silent && loginStatus.value === 'checking') {
+      loginStatus.value = 'failed';
+    }
+  } finally {
+    isVerifying = false;
+  }
+}
+
+// 保存账号
+async function handleSaveAccount() {
+  if (!cookieData.value) {
+    ElMessage.warning('请先完成登录');
+    return;
+  }
+  
+  saving.value = true;
+  try {
+    // 构建账号信息,包含获取到的用户数据
+    const accountData: {
+      platform: string;
+      cookieData: string;
+      groupId?: number;
+      accountInfo?: {
+        accountId?: string;
+        accountName?: string;
+        avatarUrl?: string;
+        fansCount?: number;
+        worksCount?: number;
+      };
+    } = {
+      platform: platform.value,
+      cookieData: cookieData.value,
+      groupId: props.tab.browserData?.groupId,
+    };
+    
+    // 如果有获取到的账号信息,一并传递
+    if (accountInfo.value) {
+      accountData.accountInfo = {
+        accountId: accountInfo.value.accountId,
+        accountName: accountInfo.value.accountName,
+        avatarUrl: accountInfo.value.avatarUrl,
+        fansCount: accountInfo.value.fansCount,
+        worksCount: accountInfo.value.worksCount,
+      };
+    }
+    
+    await accountsApi.addAccount(accountData);
+    
+    ElMessage.success('账号添加成功');
+    emit('accountSaved');
+    
+    // 清除 webview cookies
+    if (window.electronAPI?.clearWebviewCookies) {
+      await window.electronAPI.clearWebviewCookies(webviewPartition.value);
+    }
+    
+    // 关闭标签页
+    tabsStore.closeTab(props.tab.id);
+  } catch (error) {
+    // 错误已处理
+  } finally {
+    saving.value = false;
+  }
+}
+
+// 生命周期
+onMounted(() => {
+  currentUrl.value = initialUrl.value;
+});
+
+onUnmounted(() => {
+  stopAutoCheck();
+  
+  // 清理 webview session
+  if (window.electronAPI?.clearWebviewCookies) {
+    window.electronAPI.clearWebviewCookies(webviewPartition.value);
+  }
+});
+
+// 监听标签页变化
+watch(() => props.tab.id, () => {
+  stopAutoCheck();
+});
+</script>
+
+<style lang="scss" scoped>
+@use '@/styles/variables.scss' as *;
+
+.browser-tab-container {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  background: #fff;
+  margin: -20px;
+  border-radius: $radius-lg;
+  overflow: hidden;
+}
+
+.browser-toolbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 10px 16px;
+  background: linear-gradient(180deg, #fafbfc 0%, #f3f4f6 100%);
+  border-bottom: 1px solid $border-light;
+  gap: 16px;
+  
+  .toolbar-left {
+    display: flex;
+    align-items: center;
+    gap: 14px;
+    flex: 1;
+    min-width: 0;
+    
+    :deep(.el-button-group) {
+      .el-button {
+        border-radius: $radius-base;
+        background: #fff;
+        border-color: $border-light;
+        
+        &:hover:not(:disabled) {
+          background: $primary-color-light;
+          border-color: $primary-color;
+          color: $primary-color;
+        }
+        
+        &:first-child {
+          border-top-right-radius: 0;
+          border-bottom-right-radius: 0;
+        }
+        
+        &:last-child {
+          border-top-left-radius: 0;
+          border-bottom-left-radius: 0;
+        }
+        
+        &:not(:first-child):not(:last-child) {
+          border-radius: 0;
+        }
+      }
+    }
+  }
+  
+  .toolbar-right {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    flex-shrink: 0;
+    
+    .el-button {
+      border-radius: $radius-base;
+      font-weight: 500;
+    }
+    
+    .el-tag {
+      border-radius: 12px;
+      font-weight: 500;
+    }
+  }
+}
+
+.url-bar {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 8px 16px;
+  background: #fff;
+  border: 1px solid $border-light;
+  border-radius: 24px;
+  min-width: 0;
+  box-shadow: $shadow-sm;
+  transition: all 0.2s;
+  
+  &:hover {
+    border-color: $primary-color;
+  }
+  
+  .loading-icon {
+    color: $primary-color;
+    animation: spin 1s linear infinite;
+    font-size: 15px;
+  }
+  
+  .lock-icon {
+    color: $success-color;
+    font-size: 15px;
+  }
+  
+  .url-text {
+    flex: 1;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    font-size: 13px;
+    color: $text-regular;
+  }
+}
+
+.browser-content {
+  flex: 1;
+  position: relative;
+  overflow: hidden;
+  background: #fff;
+}
+
+.embedded-browser {
+  width: 100%;
+  height: 100%;
+  border: none;
+}
+
+.success-overlay {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(255, 255, 255, 0.98);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 10;
+  backdrop-filter: blur(10px);
+}
+
+.success-content {
+  text-align: center;
+  padding: 48px;
+  max-width: 400px;
+  
+  .success-icon {
+    font-size: 72px;
+    color: $success-color;
+    margin-bottom: 20px;
+    filter: drop-shadow(0 4px 12px rgba(82, 196, 26, 0.3));
+  }
+  
+  h3 {
+    margin: 0 0 24px;
+    font-size: 22px;
+    font-weight: 600;
+    color: $text-primary;
+  }
+  
+  p {
+    margin: 20px 0;
+    color: $text-secondary;
+    font-size: 14px;
+  }
+  
+  .el-button {
+    min-width: 140px;
+    height: 40px;
+    font-size: 15px;
+    font-weight: 500;
+    border-radius: $radius-base;
+  }
+}
+
+.account-preview {
+  display: flex;
+  align-items: center;
+  gap: 18px;
+  padding: 20px 28px;
+  background: $primary-color-light;
+  border-radius: $radius-lg;
+  margin: 24px 0;
+  border: 1px solid $border-light;
+  
+  :deep(.el-avatar) {
+    border: 3px solid #fff;
+    box-shadow: $shadow-sm;
+    background: linear-gradient(135deg, $primary-color-light, #fff);
+    color: $primary-color;
+    font-weight: 600;
+  }
+  
+  .account-preview-info {
+    text-align: left;
+    
+    .account-preview-name {
+      font-weight: 600;
+      font-size: 18px;
+      color: $text-primary;
+    }
+    
+    .account-preview-id {
+      font-size: 13px;
+      color: $text-secondary;
+      margin-top: 6px;
+    }
+  }
+}
+
+@keyframes spin {
+  from { transform: rotate(0deg); }
+  to { transform: rotate(360deg); }
+}
+</style>

+ 63 - 39
client/src/components/TaskProgressDialog.vue

@@ -233,12 +233,14 @@ function handleClearCompleted() {
 </template>
 
 <style scoped lang="scss">
+@use '@/styles/variables.scss' as *;
+
 .task-dialog-content {
   min-height: 100px;
 }
 
 .task-group {
-  margin-bottom: 16px;
+  margin-bottom: 20px;
 
   &:last-child {
     margin-bottom: 0;
@@ -248,42 +250,57 @@ function handleClearCompleted() {
 .task-group-title {
   display: flex;
   align-items: center;
-  gap: 6px;
+  gap: 8px;
   font-size: 13px;
-  font-weight: 500;
-  color: var(--el-text-color-secondary);
-  margin-bottom: 8px;
-  padding-bottom: 6px;
-  border-bottom: 1px solid var(--el-border-color-lighter);
+  font-weight: 600;
+  color: $text-secondary;
+  margin-bottom: 12px;
+  padding-bottom: 8px;
+  border-bottom: 1px solid $border-light;
 
   .spin-icon {
     width: 14px;
     height: 14px;
+    color: $primary-color;
     animation: spin 1s linear infinite;
   }
 }
 
 .task-item {
-  padding: 12px;
-  border-radius: 8px;
-  background: var(--el-fill-color-light);
-  margin-bottom: 8px;
+  padding: 14px 16px;
+  border-radius: $radius-lg;
+  background: $bg-base;
+  margin-bottom: 10px;
+  border: 1px solid $border-light;
+  transition: all 0.2s;
 
   &:last-child {
     margin-bottom: 0;
   }
 
   &.running {
-    background: linear-gradient(135deg, #e6f4ff 0%, #f0f5ff 100%);
-    border: 1px solid var(--el-color-primary-light-5);
+    background: $primary-color-light;
+    border-color: rgba($primary-color, 0.2);
+    
+    .task-icon {
+      color: $primary-color;
+    }
+  }
+  
+  &.pending {
+    background: $bg-base;
+    
+    .task-icon {
+      color: $text-secondary;
+    }
   }
 
   &.completed {
-    opacity: 0.8;
+    background: #fff;
 
     &.is-failed {
-      background: #fff2f0;
-      border: 1px solid #ffccc7;
+      background: $danger-color-light;
+      border-color: rgba($danger-color, 0.2);
     }
   }
 }
@@ -292,24 +309,25 @@ function handleClearCompleted() {
   display: flex;
   justify-content: space-between;
   align-items: center;
-  margin-bottom: 8px;
+  margin-bottom: 10px;
 }
 
 .task-info {
   display: flex;
   align-items: center;
-  gap: 8px;
+  gap: 10px;
 }
 
 .task-icon {
-  width: 18px;
-  height: 18px;
-  color: var(--el-color-primary);
+  width: 20px;
+  height: 20px;
+  color: $primary-color;
 }
 
 .task-title {
   font-size: 14px;
-  font-weight: 500;
+  font-weight: 600;
+  color: $text-primary;
 }
 
 .task-actions {
@@ -319,39 +337,49 @@ function handleClearCompleted() {
 }
 
 .task-progress {
-  margin-bottom: 6px;
+  margin-bottom: 8px;
+  
+  :deep(.el-progress-bar__outer) {
+    background: rgba(0, 0, 0, 0.06);
+    border-radius: 4px;
+  }
+  
+  :deep(.el-progress-bar__inner) {
+    background: linear-gradient(90deg, $primary-color, #64b5f6);
+    border-radius: 4px;
+  }
 }
 
 .task-step {
   font-size: 12px;
-  color: var(--el-text-color-secondary);
+  color: $text-secondary;
 }
 
 .task-result {
   display: flex;
   align-items: center;
-  gap: 6px;
+  gap: 8px;
   font-size: 13px;
-  color: var(--el-text-color-regular);
+  color: $text-regular;
 
   .result-icon {
-    width: 14px;
-    height: 14px;
+    width: 16px;
+    height: 16px;
 
     &.success {
-      color: var(--el-color-success);
+      color: $success-color;
     }
 
     &.failed {
-      color: var(--el-color-danger);
+      color: $danger-color;
     }
   }
 }
 
 .task-meta {
   font-size: 12px;
-  color: var(--el-text-color-placeholder);
-  margin-top: 4px;
+  color: $text-placeholder;
+  margin-top: 6px;
 }
 
 .dialog-footer {
@@ -362,15 +390,11 @@ function handleClearCompleted() {
 
 .footer-info {
   font-size: 13px;
-  color: var(--el-text-color-secondary);
+  color: $text-secondary;
 }
 
 @keyframes spin {
-  from {
-    transform: rotate(0deg);
-  }
-  to {
-    transform: rotate(360deg);
-  }
+  from { transform: rotate(0deg); }
+  to { transform: rotate(360deg); }
 }
 </style>

+ 69 - 0
client/src/components/icons/index.vue

@@ -0,0 +1,69 @@
+<template>
+  <!-- 折叠图标 -->
+  <svg v-if="name === 'fold'" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
+    <path fill="currentColor" d="M896 192H128a64 64 0 0 0-64 64v512a64 64 0 0 0 64 64h768a64 64 0 0 0 64-64V256a64 64 0 0 0-64-64zm-32 544H320V288h544v448z"/>
+    <path fill="currentColor" d="M416 480 288 352v256l128-128z"/>
+  </svg>
+  
+  <!-- 展开图标 -->
+  <svg v-else-if="name === 'expand'" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
+    <path fill="currentColor" d="M128 192h768a64 64 0 0 1 64 64v512a64 64 0 0 1-64 64H128a64 64 0 0 1-64-64V256a64 64 0 0 1 64-64zm32 64v448h544V256H160z"/>
+    <path fill="currentColor" d="M608 480 736 352v256L608 480z"/>
+  </svg>
+  
+  <!-- 文档列表图标 (任务) -->
+  <svg v-else-if="name === 'document-copy'" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
+    <path fill="currentColor" d="M128 320v576h576V320H128zm-32-64h640a32 32 0 0 1 32 32v640a32 32 0 0 1-32 32H96a32 32 0 0 1-32-32V288a32 32 0 0 1 32-32z"/>
+    <path fill="currentColor" d="M960 128v576h-64V160H320v-64h608a32 32 0 0 1 32 32z"/>
+    <path fill="currentColor" d="M256 672h320v64H256zm0-192h320v64H256z"/>
+  </svg>
+  
+  <!-- 消息图标 -->
+  <svg v-else-if="name === 'message'" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
+    <path fill="currentColor" d="M128 224v512a64 64 0 0 0 64 64h640a64 64 0 0 0 64-64V224a64 64 0 0 0-64-64H192a64 64 0 0 0-64 64zm0-64h768a128 128 0 0 1 128 128v512a128 128 0 0 1-128 128H192A128 128 0 0 1 64 800V288a128 128 0 0 1 128-128z"/>
+    <path fill="currentColor" d="M789.504 199.04 511.616 450.944 233.728 199.04a32 32 0 1 0-43.456 46.912l307.2 279.04a32 32 0 0 0 43.136 0l307.2-279.04a32 32 0 0 0-43.456-46.912z"/>
+  </svg>
+  
+  <!-- 左箭头 -->
+  <svg v-else-if="name === 'arrow-left'" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
+    <path fill="currentColor" d="M609.408 149.376 277.76 489.6a32 32 0 0 0 0 44.672l331.648 340.352a29.12 29.12 0 0 0 41.728 0 30.592 30.592 0 0 0 0-42.752L339.264 511.936l311.872-319.872a30.592 30.592 0 0 0 0-42.688 29.12 29.12 0 0 0-41.728 0z"/>
+  </svg>
+  
+  <!-- 右箭头 -->
+  <svg v-else-if="name === 'arrow-right'" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
+    <path fill="currentColor" d="M340.864 149.312a30.592 30.592 0 0 0 0 42.752L652.736 512 340.864 831.872a30.592 30.592 0 0 0 0 42.752 29.12 29.12 0 0 0 41.728 0L714.24 534.336a32 32 0 0 0 0-44.672L382.592 149.376a29.12 29.12 0 0 0-41.728 0z"/>
+  </svg>
+  
+  <!-- 加载中 -->
+  <svg v-else-if="name === 'loading'" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" class="is-loading">
+    <path fill="currentColor" d="M512 64a32 32 0 0 1 32 32v192a32 32 0 0 1-64 0V96a32 32 0 0 1 32-32zm0 640a32 32 0 0 1 32 32v192a32 32 0 1 1-64 0V736a32 32 0 0 1 32-32zm448-192a32 32 0 0 1-32 32H736a32 32 0 1 1 0-64h192a32 32 0 0 1 32 32zM192 512a32 32 0 0 1-32 32H96a32 32 0 0 1 0-64h64a32 32 0 0 1 32 32zm617.728 256.512a32 32 0 0 1-45.248 45.248l-135.872-135.872a32 32 0 0 1 45.248-45.248l135.872 135.872zM214.272 348.16a32 32 0 0 1-45.248 45.248l-135.872-135.872a32 32 0 0 1 45.248-45.248l135.872 135.872zm0 460.352-135.872 135.872a32 32 0 0 1-45.248-45.248l135.872-135.872a32 32 0 0 1 45.248 45.248zm617.728-506.752-135.872 135.872a32 32 0 0 1-45.248-45.248l135.872-135.872a32 32 0 0 1 45.248 45.248z"/>
+  </svg>
+  
+  <!-- 默认空白 -->
+  <svg v-else viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
+    <rect width="1024" height="1024" fill="transparent"/>
+  </svg>
+</template>
+
+<script setup lang="ts">
+defineProps<{
+  name: string;
+}>();
+</script>
+
+<style scoped>
+svg {
+  width: 1em;
+  height: 1em;
+  fill: currentColor;
+}
+
+.is-loading {
+  animation: rotating 2s linear infinite;
+}
+
+@keyframes rotating {
+  from { transform: rotate(0deg); }
+  to { transform: rotate(360deg); }
+}
+</style>

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 915 - 129
client/src/layouts/MainLayout.vue


+ 4 - 4
client/src/main.ts

@@ -11,10 +11,10 @@ import router from './router';
 
 const app = createApp(App);
 
-// 注册所有图标
-for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
-  app.component(key, component);
-}
+// 注册所有 Element Plus 图标组件
+Object.entries(ElementPlusIconsVue).forEach(([key, component]) => {
+  app.component(key, component as any);
+});
 
 app.use(createPinia());
 app.use(router);

+ 266 - 0
client/src/stores/tabs.ts

@@ -0,0 +1,266 @@
+import { defineStore } from 'pinia';
+import { ref, computed, markRaw, type Component } from 'vue';
+
+// 标签页类型
+export type TabType = 'page' | 'browser';
+
+// 标签页接口
+export interface Tab {
+  id: string;
+  title: string;
+  type: TabType;
+  // 页面类型标签页的路由路径
+  path?: string;
+  // 页面组件(用于缓存)
+  component?: Component;
+  // 浏览器类型标签页的数据
+  browserData?: {
+    platform: string;
+    sessionId?: string;
+    url?: string;
+    status?: 'loading' | 'ready' | 'login_pending' | 'login_success' | 'login_failed';
+    groupId?: number;
+  };
+  // 是否可关闭
+  closable?: boolean;
+  // 图标
+  icon?: string;
+}
+
+// 页面路由配置
+export const PAGE_CONFIG: Record<string, { title: string; icon: string }> = {
+  '/': { title: '数据看板', icon: 'DataAnalysis' },
+  '/accounts': { title: '账号管理', icon: 'User' },
+  '/works': { title: '作品管理', icon: 'Film' },
+  '/publish': { title: '发布管理', icon: 'Upload' },
+  '/comments': { title: '评论管理', icon: 'ChatDotRound' },
+  '/schedule': { title: '定时任务', icon: 'Clock' },
+  '/analytics': { title: '数据分析', icon: 'TrendCharts' },
+  '/settings': { title: '系统设置', icon: 'Setting' },
+  '/profile': { title: '个人中心', icon: 'User' },
+};
+
+export const useTabsStore = defineStore('tabs', () => {
+  // 所有标签页
+  const tabs = ref<Tab[]>([]);
+  
+  // 当前激活的标签页ID
+  const activeTabId = ref<string>('');
+  
+  // 账号列表刷新计数器(当计数器变化时,Accounts 页面会刷新)
+  const accountRefreshTrigger = ref(0);
+  
+  // 当前激活的标签页
+  const activeTab = computed(() => 
+    tabs.value.find(tab => tab.id === activeTabId.value)
+  );
+  
+  // 浏览器类型的标签页
+  const browserTabs = computed(() => 
+    tabs.value.filter(tab => tab.type === 'browser')
+  );
+  
+  // 页面类型的标签页
+  const pageTabs = computed(() => 
+    tabs.value.filter(tab => tab.type === 'page')
+  );
+  
+  // 生成唯一ID
+  function generateId(): string {
+    return `tab_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
+  }
+  
+  // 添加标签页
+  function addTab(tab: Omit<Tab, 'id'> & { id?: string }): Tab {
+    const newTab: Tab = {
+      ...tab,
+      id: tab.id || generateId(),
+      closable: tab.closable ?? true,
+    };
+    
+    // 检查是否已存在相同路径的页面标签
+    if (tab.type === 'page' && tab.path) {
+      const existingTab = tabs.value.find(t => t.type === 'page' && t.path === tab.path);
+      if (existingTab) {
+        activeTabId.value = existingTab.id;
+        return existingTab;
+      }
+    }
+    
+    tabs.value.push(newTab);
+    activeTabId.value = newTab.id;
+    
+    return newTab;
+  }
+  
+  // 打开页面标签页
+  function openPageTab(path: string, component?: Component): Tab {
+    // 检查是否已存在
+    const existingTab = tabs.value.find(t => t.type === 'page' && t.path === path);
+    if (existingTab) {
+      activeTabId.value = existingTab.id;
+      return existingTab;
+    }
+    
+    const config = PAGE_CONFIG[path] || { title: '未知页面', icon: 'Document' };
+    
+    const tab = addTab({
+      title: config.title,
+      type: 'page',
+      path,
+      component: component ? markRaw(component) : undefined,
+      icon: config.icon,
+      // 首页不可关闭
+      closable: path !== '/',
+    });
+    
+    return tab;
+  }
+  
+  // 打开浏览器标签页
+  function openBrowserTab(platform: string, title?: string, groupId?: number): Tab {
+    const tab = addTab({
+      title: title || `${platform} 浏览器`,
+      type: 'browser',
+      browserData: {
+        platform,
+        status: 'loading',
+        groupId,
+      },
+      icon: 'Monitor',
+    });
+    
+    return tab;
+  }
+  
+  // 更新标签页
+  function updateTab(id: string, updates: Partial<Tab>) {
+    const index = tabs.value.findIndex(tab => tab.id === id);
+    if (index !== -1) {
+      tabs.value[index] = { ...tabs.value[index], ...updates };
+    }
+  }
+  
+  // 更新浏览器标签页数据
+  function updateBrowserTab(id: string, browserData: Partial<Tab['browserData']>) {
+    const tab = tabs.value.find(t => t.id === id);
+    if (tab && tab.browserData) {
+      tab.browserData = { ...tab.browserData, ...browserData };
+    }
+  }
+  
+  // 关闭标签页
+  function closeTab(id: string) {
+    const index = tabs.value.findIndex(tab => tab.id === id);
+    if (index === -1) return;
+    
+    const tab = tabs.value[index];
+    if (!tab.closable) return;
+    
+    tabs.value.splice(index, 1);
+    
+    // 如果关闭的是当前激活的标签页,激活相邻的标签页
+    if (activeTabId.value === id) {
+      if (tabs.value.length > 0) {
+        // 激活前一个或后一个标签页
+        const newIndex = Math.min(index, tabs.value.length - 1);
+        activeTabId.value = tabs.value[newIndex].id;
+      } else {
+        activeTabId.value = '';
+      }
+    }
+  }
+  
+  // 关闭所有标签页
+  function closeAllTabs() {
+    tabs.value = tabs.value.filter(tab => !tab.closable);
+    if (tabs.value.length > 0) {
+      activeTabId.value = tabs.value[0].id;
+    } else {
+      activeTabId.value = '';
+    }
+  }
+  
+  // 关闭其他标签页
+  function closeOtherTabs(id: string) {
+    tabs.value = tabs.value.filter(tab => tab.id === id || !tab.closable);
+    activeTabId.value = id;
+  }
+  
+  // 关闭右侧标签页
+  function closeRightTabs(id: string) {
+    const index = tabs.value.findIndex(tab => tab.id === id);
+    if (index === -1) return;
+    
+    tabs.value = tabs.value.filter((tab, i) => i <= index || !tab.closable);
+    
+    // 如果当前激活的标签页被关闭了,激活指定的标签页
+    if (!tabs.value.find(t => t.id === activeTabId.value)) {
+      activeTabId.value = id;
+    }
+  }
+  
+  // 激活标签页
+  function activateTab(id: string) {
+    if (tabs.value.find(tab => tab.id === id)) {
+      activeTabId.value = id;
+    }
+  }
+  
+  // 通过路径激活标签页
+  function activateTabByPath(path: string): boolean {
+    const tab = tabs.value.find(t => t.type === 'page' && t.path === path);
+    if (tab) {
+      activeTabId.value = tab.id;
+      return true;
+    }
+    return false;
+  }
+  
+  // 通过浏览器会话ID查找标签页
+  function findTabBySessionId(sessionId: string): Tab | undefined {
+    return tabs.value.find(
+      tab => tab.type === 'browser' && tab.browserData?.sessionId === sessionId
+    );
+  }
+  
+  // 获取当前激活的页面路径
+  function getActivePagePath(): string | undefined {
+    const tab = activeTab.value;
+    if (tab?.type === 'page') {
+      return tab.path;
+    }
+    return undefined;
+  }
+  
+  // 触发账号列表刷新
+  function triggerAccountRefresh() {
+    accountRefreshTrigger.value++;
+  }
+  
+  return {
+    // 状态
+    tabs,
+    activeTabId,
+    accountRefreshTrigger,
+    // 计算属性
+    activeTab,
+    browserTabs,
+    pageTabs,
+    // 方法
+    addTab,
+    openPageTab,
+    openBrowserTab,
+    updateTab,
+    updateBrowserTab,
+    closeTab,
+    closeAllTabs,
+    closeOtherTabs,
+    closeRightTabs,
+    activateTab,
+    activateTabByPath,
+    findTabBySessionId,
+    getActivePagePath,
+    triggerAccountRefresh,
+  };
+});

+ 9 - 0
client/src/stores/taskQueue.ts

@@ -13,6 +13,9 @@ export const useTaskQueueStore = defineStore('taskQueue', () => {
   const ws = ref<WebSocket | null>(null);
   const wsConnected = ref(false);
   
+  // 作品刷新信号(当 sync_works 任务完成时递增)
+  const worksRefreshTrigger = ref(0);
+  
   // 验证码相关状态
   const showCaptchaDialog = ref(false);
   const captchaTaskId = ref('');
@@ -141,6 +144,11 @@ export const useTaskQueueStore = defineStore('taskQueue', () => {
         const task = data.payload?.task as Task;
         if (task) {
           updateTask(task);
+          // 如果是 sync_works 任务完成,触发作品列表刷新
+          if (event === 'completed' && task.type === 'sync_works') {
+            worksRefreshTrigger.value++;
+            console.log('[TaskQueue] sync_works completed, triggering refresh');
+          }
         }
         break;
       }
@@ -338,6 +346,7 @@ export const useTaskQueueStore = defineStore('taskQueue', () => {
     tasks,
     isDialogVisible,
     wsConnected,
+    worksRefreshTrigger,
     // 验证码状态
     showCaptchaDialog,
     captchaTaskId,

+ 68 - 18
client/src/styles/index.scss

@@ -8,32 +8,33 @@ html, body {
   margin: 0;
   padding: 0;
   height: 100%;
-  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+  font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
   -webkit-font-smoothing: antialiased;
   -moz-osx-font-smoothing: grayscale;
+  background: $bg-page;
+  color: $text-primary;
 }
 
 #app {
   height: 100%;
 }
 
-// 滚动条样式
+// 滚动条样式 - 更精致
 ::-webkit-scrollbar {
-  width: 8px;
-  height: 8px;
+  width: 6px;
+  height: 6px;
 }
 
 ::-webkit-scrollbar-track {
-  background: #f1f1f1;
-  border-radius: 4px;
+  background: transparent;
 }
 
 ::-webkit-scrollbar-thumb {
-  background: #c1c1c1;
-  border-radius: 4px;
+  background: #d1d5db;
+  border-radius: 3px;
   
   &:hover {
-    background: #a1a1a1;
+    background: #9ca3af;
   }
 }
 
@@ -53,7 +54,7 @@ html, body {
 // 页面过渡动画
 .fade-enter-active,
 .fade-leave-active {
-  transition: opacity 0.3s ease;
+  transition: opacity 0.2s ease;
 }
 
 .fade-enter-from,
@@ -61,12 +62,13 @@ html, body {
   opacity: 0;
 }
 
-// 卡片样式
+// 卡片样式 - 更精致
 .page-card {
   background: #fff;
-  border-radius: 8px;
-  padding: 20px;
-  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
+  border-radius: $radius-lg;
+  padding: 24px;
+  box-shadow: $shadow-sm;
+  border: 1px solid $border-light;
 }
 
 // 统计数据样式
@@ -76,12 +78,60 @@ html, body {
   .stat-value {
     font-size: 28px;
     font-weight: 600;
-    color: $primary-color;
+    color: $text-primary;
   }
   
   .stat-label {
-    font-size: 14px;
-    color: #666;
-    margin-top: 8px;
+    font-size: 13px;
+    color: $text-secondary;
+    margin-top: 4px;
   }
 }
+
+// Element Plus 样式覆盖
+.el-button {
+  border-radius: $radius-base !important;
+  font-weight: 500;
+}
+
+.el-input {
+  --el-input-border-radius: #{$radius-base};
+}
+
+.el-select {
+  --el-select-border-radius: #{$radius-base};
+}
+
+.el-card {
+  border-radius: $radius-lg !important;
+  border: 1px solid $border-light !important;
+  box-shadow: $shadow-sm !important;
+}
+
+.el-dialog {
+  border-radius: $radius-xl !important;
+  overflow: hidden;
+}
+
+.el-message-box {
+  border-radius: $radius-lg !important;
+}
+
+.el-tag {
+  border-radius: $radius-sm !important;
+}
+
+.el-table {
+  --el-table-border-color: #{$border-light};
+  --el-table-header-bg-color: #{$bg-base};
+  
+  th.el-table__cell {
+    font-weight: 600;
+    color: $text-primary;
+  }
+}
+
+// 表格行悬停
+.el-table__row:hover > td.el-table__cell {
+  background: $primary-color-light !important;
+}

+ 52 - 21
client/src/styles/variables.scss

@@ -1,34 +1,65 @@
-// 主题颜色
-$primary-color: #409eff;
-$success-color: #67c23a;
-$warning-color: #e6a23c;
-$danger-color: #f56c6c;
-$info-color: #909399;
+// 主题颜色 - 精致简约浅色系
+$primary-color: #4f8cff;
+$primary-color-light: #e8f1ff;
+$primary-color-dark: #3a7bd5;
+$success-color: #52c41a;
+$success-color-light: #f6ffed;
+$warning-color: #faad14;
+$warning-color-light: #fffbe6;
+$danger-color: #ff4d4f;
+$danger-color-light: #fff2f0;
+$info-color: #8c8c8c;
+
+// 品牌渐变色
+$gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+$gradient-success: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
+$gradient-info: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
+$gradient-warm: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
 
 // 文字颜色
-$text-primary: #303133;
-$text-regular: #606266;
-$text-secondary: #909399;
-$text-placeholder: #c0c4cc;
+$text-primary: #1f2937;
+$text-regular: #4b5563;
+$text-secondary: #9ca3af;
+$text-placeholder: #d1d5db;
 
 // 边框颜色
-$border-base: #dcdfe6;
-$border-light: #e4e7ed;
-$border-lighter: #ebeef5;
+$border-base: #e5e7eb;
+$border-light: #f3f4f6;
+$border-lighter: #f9fafb;
 
 // 背景颜色
-$bg-base: #f5f7fa;
-$bg-light: #fafafa;
+$bg-base: #f8fafc;
+$bg-light: #ffffff;
+$bg-page: #f1f5f9;
 
-// 侧边栏
-$sidebar-width: 220px;
+// 侧边栏 - 浅色优雅风格
+$sidebar-width: 200px;
 $sidebar-collapsed-width: 64px;
-$sidebar-bg: #001529;
-$sidebar-menu-active-bg: $primary-color;
+$sidebar-bg: #ffffff;
+$sidebar-menu-active-bg: $primary-color-light;
+$sidebar-text-color: #64748b;
+$sidebar-active-text-color: $primary-color;
 
 // 头部
-$header-height: 60px;
-$header-bg: #fff;
+$header-height: 48px;
+$header-bg: #ffffff;
 
 // 布局
 $content-padding: 20px;
+
+// 标签页面板
+$tabs-panel-width: 480px;
+$tabs-panel-min-width: 360px;
+$tabs-panel-max-width: 640px;
+
+// 阴影
+$shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
+$shadow-base: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
+$shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
+$shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
+
+// 圆角
+$radius-sm: 6px;
+$radius-base: 8px;
+$radius-lg: 12px;
+$radius-xl: 16px;

+ 164 - 377
client/src/views/Accounts/index.vue

@@ -7,7 +7,7 @@
           <el-icon><Refresh /></el-icon>
           刷新所有
         </el-button>
-        <el-button type="primary" @click="showBrowserLoginDialog = true">
+        <el-button type="primary" @click="showPlatformSelect = true">
           <el-icon><Monitor /></el-icon>
           浏览器登录
         </el-button>
@@ -161,172 +161,57 @@
       </template>
     </el-dialog>
     
-    <!-- 浏览器登录对话框 -->
+    <!-- 平台选择对话框 -->
     <el-dialog 
-      v-model="showBrowserLoginDialog" 
-      title="浏览器登录" 
-      width="500px"
-      :close-on-click-modal="false"
-      @closed="resetBrowserLogin"
+      v-model="showPlatformSelect" 
+      title="选择登录平台" 
+      width="450px"
+      :close-on-click-modal="true"
     >
-      <template v-if="!browserLoginSession.sessionId">
-        <el-form :model="browserLoginForm" label-width="80px">
-          <el-form-item label="平台">
-            <el-select v-model="browserLoginForm.platform" placeholder="选择要登录的平台" style="width: 100%">
-              <el-option
-                v-for="platform in platforms"
-                :key="platform.type"
-                :label="platform.name"
-                :value="platform.type"
-                :disabled="!platform.supported"
-              >
-                <span class="platform-option">
-                  <span>{{ platform.name }}</span>
-                  <el-tag v-if="!platform.supported" size="small" type="info">适配中</el-tag>
-                </span>
-              </el-option>
-            </el-select>
-          </el-form-item>
-          <el-form-item label="分组">
-            <el-select v-model="browserLoginForm.groupId" placeholder="选择分组" clearable style="width: 100%">
-              <el-option
-                v-for="group in groups"
-                :key="group.id"
-                :label="group.name"
-                :value="group.id"
-              />
-            </el-select>
-          </el-form-item>
-        </el-form>
-        
-        <el-alert type="info" :closable="false" style="margin-top: 10px;">
-          <template #title>
-            点击"开始登录"后会自动打开浏览器窗口,请在浏览器中完成平台登录,登录成功后系统会自动获取Cookie。
-          </template>
-        </el-alert>
-      </template>
+      <el-form :model="browserLoginForm" label-width="80px">
+        <el-form-item label="平台">
+          <el-select v-model="browserLoginForm.platform" placeholder="选择要登录的平台" style="width: 100%">
+            <el-option
+              v-for="platform in platforms"
+              :key="platform.type"
+              :label="platform.name"
+              :value="platform.type"
+              :disabled="!platform.supported"
+            >
+              <span class="platform-option">
+                <span>{{ platform.name }}</span>
+                <el-tag v-if="!platform.supported" size="small" type="info">适配中</el-tag>
+              </span>
+            </el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item label="分组">
+          <el-select v-model="browserLoginForm.groupId" placeholder="选择分组" clearable style="width: 100%">
+            <el-option
+              v-for="group in groups"
+              :key="group.id"
+              :label="group.name"
+              :value="group.id"
+            />
+          </el-select>
+        </el-form-item>
+      </el-form>
       
-      <template v-else>
-        <div class="browser-login-status">
-          <el-icon 
-            class="status-icon" 
-            :class="browserLoginSession.status"
-            v-if="browserLoginSession.status === 'pending' || browserLoginSession.status === 'fetching'"
-          >
-            <Loading />
-          </el-icon>
-          <el-icon 
-            class="status-icon success" 
-            v-else-if="browserLoginSession.status === 'success'"
-          >
-            <CircleCheck />
-          </el-icon>
-          <el-icon 
-            class="status-icon error" 
-            v-else
-          >
-            <CircleClose />
-          </el-icon>
-          
-          <div class="status-text">
-            <template v-if="browserLoginSession.status === 'pending'">
-              <h3>等待登录中...</h3>
-              <p>请在打开的浏览器窗口中完成 {{ getPlatformName(browserLoginForm.platform as PlatformType) }} 登录</p>
-              <div class="login-tips">
-                <el-text type="info" size="small">
-                  <el-icon><InfoFilled /></el-icon>
-                  扫码或输入账号密码登录后会自动检测
-                </el-text>
-              </div>
-            </template>
-            <template v-else-if="browserLoginSession.status === 'fetching'">
-              <h3>
-                <span class="fetching-text">正在获取账号信息</span>
-                <span class="fetching-dots"></span>
-              </h3>
-              <div class="fetching-progress">
-                <el-progress :percentage="fetchingProgress" :show-text="false" :stroke-width="4" />
-              </div>
-              <p class="fetching-hint">浏览器已关闭,正在后台静默获取数据</p>
-              <div class="fetching-steps">
-                <div class="step" :class="{ active: fetchingStep >= 1, done: fetchingStep > 1 }">
-                  <el-icon v-if="fetchingStep > 1"><CircleCheck /></el-icon>
-                  <el-icon v-else-if="fetchingStep === 1"><Loading /></el-icon>
-                  <span v-else>1</span>
-                  <span class="step-text">验证登录</span>
-                </div>
-                <div class="step" :class="{ active: fetchingStep >= 2, done: fetchingStep > 2 }">
-                  <el-icon v-if="fetchingStep > 2"><CircleCheck /></el-icon>
-                  <el-icon v-else-if="fetchingStep === 2"><Loading /></el-icon>
-                  <span v-else>2</span>
-                  <span class="step-text">获取资料</span>
-                </div>
-                <div class="step" :class="{ active: fetchingStep >= 3, done: fetchingStep > 3 }">
-                  <el-icon v-if="fetchingStep > 3"><CircleCheck /></el-icon>
-                  <el-icon v-else-if="fetchingStep === 3"><Loading /></el-icon>
-                  <span v-else>3</span>
-                  <span class="step-text">获取作品</span>
-                </div>
-              </div>
-            </template>
-            <template v-else-if="browserLoginSession.status === 'success'">
-              <h3>登录成功!</h3>
-              <div v-if="browserLoginSession.accountInfo" class="account-preview">
-                <el-avatar :size="48" :src="browserLoginSession.accountInfo.avatarUrl || undefined">
-                  {{ browserLoginSession.accountInfo.accountName?.[0] }}
-                </el-avatar>
-                <div class="account-preview-info">
-                  <div class="account-preview-name">{{ browserLoginSession.accountInfo.accountName }}</div>
-                  <div class="account-preview-stats">
-                    <span>粉丝: {{ formatNumber(browserLoginSession.accountInfo.fansCount) }}</span>
-                    <span v-if="browserLoginSession.accountInfo.worksCount">
-                      · 作品: {{ browserLoginSession.accountInfo.worksCount }}
-                    </span>
-                  </div>
-                </div>
-              </div>
-              <p>点击"保存账号"完成添加</p>
-            </template>
-            <template v-else>
-              <h3>登录失败</h3>
-              <p>{{ browserLoginSession.error || '未知错误' }}</p>
-            </template>
-          </div>
-        </div>
-      </template>
+      <el-alert type="info" :closable="false" style="margin-top: 10px;">
+        <template #title>
+          点击"开始登录"后会在右侧标签页中打开浏览器登录界面,登录成功后系统会自动获取Cookie。
+        </template>
+      </el-alert>
       
       <template #footer>
-        <template v-if="!browserLoginSession.sessionId">
-          <el-button @click="showBrowserLoginDialog = false">取消</el-button>
-          <el-button 
-            type="primary" 
-            @click="startBrowserLogin" 
-            :loading="browserLoginLoading"
-            :disabled="!browserLoginForm.platform"
-          >
-            开始登录
-          </el-button>
-        </template>
-        <template v-else>
-          <el-button @click="cancelBrowserLogin" :disabled="browserLoginLoading">
-            取消登录
-          </el-button>
-          <el-button 
-            v-if="browserLoginSession.status === 'success'"
-            type="primary" 
-            @click="confirmBrowserLogin"
-            :loading="browserLoginLoading"
-          >
-            保存账号
-          </el-button>
-          <el-button 
-            v-else-if="browserLoginSession.status !== 'pending'"
-            type="primary" 
-            @click="resetBrowserLogin"
-          >
-            重试
-          </el-button>
-        </template>
+        <el-button @click="showPlatformSelect = false">取消</el-button>
+        <el-button 
+          type="primary" 
+          @click="startBrowserLogin" 
+          :disabled="!browserLoginForm.platform"
+        >
+          开始登录
+        </el-button>
       </template>
     </el-dialog>
     
@@ -429,14 +314,16 @@
 
 <script setup lang="ts">
 import { ref, reactive, onMounted, onUnmounted, watch } from 'vue';
-import { Plus, Refresh, Monitor, Loading, CircleCheck, CircleClose, InfoFilled } from '@element-plus/icons-vue';
+import { Plus, Refresh, Monitor, Loading, CircleCheck, CircleClose } from '@element-plus/icons-vue';
 import { ElMessage, ElMessageBox } from 'element-plus';
 import { accountsApi } from '@/api/accounts';
 import { PLATFORMS, PLATFORM_TYPES } from '@media-manager/shared';
 import type { PlatformAccount, AccountGroup, PlatformType } from '@media-manager/shared';
 import { useTaskQueueStore } from '@/stores/taskQueue';
+import { useTabsStore } from '@/stores/tabs';
 
 const taskStore = useTaskQueueStore();
+const tabsStore = useTabsStore();
 
 // 监听任务列表变化,当 sync_account 任务完成时自动刷新账号列表
 watch(() => taskStore.tasks, (newTasks, oldTasks) => {
@@ -457,18 +344,18 @@ watch(() => taskStore.tasks, (newTasks, oldTasks) => {
   }
 }, { deep: true });
 
+// 监听账号刷新信号(当浏览器登录添加账号后触发)
+watch(() => tabsStore.accountRefreshTrigger, () => {
+  console.log('[Accounts] Account refresh triggered, reloading list...');
+  loadAccounts();
+});
+
 const loading = ref(false);
 const submitting = ref(false);
 const showAddDialog = ref(false);
-const showBrowserLoginDialog = ref(false);
-const browserLoginLoading = ref(false);
+const showPlatformSelect = ref(false);
 const showRefreshDialog = ref(false);
 
-// 获取账号信息的进度状态
-const fetchingProgress = ref(0);
-const fetchingStep = ref(0);
-let fetchingTimer: ReturnType<typeof setInterval> | null = null;
-
 // 刷新账号的状态
 const refreshState = reactive({
   status: '' as '' | 'loading' | 'success' | 'expired' | 'failed',
@@ -489,21 +376,6 @@ const browserLoginForm = reactive({
   groupId: undefined as number | undefined,
 });
 
-const browserLoginSession = reactive({
-  sessionId: '',
-  status: '' as '' | 'pending' | 'fetching' | 'success' | 'failed' | 'timeout' | 'cancelled',
-  error: '',
-  accountInfo: null as {
-    accountId: string;
-    accountName: string;
-    avatarUrl: string;
-    fansCount: number;
-    worksCount: number;
-  } | null,
-});
-
-let pollTimer: ReturnType<typeof setInterval> | null = null;
-
 const platforms = PLATFORM_TYPES.map(type => PLATFORMS[type]);
 
 const filter = reactive({
@@ -674,7 +546,7 @@ function resetRefreshState() {
 function handleReLoginFromRefresh() {
   showRefreshDialog.value = false;
   browserLoginForm.platform = refreshState.platform;
-  showBrowserLoginDialog.value = true;
+  showPlatformSelect.value = true;
 }
 
 function editAccount(account: PlatformAccount) {
@@ -695,171 +567,30 @@ async function deleteAccount(id: number) {
   }
 }
 
-// 浏览器登录功能
-async function startBrowserLogin() {
+// 浏览器登录功能 - 改为在标签页中打开
+function startBrowserLogin() {
   if (!browserLoginForm.platform) {
     ElMessage.warning('请选择平台');
     return;
   }
   
-  browserLoginLoading.value = true;
-  try {
-    const result = await accountsApi.startBrowserLogin(browserLoginForm.platform);
-    browserLoginSession.sessionId = result.sessionId;
-    browserLoginSession.status = 'pending';
-    ElMessage.success(result.message);
-    
-    // 开始轮询登录状态
-    startPolling();
-  } catch {
-    // 错误已处理
-  } finally {
-    browserLoginLoading.value = false;
-  }
-}
-
-function startPolling() {
-  if (pollTimer) {
-    clearInterval(pollTimer);
-  }
-  
-  pollTimer = setInterval(async () => {
-    // 在 pending 或 fetching 状态下继续轮询
-    if (!browserLoginSession.sessionId || 
-        (browserLoginSession.status !== 'pending' && browserLoginSession.status !== 'fetching')) {
-      stopPolling();
-      return;
-    }
-    
-    try {
-      const status = await accountsApi.getBrowserLoginStatus(browserLoginSession.sessionId);
-      const prevStatus = browserLoginSession.status;
-      browserLoginSession.status = status.status as typeof browserLoginSession.status;
-      
-      // 状态变为 fetching 时启动进度动画
-      if (status.status === 'fetching' && prevStatus !== 'fetching') {
-        startFetchingAnimation();
-      }
-      
-      if (status.status === 'success') {
-        // 保存账号信息
-        if (status.accountInfo) {
-          browserLoginSession.accountInfo = status.accountInfo;
-        }
-        stopFetchingAnimation();
-        ElMessage.success('账号信息获取完成!');
-        stopPolling();
-      } else if (status.status === 'fetching') {
-        // 正在获取账号信息,继续轮询
-      } else if (status.status !== 'pending') {
-        stopFetchingAnimation();
-        browserLoginSession.error = status.error || '登录失败';
-        stopPolling();
-      }
-    } catch {
-      // 会话可能已过期
-      stopFetchingAnimation();
-      browserLoginSession.status = 'failed';
-      browserLoginSession.error = '会话已过期';
-      stopPolling();
-    }
-  }, 2000);
-}
-
-// 启动获取信息的进度动画
-function startFetchingAnimation() {
-  fetchingProgress.value = 0;
-  fetchingStep.value = 1;
+  const platformName = getPlatformName(browserLoginForm.platform as PlatformType);
   
-  if (fetchingTimer) {
-    clearInterval(fetchingTimer);
-  }
+  // 在标签页中打开浏览器
+  tabsStore.openBrowserTab(
+    browserLoginForm.platform,
+    `${platformName} 登录`,
+    browserLoginForm.groupId
+  );
   
-  let elapsed = 0;
-  fetchingTimer = setInterval(() => {
-    elapsed += 100;
-    
-    // 进度条模拟(最多到95%,剩余5%在完成时填满)
-    if (fetchingProgress.value < 95) {
-      // 非线性增长,开始快后面慢
-      const targetProgress = Math.min(95, Math.log(elapsed / 500 + 1) * 30);
-      fetchingProgress.value = Math.min(95, targetProgress);
-    }
-    
-    // 步骤更新
-    if (elapsed > 1000 && fetchingStep.value < 2) {
-      fetchingStep.value = 2;
-    }
-    if (elapsed > 3000 && fetchingStep.value < 3) {
-      fetchingStep.value = 3;
-    }
-  }, 100);
-}
-
-// 停止获取信息的进度动画
-function stopFetchingAnimation() {
-  if (fetchingTimer) {
-    clearInterval(fetchingTimer);
-    fetchingTimer = null;
-  }
-  fetchingProgress.value = 100;
-  fetchingStep.value = 4;
-}
-
-function stopPolling() {
-  if (pollTimer) {
-    clearInterval(pollTimer);
-    pollTimer = null;
-  }
-}
-
-async function cancelBrowserLogin() {
-  if (browserLoginSession.sessionId) {
-    try {
-      await accountsApi.cancelBrowserLogin(browserLoginSession.sessionId);
-    } catch {
-      // 忽略错误
-    }
-  }
-  resetBrowserLogin();
-  showBrowserLoginDialog.value = false;
-}
-
-async function confirmBrowserLogin() {
-  if (!browserLoginSession.sessionId || browserLoginSession.status !== 'success') {
-    ElMessage.warning('登录未完成');
-    return;
-  }
+  // 关闭平台选择对话框
+  showPlatformSelect.value = false;
   
-  browserLoginLoading.value = true;
-  try {
-    await accountsApi.confirmBrowserLogin(
-      browserLoginSession.sessionId,
-      browserLoginForm.platform as string,
-      browserLoginForm.groupId
-    );
-    ElMessage.success('账号添加成功');
-    showBrowserLoginDialog.value = false;
-    resetBrowserLogin();
-    loadAccounts();
-  } catch {
-    // 错误已处理
-  } finally {
-    browserLoginLoading.value = false;
-  }
-}
-
-function resetBrowserLogin() {
-  stopPolling();
-  stopFetchingAnimation();
-  browserLoginSession.sessionId = '';
-  browserLoginSession.status = '';
-  browserLoginSession.error = '';
-  browserLoginSession.accountInfo = null;
+  // 重置表单
   browserLoginForm.platform = '';
   browserLoginForm.groupId = undefined;
-  fetchingProgress.value = 0;
-  fetchingStep.value = 0;
+  
+  ElMessage.success('已打开浏览器登录标签页');
 }
 
 // 检查过期账号并提示重新登录
@@ -879,7 +610,7 @@ async function checkExpiredAccounts() {
       }
     ).then(() => {
       browserLoginForm.platform = account.platform;
-      showBrowserLoginDialog.value = true;
+      showPlatformSelect.value = true;
     }).catch(() => {
       // 用户取消
     });
@@ -913,8 +644,6 @@ onMounted(async () => {
 });
 
 onUnmounted(() => {
-  stopPolling();
-  stopFetchingAnimation();
   stopRefreshAnimation();
 });
 </script>
@@ -922,19 +651,32 @@ onUnmounted(() => {
 <style lang="scss" scoped>
 @use '@/styles/variables.scss' as *;
 
+.accounts-page {
+  max-width: 1400px;
+  margin: 0 auto;
+}
+
 .page-header {
   display: flex;
   align-items: center;
   justify-content: space-between;
-  margin-bottom: 20px;
+  margin-bottom: 24px;
   
   h2 {
     margin: 0;
+    font-size: 22px;
+    font-weight: 600;
+    color: $text-primary;
   }
   
   .header-actions {
     display: flex;
-    gap: 10px;
+    gap: 12px;
+    
+    .el-button {
+      border-radius: $radius-base;
+      font-weight: 500;
+    }
   }
 }
 
@@ -942,26 +684,71 @@ onUnmounted(() => {
   display: flex;
   gap: 12px;
   margin-bottom: 20px;
+  padding: 16px 20px !important;
+  
+  :deep(.el-select) {
+    .el-input__wrapper {
+      border-radius: $radius-base;
+    }
+  }
+}
+
+.page-card {
+  background: #fff;
+  border-radius: $radius-lg;
+  padding: 20px 24px;
+  box-shadow: $shadow-sm;
+  border: 1px solid $border-light;
+  
+  :deep(.el-table) {
+    --el-table-border-color: #{$border-light};
+    --el-table-header-bg-color: #{$bg-base};
+    
+    th.el-table__cell {
+      font-weight: 600;
+      color: $text-primary;
+      background: $bg-base !important;
+    }
+    
+    .el-table__row {
+      &:hover > td.el-table__cell {
+        background: $primary-color-light !important;
+      }
+    }
+    
+    .el-button--link {
+      font-weight: 500;
+    }
+  }
 }
 
 .account-cell {
   display: flex;
   align-items: center;
-  gap: 12px;
+  gap: 14px;
+  
+  :deep(.el-avatar) {
+    flex-shrink: 0;
+    border: 2px solid $border-light;
+    background: linear-gradient(135deg, $primary-color-light, #fff);
+    color: $primary-color;
+    font-weight: 600;
+  }
   
   .account-info {
     .account-name {
-      font-weight: 500;
+      font-weight: 600;
+      color: $text-primary;
     }
     
     .account-id {
       font-size: 12px;
       color: $text-secondary;
+      margin-top: 2px;
     }
   }
 }
 
-.browser-login-status,
 .refresh-status {
   display: flex;
   flex-direction: column;
@@ -970,11 +757,9 @@ onUnmounted(() => {
   text-align: center;
   
   .status-icon {
-    font-size: 64px;
+    font-size: 56px;
     margin-bottom: 20px;
     
-    &.pending,
-    &.fetching,
     &.loading {
       color: $primary-color;
       animation: spin 1s linear infinite;
@@ -995,6 +780,8 @@ onUnmounted(() => {
     h3 {
       margin: 0 0 10px;
       font-size: 18px;
+      font-weight: 600;
+      color: $text-primary;
     }
     
     p {
@@ -1006,11 +793,12 @@ onUnmounted(() => {
   .account-preview {
     display: flex;
     align-items: center;
-    gap: 12px;
+    gap: 14px;
     margin: 15px 0;
-    padding: 12px 20px;
-    background: rgba($primary-color, 0.05);
-    border-radius: 8px;
+    padding: 16px 24px;
+    background: $primary-color-light;
+    border-radius: $radius-lg;
+    border: 1px solid $border-light;
     
     .account-preview-info {
       text-align: left;
@@ -1033,16 +821,6 @@ onUnmounted(() => {
     }
   }
   
-  .login-tips {
-    margin-top: 16px;
-    
-    .el-text {
-      display: flex;
-      align-items: center;
-      gap: 4px;
-    }
-  }
-  
   .fetching-text {
     display: inline-block;
   }
@@ -1053,8 +831,16 @@ onUnmounted(() => {
   }
   
   .fetching-progress {
-    width: 200px;
+    width: 220px;
     margin: 16px 0;
+    
+    :deep(.el-progress-bar__outer) {
+      background: $border-light;
+    }
+    
+    :deep(.el-progress-bar__inner) {
+      background: linear-gradient(90deg, $primary-color, #64b5f6);
+    }
   }
   
   .fetching-hint {
@@ -1065,30 +851,31 @@ onUnmounted(() => {
   
   .fetching-steps {
     display: flex;
-    gap: 30px;
+    gap: 36px;
     
     .step {
       display: flex;
       flex-direction: column;
       align-items: center;
-      gap: 8px;
+      gap: 10px;
       color: $text-secondary;
       
       > span:first-child,
       > .el-icon:first-child {
-        width: 28px;
-        height: 28px;
+        width: 32px;
+        height: 32px;
         border-radius: 50%;
-        background: #f0f0f0;
+        background: $bg-base;
         display: flex;
         align-items: center;
         justify-content: center;
-        font-size: 13px;
-        font-weight: 500;
+        font-size: 14px;
+        font-weight: 600;
       }
       
       .step-text {
-        font-size: 12px;
+        font-size: 13px;
+        font-weight: 500;
       }
       
       &.active {
@@ -1096,7 +883,7 @@ onUnmounted(() => {
         
         > span:first-child,
         > .el-icon:first-child {
-          background: rgba($primary-color, 0.1);
+          background: $primary-color-light;
           color: $primary-color;
         }
         
@@ -1109,7 +896,7 @@ onUnmounted(() => {
         color: $success-color;
         
         > .el-icon:first-child {
-          background: rgba($success-color, 0.1);
+          background: $success-color-light;
           color: $success-color;
           animation: none;
         }

+ 332 - 111
client/src/views/Dashboard/index.vue

@@ -1,93 +1,92 @@
 <template>
   <div class="dashboard">
-    <el-row :gutter="20">
-      <!-- 统计卡片 -->
-      <el-col :span="6" v-for="stat in stats" :key="stat.label">
-        <div class="stat-card" :style="{ borderColor: stat.color }">
-          <div class="stat-icon" :style="{ background: stat.color }">
-            <el-icon><component :is="stat.icon" /></el-icon>
-          </div>
-          <div class="stat-content">
-            <div class="stat-value">{{ stat.value }}</div>
-            <div class="stat-label">{{ stat.label }}</div>
-          </div>
+    <!-- 统计卡片 -->
+    <div class="stats-grid">
+      <div class="stat-card" v-for="stat in stats" :key="stat.label">
+        <div class="stat-icon" :class="stat.iconClass">
+          <el-icon><component :is="stat.icon" /></el-icon>
+        </div>
+        <div class="stat-content">
+          <div class="stat-value">{{ stat.value }}</div>
+          <div class="stat-label">{{ stat.label }}</div>
         </div>
-      </el-col>
-    </el-row>
+      </div>
+    </div>
     
-    <el-row :gutter="20" style="margin-top: 20px">
+    <!-- 主要内容区 -->
+    <div class="content-grid">
       <!-- 平台账号状态 -->
-      <el-col :span="12">
-        <div class="page-card">
-          <div class="card-header">
-            <h3>平台账号状态</h3>
-            <el-button type="primary" link @click="$router.push('/accounts')">
-              管理账号
-            </el-button>
+      <div class="content-card">
+        <div class="card-header">
+          <h3>平台账号状态</h3>
+          <el-button type="primary" link @click="handleNavigate('/accounts')">
+            管理账号
+          </el-button>
+        </div>
+        <div class="account-list">
+          <div v-if="accounts.length === 0" class="empty-state">
+            <el-empty description="暂无账号" :image-size="80">
+              <el-button type="primary" @click="handleNavigate('/accounts')">
+                添加账号
+              </el-button>
+            </el-empty>
           </div>
-          <div class="account-list">
-            <div v-if="accounts.length === 0" class="empty-state">
-              <el-empty description="暂无账号">
-                <el-button type="primary" @click="$router.push('/accounts')">
-                  添加账号
-                </el-button>
-              </el-empty>
-            </div>
-            <div v-else v-for="account in accounts" :key="account.id" class="account-item">
-              <el-avatar :size="40" :src="account.avatarUrl || undefined">
-                {{ account.accountName?.[0] }}
-              </el-avatar>
-              <div class="account-info">
-                <div class="account-name">{{ account.accountName }}</div>
-                <div class="account-platform">{{ getPlatformName(account.platform) }}</div>
-              </div>
-              <el-tag
-                :type="account.status === 'active' ? 'success' : 'danger'"
-                size="small"
-              >
-                {{ account.status === 'active' ? '正常' : '已过期' }}
-              </el-tag>
+          <div v-else v-for="account in accounts" :key="account.id" class="account-item">
+            <el-avatar :size="44" :src="account.avatarUrl || undefined">
+              {{ account.accountName?.[0] }}
+            </el-avatar>
+            <div class="account-info">
+              <div class="account-name">{{ account.accountName }}</div>
+              <div class="account-platform">{{ getPlatformName(account.platform) }}</div>
             </div>
+            <el-tag
+              :type="account.status === 'active' ? 'success' : 'danger'"
+              size="small"
+              effect="light"
+              round
+            >
+              {{ account.status === 'active' ? '正常' : '已过期' }}
+            </el-tag>
           </div>
         </div>
-      </el-col>
+      </div>
       
       <!-- 最近任务 -->
-      <el-col :span="12">
-        <div class="page-card">
-          <div class="card-header">
-            <h3>最近发布任务</h3>
-            <el-button type="primary" link @click="$router.push('/publish')">
-              查看全部
-            </el-button>
+      <div class="content-card">
+        <div class="card-header">
+          <h3>最近发布任务</h3>
+          <el-button type="primary" link @click="handleNavigate('/publish')">
+            查看全部
+          </el-button>
+        </div>
+        <div class="task-list">
+          <div v-if="tasks.length === 0" class="empty-state">
+            <el-empty description="暂无任务" :image-size="80">
+              <el-button type="primary" @click="handleNavigate('/publish')">
+                创建任务
+              </el-button>
+            </el-empty>
           </div>
-          <div class="task-list">
-            <div v-if="tasks.length === 0" class="empty-state">
-              <el-empty description="暂无任务">
-                <el-button type="primary" @click="$router.push('/publish')">
-                  创建任务
-                </el-button>
-              </el-empty>
-            </div>
-            <div v-else v-for="task in tasks" :key="task.id" class="task-item">
-              <div class="task-info">
-                <div class="task-title">{{ task.title }}</div>
-                <div class="task-time">{{ formatDate(task.createdAt) }}</div>
-              </div>
-              <el-tag
-                :type="getTaskStatusType(task.status)"
-                size="small"
-              >
-                {{ getTaskStatusText(task.status) }}
-              </el-tag>
+          <div v-else v-for="task in tasks" :key="task.id" class="task-item">
+            <div class="task-info">
+              <div class="task-title">{{ task.title }}</div>
+              <div class="task-time">{{ formatDate(task.createdAt) }}</div>
             </div>
+            <el-tag
+              :type="getTaskStatusType(task.status)"
+              size="small"
+              effect="light"
+              round
+            >
+              {{ getTaskStatusText(task.status) }}
+            </el-tag>
           </div>
         </div>
-      </el-col>
-    </el-row>
+      </div>
+    </div>
     
     <!-- 数据趋势图 -->
-    <div class="page-card" style="margin-top: 20px">
+    <div class="content-card chart-card">
       <div class="card-header">
         <h3>数据趋势</h3>
         <el-radio-group v-model="trendType" size="small">
@@ -97,34 +96,41 @@
         </el-radio-group>
       </div>
       <div class="chart-container">
-        <div ref="chartRef" style="width: 100%; height: 300px"></div>
+        <div ref="chartRef" style="width: 100%; height: 280px"></div>
       </div>
     </div>
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted, watch } from 'vue';
+import { ref, onMounted, onUnmounted, watch, markRaw, nextTick } from 'vue';
 import { User, VideoPlay, ChatDotRound, TrendCharts } from '@element-plus/icons-vue';
 import * as echarts from 'echarts';
 import { accountsApi } from '@/api/accounts';
 import { PLATFORMS } from '@media-manager/shared';
 import type { PlatformAccount, PublishTask, PlatformType } from '@media-manager/shared';
+import { useTabsStore } from '@/stores/tabs';
 import dayjs from 'dayjs';
 
+const tabsStore = useTabsStore();
 const accounts = ref<PlatformAccount[]>([]);
 const tasks = ref<PublishTask[]>([]);
 const trendType = ref('fans');
 const chartRef = ref<HTMLElement>();
 let chartInstance: echarts.ECharts | null = null;
+let resizeObserver: ResizeObserver | null = null;
 
 const stats = ref([
-  { label: '平台账号', value: 0, icon: User, color: '#409eff' },
-  { label: '发布视频', value: 0, icon: VideoPlay, color: '#67c23a' },
-  { label: '新增评论', value: 0, icon: ChatDotRound, color: '#e6a23c' },
-  { label: '总播放量', value: '0', icon: TrendCharts, color: '#f56c6c' },
+  { label: '平台账号', value: 0, icon: markRaw(User), iconClass: 'blue' },
+  { label: '发布视频', value: 0, icon: markRaw(VideoPlay), iconClass: 'green' },
+  { label: '新增评论', value: 0, icon: markRaw(ChatDotRound), iconClass: 'orange' },
+  { label: '总播放量', value: '0', icon: markRaw(TrendCharts), iconClass: 'pink' },
 ]);
 
+function handleNavigate(path: string) {
+  tabsStore.openPageTab(path);
+}
+
 function getPlatformName(platform: PlatformType) {
   return PLATFORMS[platform]?.name || platform;
 }
@@ -161,19 +167,66 @@ function initChart() {
   chartInstance = echarts.init(chartRef.value);
   
   const option: echarts.EChartsOption = {
-    tooltip: { trigger: 'axis' },
+    tooltip: { 
+      trigger: 'axis',
+      backgroundColor: 'rgba(255, 255, 255, 0.95)',
+      borderColor: '#e5e7eb',
+      borderWidth: 1,
+      textStyle: {
+        color: '#374151',
+      },
+      axisPointer: {
+        type: 'cross',
+        crossStyle: {
+          color: '#9ca3af',
+        },
+      },
+    },
+    grid: {
+      left: '3%',
+      right: '4%',
+      bottom: '3%',
+      containLabel: true,
+    },
     xAxis: {
       type: 'category',
       data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
+      axisLine: {
+        lineStyle: { color: '#e5e7eb' },
+      },
+      axisLabel: {
+        color: '#6b7280',
+      },
+    },
+    yAxis: { 
+      type: 'value',
+      axisLine: { show: false },
+      axisTick: { show: false },
+      splitLine: {
+        lineStyle: { color: '#f3f4f6' },
+      },
+      axisLabel: {
+        color: '#6b7280',
+      },
     },
-    yAxis: { type: 'value' },
     series: [{
       data: [820, 932, 901, 934, 1290, 1330, 1320],
       type: 'line',
       smooth: true,
-      areaStyle: { opacity: 0.3 },
+      symbol: 'circle',
+      symbolSize: 6,
+      lineStyle: {
+        width: 3,
+      },
+      areaStyle: { 
+        opacity: 0.1,
+        color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+          { offset: 0, color: '#4f8cff' },
+          { offset: 1, color: 'rgba(79, 140, 255, 0)' },
+        ]),
+      },
     }],
-    color: ['#409eff'],
+    color: ['#4f8cff'],
   };
   
   chartInstance.setOption(option);
@@ -183,20 +236,46 @@ async function loadData() {
   try {
     accounts.value = await accountsApi.getAccounts();
     stats.value[0].value = accounts.value.length;
-    
-    // TODO: 加载其他数据
   } catch {
     // 错误已在拦截器中处理
   }
 }
 
-onMounted(() => {
+// resize 处理函数
+function handleResize() {
+  if (chartInstance && !chartInstance.isDisposed()) {
+    chartInstance.resize();
+  }
+}
+
+onMounted(async () => {
   loadData();
-  initChart();
   
-  window.addEventListener('resize', () => {
-    chartInstance?.resize();
-  });
+  // 等待 DOM 完全渲染后再初始化图表
+  await nextTick();
+  
+  // 延迟初始化以确保容器尺寸正确
+  setTimeout(() => {
+    initChart();
+    
+    // 使用 ResizeObserver 监听容器大小变化
+    if (chartRef.value) {
+      resizeObserver = new ResizeObserver(() => {
+        handleResize();
+      });
+      resizeObserver.observe(chartRef.value);
+    }
+  }, 100);
+  
+  window.addEventListener('resize', handleResize);
+});
+
+onUnmounted(() => {
+  window.removeEventListener('resize', handleResize);
+  resizeObserver?.disconnect();
+  resizeObserver = null;
+  chartInstance?.dispose();
+  chartInstance = null;
 });
 
 watch(trendType, () => {
@@ -207,40 +286,105 @@ watch(trendType, () => {
 <style lang="scss" scoped>
 @use '@/styles/variables.scss' as *;
 
+.dashboard {
+  max-width: 1400px;
+  margin: 0 auto;
+}
+
+// 统计卡片网格
+.stats-grid {
+  display: grid;
+  grid-template-columns: repeat(4, 1fr);
+  gap: 20px;
+  margin-bottom: 24px;
+}
+
 .stat-card {
   background: #fff;
-  border-radius: 8px;
-  padding: 20px;
+  border-radius: $radius-lg;
+  padding: 24px;
   display: flex;
   align-items: center;
-  gap: 16px;
-  border-left: 4px solid;
-  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
+  gap: 18px;
+  box-shadow: $shadow-sm;
+  border: 1px solid $border-light;
+  transition: all 0.3s ease;
+  
+  &:hover {
+    transform: translateY(-2px);
+    box-shadow: $shadow-md;
+  }
   
   .stat-icon {
-    width: 50px;
-    height: 50px;
-    border-radius: 8px;
+    width: 56px;
+    height: 56px;
+    border-radius: $radius-lg;
     display: flex;
     align-items: center;
     justify-content: center;
     
     .el-icon {
-      font-size: 24px;
+      font-size: 26px;
       color: #fff;
     }
+    
+    &.blue {
+      background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
+    }
+    
+    &.green {
+      background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
+    }
+    
+    &.orange {
+      background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
+    }
+    
+    &.pink {
+      background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
+    }
+  }
+  
+  .stat-content {
+    flex: 1;
   }
   
   .stat-value {
-    font-size: 28px;
-    font-weight: 600;
+    font-size: 32px;
+    font-weight: 700;
     color: $text-primary;
+    line-height: 1.2;
   }
   
   .stat-label {
     font-size: 14px;
     color: $text-secondary;
-    margin-top: 4px;
+    margin-top: 6px;
+  }
+}
+
+// 内容卡片网格
+.content-grid {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 20px;
+  margin-bottom: 24px;
+}
+
+.content-card {
+  background: #fff;
+  border-radius: $radius-lg;
+  padding: 24px;
+  box-shadow: $shadow-sm;
+  border: 1px solid $border-light;
+  
+  &.chart-card {
+    width: 100%;
+    overflow: hidden;
+    
+    .card-header {
+      margin-bottom: 20px;
+    }
   }
 }
 
@@ -248,13 +392,32 @@ watch(trendType, () => {
   display: flex;
   align-items: center;
   justify-content: space-between;
-  margin-bottom: 16px;
+  margin-bottom: 20px;
   
   h3 {
     margin: 0;
-    font-size: 16px;
+    font-size: 17px;
+    font-weight: 600;
     color: $text-primary;
   }
+  
+  .el-button {
+    font-weight: 500;
+  }
+  
+  :deep(.el-radio-group) {
+    .el-radio-button__inner {
+      border-radius: $radius-sm;
+      border-color: $border-light;
+      color: $text-secondary;
+      font-weight: 500;
+    }
+    
+    .el-radio-button__original-radio:checked + .el-radio-button__inner {
+      background: $primary-color;
+      border-color: $primary-color;
+    }
+  }
 }
 
 .account-list, .task-list {
@@ -264,27 +427,45 @@ watch(trendType, () => {
 .account-item, .task-item {
   display: flex;
   align-items: center;
-  padding: 12px 0;
-  border-bottom: 1px solid $border-lighter;
+  padding: 14px 0;
+  border-bottom: 1px solid $border-light;
   
   &:last-child {
     border-bottom: none;
+    padding-bottom: 0;
+  }
+  
+  &:first-child {
+    padding-top: 0;
   }
 }
 
 .account-item {
-  gap: 12px;
+  gap: 14px;
+  
+  :deep(.el-avatar) {
+    flex-shrink: 0;
+    border: 2px solid $border-light;
+    background: linear-gradient(135deg, $primary-color-light, #fff);
+    color: $primary-color;
+    font-weight: 600;
+  }
   
   .account-info {
     flex: 1;
+    min-width: 0;
     
     .account-name {
-      font-weight: 500;
+      font-weight: 600;
       color: $text-primary;
+      font-size: 15px;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
     }
     
     .account-platform {
-      font-size: 12px;
+      font-size: 13px;
       color: $text-secondary;
       margin-top: 4px;
     }
@@ -294,13 +475,22 @@ watch(trendType, () => {
 .task-item {
   justify-content: space-between;
   
+  .task-info {
+    flex: 1;
+    min-width: 0;
+  }
+  
   .task-title {
-    font-weight: 500;
+    font-weight: 600;
     color: $text-primary;
+    font-size: 15px;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
   }
   
   .task-time {
-    font-size: 12px;
+    font-size: 13px;
     color: $text-secondary;
     margin-top: 4px;
   }
@@ -311,5 +501,36 @@ watch(trendType, () => {
   align-items: center;
   justify-content: center;
   min-height: 200px;
+  
+  :deep(.el-empty__description p) {
+    color: $text-secondary;
+  }
+}
+
+.chart-container {
+  margin-top: 10px;
+  width: 100%;
+  min-height: 280px;
+  
+  > div {
+    width: 100% !important;
+  }
+}
+
+// 响应式调整
+@media (max-width: 1200px) {
+  .stats-grid {
+    grid-template-columns: repeat(2, 1fr);
+  }
+  
+  .content-grid {
+    grid-template-columns: 1fr;
+  }
+}
+
+@media (max-width: 768px) {
+  .stats-grid {
+    grid-template-columns: 1fr;
+  }
 }
 </style>

+ 143 - 12
client/src/views/Login/index.vue

@@ -1,7 +1,17 @@
 <template>
   <div class="login-container">
+    <!-- 背景装饰 -->
+    <div class="bg-decoration">
+      <div class="circle circle-1"></div>
+      <div class="circle circle-2"></div>
+      <div class="circle circle-3"></div>
+    </div>
+    
     <div class="login-card">
       <div class="login-header">
+        <div class="logo">
+          <el-icon><VideoPlay /></el-icon>
+        </div>
         <h1>多平台媒体管理系统</h1>
         <p>登录您的账号以继续</p>
       </div>
@@ -33,7 +43,7 @@
           />
         </el-form-item>
         
-        <el-form-item>
+        <el-form-item class="remember-row">
           <el-checkbox v-model="form.rememberMe">记住登录状态</el-checkbox>
         </el-form-item>
         
@@ -71,7 +81,7 @@
 <script setup lang="ts">
 import { ref, reactive } from 'vue';
 import { useRouter } from 'vue-router';
-import { User, Lock, Link } from '@element-plus/icons-vue';
+import { User, Lock, Link, VideoPlay } from '@element-plus/icons-vue';
 import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
 import { useAuthStore } from '@/stores/auth';
 import { useServerStore } from '@/stores/server';
@@ -125,53 +135,174 @@ async function handleLogin() {
   display: flex;
   align-items: center;
   justify-content: center;
-  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  background: linear-gradient(135deg, #f5f7fa 0%, #e4e8eb 100%);
+  position: relative;
+  overflow: hidden;
+}
+
+// 背景装饰
+.bg-decoration {
+  position: absolute;
+  inset: 0;
+  overflow: hidden;
+  pointer-events: none;
+  
+  .circle {
+    position: absolute;
+    border-radius: 50%;
+    opacity: 0.5;
+  }
+  
+  .circle-1 {
+    width: 400px;
+    height: 400px;
+    background: linear-gradient(135deg, rgba(79, 140, 255, 0.2), rgba(79, 140, 255, 0.05));
+    top: -100px;
+    right: -100px;
+  }
+  
+  .circle-2 {
+    width: 300px;
+    height: 300px;
+    background: linear-gradient(135deg, rgba(250, 112, 154, 0.15), rgba(254, 225, 64, 0.1));
+    bottom: -80px;
+    left: -80px;
+  }
+  
+  .circle-3 {
+    width: 200px;
+    height: 200px;
+    background: linear-gradient(135deg, rgba(102, 126, 234, 0.15), rgba(118, 75, 162, 0.1));
+    top: 50%;
+    left: 10%;
+    transform: translateY(-50%);
+  }
 }
 
 .login-card {
-  width: 400px;
-  padding: 40px;
+  width: 420px;
+  padding: 48px 40px;
   background: #fff;
-  border-radius: 12px;
-  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
+  border-radius: $radius-xl;
+  box-shadow: $shadow-lg;
+  position: relative;
+  z-index: 1;
+  border: 1px solid rgba(255, 255, 255, 0.8);
 }
 
 .login-header {
   text-align: center;
-  margin-bottom: 30px;
+  margin-bottom: 36px;
+  
+  .logo {
+    width: 64px;
+    height: 64px;
+    margin: 0 auto 20px;
+    border-radius: $radius-lg;
+    background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    box-shadow: 0 8px 24px rgba(79, 172, 254, 0.3);
+    
+    .el-icon {
+      font-size: 32px;
+      color: #fff;
+    }
+  }
   
   h1 {
-    margin: 0 0 10px;
+    margin: 0 0 12px;
     font-size: 24px;
+    font-weight: 700;
     color: $text-primary;
   }
   
   p {
     margin: 0;
     color: $text-secondary;
+    font-size: 14px;
   }
 }
 
 .login-form {
+  :deep(.el-input__wrapper) {
+    border-radius: $radius-base;
+    box-shadow: 0 0 0 1px $border-light inset;
+    transition: all 0.2s;
+    
+    &:hover {
+      box-shadow: 0 0 0 1px $primary-color inset;
+    }
+    
+    &.is-focus {
+      box-shadow: 0 0 0 1px $primary-color inset, 0 0 0 3px rgba($primary-color, 0.1);
+    }
+  }
+  
+  :deep(.el-input__inner) {
+    height: 44px;
+    font-size: 15px;
+  }
+  
+  :deep(.el-input__prefix) {
+    color: $text-secondary;
+  }
+  
+  .remember-row {
+    margin-bottom: 24px;
+    
+    :deep(.el-checkbox__label) {
+      color: $text-secondary;
+    }
+  }
+  
   .login-btn {
     width: 100%;
+    height: 48px;
+    font-size: 16px;
+    font-weight: 600;
+    border-radius: $radius-base;
+    background: linear-gradient(135deg, $primary-color 0%, #3a7bd5 100%);
+    border: none;
+    box-shadow: 0 4px 16px rgba($primary-color, 0.3);
+    transition: all 0.3s;
+    
+    &:hover {
+      transform: translateY(-1px);
+      box-shadow: 0 6px 20px rgba($primary-color, 0.4);
+    }
+    
+    &:active {
+      transform: translateY(0);
+    }
   }
 }
 
 .login-footer {
   text-align: center;
   color: $text-secondary;
+  font-size: 14px;
+  margin-top: 8px;
+  
+  .el-button {
+    font-weight: 500;
+  }
 }
 
 .server-info {
-  margin-top: 20px;
-  padding-top: 20px;
-  border-top: 1px solid $border-lighter;
+  margin-top: 28px;
+  padding-top: 24px;
+  border-top: 1px solid $border-light;
   display: flex;
   align-items: center;
   justify-content: center;
   gap: 8px;
   color: $text-secondary;
   font-size: 13px;
+  
+  .el-icon {
+    color: $primary-color;
+  }
 }
 </style>

+ 112 - 6
client/src/views/Publish/index.vue

@@ -141,15 +141,28 @@
         </el-form-item>
         
         <el-form-item label="目标账号">
-          <el-checkbox-group v-model="createForm.targetAccounts">
+          <el-checkbox-group v-model="createForm.targetAccounts" v-if="accounts.length > 0">
             <el-checkbox
               v-for="account in accounts"
               :key="account.id"
               :label="account.id"
+              :disabled="account.status !== 'active'"
             >
-              {{ account.accountName }} ({{ getPlatformName(account.platform) }})
+              <div class="account-option">
+                <el-avatar :size="24" :src="account.avatarUrl || undefined">
+                  {{ account.accountName?.[0] }}
+                </el-avatar>
+                <span class="account-name">{{ account.accountName }}</span>
+                <el-tag size="small" :type="account.platform === 'douyin' ? 'danger' : 'primary'">
+                  {{ getPlatformName(account.platform) }}
+                </el-tag>
+                <el-tag v-if="account.status !== 'active'" size="small" type="warning">
+                  {{ account.status === 'expired' ? '已过期' : '异常' }}
+                </el-tag>
+              </div>
             </el-checkbox>
           </el-checkbox-group>
+          <el-empty v-else description="暂无可用账号,请先在账号管理中添加" :image-size="60" />
         </el-form-item>
         
         <el-form-item label="定时发布">
@@ -215,7 +228,22 @@
                 </el-tag>
               </template>
             </el-table-column>
-            <el-table-column label="错误信息" prop="errorMessage" show-overflow-tooltip />
+            <el-table-column label="错误信息" min-width="200">
+              <template #default="{ row }">
+                <div v-if="isCaptchaError(row.errorMessage)" class="captcha-error">
+                  <el-text type="warning">检测到验证码,需要手动验证</el-text>
+                  <el-button
+                    type="primary"
+                    size="small"
+                    link
+                    @click="openBrowserForCaptcha(row.accountId, row.platform)"
+                  >
+                    打开浏览器验证
+                  </el-button>
+                </div>
+                <span v-else>{{ row.errorMessage || '-' }}</span>
+              </template>
+            </el-table-column>
             <el-table-column label="发布时间" width="160">
               <template #default="{ row }">
                 {{ row.publishedAt ? formatDate(row.publishedAt) : '-' }}
@@ -270,15 +298,28 @@
         </el-form-item>
         
         <el-form-item label="目标账号">
-          <el-checkbox-group v-model="editForm.targetAccounts">
+          <el-checkbox-group v-model="editForm.targetAccounts" v-if="accounts.length > 0">
             <el-checkbox
               v-for="account in accounts"
               :key="account.id"
               :label="account.id"
+              :disabled="account.status !== 'active'"
             >
-              {{ account.accountName }} ({{ getPlatformName(account.platform) }})
+              <div class="account-option">
+                <el-avatar :size="24" :src="account.avatarUrl || undefined">
+                  {{ account.accountName?.[0] }}
+                </el-avatar>
+                <span class="account-name">{{ account.accountName }}</span>
+                <el-tag size="small" :type="account.platform === 'douyin' ? 'danger' : 'primary'">
+                  {{ getPlatformName(account.platform) }}
+                </el-tag>
+                <el-tag v-if="account.status !== 'active'" size="small" type="warning">
+                  {{ account.status === 'expired' ? '已过期' : '异常' }}
+                </el-tag>
+              </div>
             </el-checkbox>
           </el-checkbox-group>
+          <el-empty v-else description="暂无可用账号" :image-size="60" />
         </el-form-item>
         
         <el-form-item label="定时发布">
@@ -309,9 +350,11 @@ import request from '@/api/request';
 import { PLATFORMS } from '@media-manager/shared';
 import type { PublishTask, PublishTaskDetail, PlatformAccount, PlatformType } from '@media-manager/shared';
 import { useTaskQueueStore } from '@/stores/taskQueue';
+import { useTabsStore } from '@/stores/tabs';
 import dayjs from 'dayjs';
 
 const taskStore = useTaskQueueStore();
+const tabsStore = useTabsStore();
 
 const loading = ref(false);
 const submitting = ref(false);
@@ -391,6 +434,38 @@ function formatDate(date: string) {
   return dayjs(date).format('YYYY-MM-DD HH:mm');
 }
 
+// 检查是否是验证码错误
+function isCaptchaError(errorMessage: string | null | undefined): boolean {
+  return !!errorMessage && errorMessage.includes('CAPTCHA_REQUIRED');
+}
+
+// 打开浏览器标签页让用户完成验证码验证
+function openBrowserForCaptcha(accountId: number, platform: string) {
+  // 找到对应的账号
+  const account = accounts.value.find(a => a.id === accountId);
+  const accountName = account?.accountName || `账号${accountId}`;
+  
+  // 根据平台获取登录URL
+  const platformUrls: Record<string, string> = {
+    douyin: 'https://creator.douyin.com/',
+    bilibili: 'https://member.bilibili.com/',
+    kuaishou: 'https://cp.kuaishou.com/',
+    xiaohongshu: 'https://creator.xiaohongshu.com/',
+  };
+  
+  const url = platformUrls[platform] || 'https://creator.douyin.com/';
+  
+  // 打开浏览器标签页
+  tabsStore.openBrowserTab({
+    platform: platform as PlatformType,
+    url,
+    title: `验证码验证 - ${accountName}`,
+  });
+  
+  ElMessage.info('请在浏览器标签页中完成验证码验证,完成后重试发布');
+  showDetailDialog.value = false;
+}
+
 async function loadTasks() {
   loading.value = true;
   try {
@@ -411,7 +486,8 @@ async function loadTasks() {
 
 async function loadAccounts() {
   try {
-    accounts.value = await accountsApi.getAccounts({ status: 'active' });
+    // 获取所有账号,不限制状态
+    accounts.value = await accountsApi.getAccounts();
   } catch {
     // 错误已处理
   }
@@ -642,4 +718,34 @@ onMounted(() => {
     color: var(--el-text-color-primary);
   }
 }
+
+.captcha-error {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.account-option {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  
+  .account-name {
+    font-weight: 500;
+  }
+}
+
+:deep(.el-checkbox-group) {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+:deep(.el-checkbox) {
+  height: auto;
+  
+  .el-checkbox__label {
+    padding-left: 8px;
+  }
+}
 </style>

+ 9 - 6
client/src/views/Works/index.vue

@@ -357,7 +357,7 @@
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, onMounted, onUnmounted, computed } from 'vue';
+import { ref, reactive, onMounted, onUnmounted, computed, watch } from 'vue';
 import { Search, Refresh, VideoPlay, Star, ChatDotSquare, Share, Loading, CircleCheckFilled, CircleCloseFilled, WarningFilled, Delete } from '@element-plus/icons-vue';
 import { ElMessageBox } from 'element-plus';
 import { ElMessage } from 'element-plus';
@@ -374,6 +374,13 @@ const serverStore = useServerStore();
 const authStore = useAuthStore();
 const taskStore = useTaskQueueStore();
 
+// 监听作品刷新信号,当 sync_works 任务完成时自动刷新作品列表
+watch(() => taskStore.worksRefreshTrigger, () => {
+  console.log('[Works] worksRefreshTrigger changed, refreshing list...');
+  loadWorks();
+  loadStats();
+});
+
 const loading = ref(false);
 const refreshing = ref(false);
 const syncingComments = ref(false);
@@ -530,11 +537,7 @@ async function refreshAllWorks() {
     ElMessage.success('作品同步任务已创建,请在任务队列中查看进度');
     // 打开任务队列弹框
     taskStore.openDialog();
-    // 延迟刷新
-    setTimeout(() => {
-      loadWorks();
-      loadStats();
-    }, 5000);
+    // 任务完成后会通过 watch 自动刷新列表
   } catch (error) {
     ElMessage.error((error as Error)?.message || '创建同步任务失败');
   } finally {

+ 21 - 1
client/vite.config.ts

@@ -25,7 +25,27 @@ export default defineConfig(({ command }) => {
         dts: 'src/auto-imports.d.ts',
       }),
       Components({
-        resolvers: [ElementPlusResolver()],
+        resolvers: [
+          ElementPlusResolver(),
+          // 自动解析 Element Plus 图标组件
+          {
+            type: 'component',
+            resolve: (name: string) => {
+              // Element Plus 图标组件通常是 PascalCase 且不以 El 开头
+              const iconNames = [
+                'Fold', 'Expand', 'Loading', 'DocumentCopy', 'Message', 'Close', 
+                'Refresh', 'Delete', 'Right', 'FolderDelete', 'DataAnalysis', 
+                'User', 'Film', 'Upload', 'Clock', 'Setting', 'Document', 
+                'Monitor', 'TrendCharts', 'ChatDotRound', 'ArrowDown', 'ArrowLeft',
+                'ArrowRight', 'VideoPlay', 'CircleCheck', 'CircleClose', 'Lock',
+                'Picture', 'Plus', 'Search', 'Edit', 'Download', 'MoreFilled',
+              ];
+              if (iconNames.includes(name)) {
+                return { name, from: '@element-plus/icons-vue' };
+              }
+            },
+          },
+        ],
         dts: 'src/components.d.ts',
       }),
       electron([

+ 51 - 2
server/src/automation/platforms/base.ts

@@ -154,6 +154,9 @@ export abstract class BasePlatformAdapter {
   
   /**
    * 设置 Cookie
+   * 支持两种格式:
+   * 1. JSON 数组格式:[{name, value, domain, path}]
+   * 2. 字符串格式:name=value; name2=value2;
    */
   async setCookies(cookies: string): Promise<void> {
     if (!this.context) {
@@ -161,8 +164,26 @@ export abstract class BasePlatformAdapter {
     }
     
     try {
-      const cookieList = JSON.parse(cookies);
-      await this.context.addCookies(cookieList);
+      let cookieList: Array<{ name: string; value: string; domain?: string; path?: string }>;
+      
+      // 尝试解析为 JSON
+      try {
+        cookieList = JSON.parse(cookies);
+      } catch {
+        // JSON 解析失败,尝试解析为 name=value 格式的字符串
+        cookieList = this.parseCookieString(cookies);
+      }
+      
+      // 确保每个 cookie 都有必要的字段
+      const formattedCookies = cookieList.map(c => ({
+        name: c.name,
+        value: c.value,
+        domain: c.domain || this.getCookieDomain(),
+        path: c.path || '/',
+      }));
+      
+      await this.context.addCookies(formattedCookies);
+      logger.info(`Set ${formattedCookies.length} cookies`);
     } catch (error) {
       logger.error('Failed to set cookies:', error);
       throw error;
@@ -170,6 +191,34 @@ export abstract class BasePlatformAdapter {
   }
   
   /**
+   * 解析 name=value; 格式的 cookie 字符串
+   */
+  private parseCookieString(cookieStr: string): Array<{ name: string; value: string; domain?: string; path?: string }> {
+    const cookies: Array<{ name: string; value: string; domain?: string; path?: string }> = [];
+    const pairs = cookieStr.split(';').map(s => s.trim()).filter(s => s);
+    
+    for (const pair of pairs) {
+      const eqIndex = pair.indexOf('=');
+      if (eqIndex > 0) {
+        const name = pair.substring(0, eqIndex).trim();
+        const value = pair.substring(eqIndex + 1).trim();
+        if (name && !['path', 'domain', 'expires', 'max-age', 'secure', 'httponly', 'samesite'].includes(name.toLowerCase())) {
+          cookies.push({ name, value });
+        }
+      }
+    }
+    
+    return cookies;
+  }
+  
+  /**
+   * 获取平台对应的 cookie domain
+   */
+  protected getCookieDomain(): string {
+    return '.douyin.com'; // 子类可以覆盖
+  }
+  
+  /**
    * 获取 Cookie
    */
   async getCookies(): Promise<string> {

+ 4 - 0
server/src/automation/platforms/bilibili.ts

@@ -18,6 +18,10 @@ export class BilibiliAdapter extends BasePlatformAdapter {
   readonly loginUrl = 'https://member.bilibili.com/';
   readonly publishUrl = 'https://member.bilibili.com/platform/upload/video/frame';
   
+  protected getCookieDomain(): string {
+    return '.bilibili.com';
+  }
+  
   async getQRCode(): Promise<QRCodeInfo> {
     try {
       await this.initBrowser();

+ 15 - 9
server/src/automation/platforms/douyin.ts

@@ -1045,13 +1045,16 @@ export class DouyinAdapter extends BasePlatformAdapter {
       if (captchaHandled === 'failed') {
         throw new Error('验证码验证失败');
       }
-      // 如果在 headless 模式检测到验证码,关闭浏览器并用 headful 模式从头开始发布
+      // 如果检测到验证码,通知前端需要手动验证
       if (captchaHandled === 'need_retry_headful') {
-        logger.info('[Douyin Publish] Captcha detected, closing headless and restarting with headful...');
-        onProgress?.(85, '检测到验证码,正在打开浏览器窗口重新发布...');
+        logger.info('[Douyin Publish] Captcha detected, requesting user to complete verification...');
+        onProgress?.(85, '检测到验证码,请在浏览器中完成验证后重试');
         await this.closeBrowser();
-        // 递归调用,使用 headful 模式
-        return this.publishVideo(cookies, params, onProgress, onCaptchaRequired, { headless: false });
+        // 返回特殊错误,让前端知道需要手动验证
+        return {
+          success: false,
+          errorMessage: 'CAPTCHA_REQUIRED:检测到验证码,请在浏览器中登录账号完成验证后重试发布',
+        };
       }
       
       onProgress?.(90, '等待发布完成...');
@@ -1077,12 +1080,15 @@ export class DouyinAdapter extends BasePlatformAdapter {
         if (captchaResult === 'failed') {
           throw new Error('验证码验证失败');
         }
-        // 如果在 headless 模式检测到验证码,关闭浏览器并用 headful 模式从头开始发布
+        // 如果检测到验证码,通知前端需要手动验证
         if (captchaResult === 'need_retry_headful') {
-          logger.info('[Douyin Publish] Captcha detected in wait loop, restarting with headful...');
-          onProgress?.(85, '检测到验证码,正在打开浏览器窗口重新发布...');
+          logger.info('[Douyin Publish] Captcha detected in wait loop, requesting user verification...');
+          onProgress?.(85, '检测到验证码,请在浏览器中完成验证后重试');
           await this.closeBrowser();
-          return this.publishVideo(cookies, params, onProgress, onCaptchaRequired, { headless: false });
+          return {
+            success: false,
+            errorMessage: 'CAPTCHA_REQUIRED:检测到验证码,请在浏览器中登录账号完成验证后重试发布',
+          };
         }
         
         // 检查是否跳转到管理页面 - 这是最可靠的成功标志

+ 4 - 0
server/src/automation/platforms/kuaishou.ts

@@ -18,6 +18,10 @@ export class KuaishouAdapter extends BasePlatformAdapter {
   readonly loginUrl = 'https://cp.kuaishou.com/';
   readonly publishUrl = 'https://cp.kuaishou.com/article/publish/video';
   
+  protected getCookieDomain(): string {
+    return '.kuaishou.com';
+  }
+  
   async getQRCode(): Promise<QRCodeInfo> {
     try {
       await this.initBrowser();

+ 40 - 0
server/src/routes/accounts.ts

@@ -314,4 +314,44 @@ router.post(
   })
 );
 
+// ============ 内嵌浏览器登录 Cookie 验证 ============
+
+// 验证 Cookie 登录状态(用于 Electron 内嵌浏览器)
+router.post(
+  '/verify-cookie',
+  [
+    body('platform').notEmpty().withMessage('平台不能为空'),
+    body('cookieData').notEmpty().withMessage('Cookie不能为空'),
+    validateRequest,
+  ],
+  asyncHandler(async (req, res) => {
+    const { platform, cookieData } = req.body;
+    
+    try {
+      // 使用服务验证 cookie 并获取账号信息
+      const result = await accountService.verifyCookieAndGetInfo(
+        platform as PlatformType,
+        cookieData
+      );
+      
+      res.json({ 
+        success: true, 
+        data: {
+          success: result.success,
+          message: result.message,
+          accountInfo: result.accountInfo,
+        }
+      });
+    } catch (error) {
+      res.json({ 
+        success: true, 
+        data: {
+          success: false,
+          message: error instanceof Error ? error.message : '验证失败',
+        }
+      });
+    }
+  })
+);
+
 export default router;

+ 155 - 8
server/src/services/AccountService.ts

@@ -269,15 +269,19 @@ export class AccountService {
           decryptedCookies = account.cookieData;
         }
 
-        // 解析 Cookie
+        // 解析 Cookie - 支持两种格式
         let cookieList: { name: string; value: string; domain: string; path: string }[];
         try {
+          // 先尝试 JSON 格式
           cookieList = JSON.parse(decryptedCookies);
         } catch {
-          logger.error(`Invalid cookie format for account ${accountId}`);
-          updateData.status = 'expired';
-          needReLogin = true;
-          cookieList = [];
+          // 如果 JSON 解析失败,尝试解析 "name=value; name2=value2" 格式
+          cookieList = this.parseCookieString(decryptedCookies, platform);
+          if (cookieList.length === 0) {
+            logger.error(`Invalid cookie format for account ${accountId}`);
+            updateData.status = 'expired';
+            needReLogin = true;
+          }
         }
 
         if (cookieList.length > 0) {
@@ -365,13 +369,18 @@ export class AccountService {
         decryptedCookies = account.cookieData;
       }
 
-      // 解析 Cookie
+      // 解析 Cookie - 支持两种格式
       let cookieList: { name: string; value: string; domain: string; path: string }[];
       try {
+        // 先尝试 JSON 格式
         cookieList = JSON.parse(decryptedCookies);
       } catch {
-        await this.accountRepository.update(accountId, { status: 'expired' });
-        return { isValid: false };
+        // 如果 JSON 解析失败,尝试解析 "name=value; name2=value2" 格式
+        cookieList = this.parseCookieString(decryptedCookies, platform);
+        if (cookieList.length === 0) {
+          await this.accountRepository.update(accountId, { status: 'expired' });
+          return { isValid: false };
+        }
       }
 
       // 使用 API 检查 Cookie 是否有效
@@ -439,6 +448,144 @@ export class AccountService {
     };
   }
 
+  /**
+   * 验证 Cookie 并获取账号信息(用于 Electron 内嵌浏览器登录)
+   */
+  async verifyCookieAndGetInfo(platform: PlatformType, cookieData: string): Promise<{
+    success: boolean;
+    message?: string;
+    accountInfo?: {
+      accountId: string;
+      accountName: string;
+      avatarUrl: string;
+      fansCount: number;
+      worksCount: number;
+    };
+  }> {
+    try {
+      // 将 cookie 字符串转换为 cookie 列表格式
+      const cookieList = this.parseCookieString(cookieData, platform);
+      
+      if (cookieList.length === 0) {
+        return { success: false, message: 'Cookie 格式无效' };
+      }
+
+      // 验证 Cookie 是否有效
+      const isValid = await headlessBrowserService.checkCookieValid(platform, cookieList);
+      
+      if (!isValid) {
+        return { success: false, message: '登录状态无效或已过期' };
+      }
+
+      // 获取账号信息
+      try {
+        const profile = await headlessBrowserService.fetchAccountInfo(platform, cookieList);
+        
+        // 检查是否获取到有效信息
+        const isValidProfile = profile.accountName && 
+          profile.accountName !== `${platform}账号` &&
+          profile.accountName !== '未知账号';
+
+        if (isValidProfile) {
+          return {
+            success: true,
+            accountInfo: {
+              accountId: profile.accountId || `${platform}_${Date.now()}`,
+              accountName: profile.accountName,
+              avatarUrl: profile.avatarUrl || '',
+              fansCount: profile.fansCount || 0,
+              worksCount: profile.worksCount || 0,
+            },
+          };
+        } else {
+          // Cookie 有效但未能获取账号信息,返回基本成功
+          return {
+            success: true,
+            message: '登录成功,但无法获取详细信息',
+            accountInfo: {
+              accountId: `${platform}_${Date.now()}`,
+              accountName: `${platform}账号`,
+              avatarUrl: '',
+              fansCount: 0,
+              worksCount: 0,
+            },
+          };
+        }
+      } catch (infoError) {
+        logger.warn(`Failed to fetch account info for ${platform}:`, infoError);
+        // Cookie 有效但获取信息失败
+        return {
+          success: true,
+          message: '登录成功,但无法获取详细信息',
+          accountInfo: {
+            accountId: `${platform}_${Date.now()}`,
+            accountName: `${platform}账号`,
+            avatarUrl: '',
+            fansCount: 0,
+            worksCount: 0,
+          },
+        };
+      }
+    } catch (error) {
+      logger.error(`Failed to verify cookie for ${platform}:`, error);
+      return { 
+        success: false, 
+        message: error instanceof Error ? error.message : '验证失败' 
+      };
+    }
+  }
+
+  /**
+   * 将 cookie 字符串解析为 cookie 列表
+   */
+  private parseCookieString(cookieString: string, platform: PlatformType): { 
+    name: string; 
+    value: string; 
+    domain: string; 
+    path: string;
+  }[] {
+    // 获取平台对应的域名
+    const domainMap: Record<string, string> = {
+      douyin: '.douyin.com',
+      kuaishou: '.kuaishou.com',
+      xiaohongshu: '.xiaohongshu.com',
+      weixin_video: '.qq.com',
+      bilibili: '.bilibili.com',
+      toutiao: '.toutiao.com',
+      baijiahao: '.baidu.com',
+      qie: '.qq.com',
+      dayuhao: '.alibaba.com',
+    };
+    
+    const domain = domainMap[platform] || `.${platform}.com`;
+    
+    // 解析 "name=value; name2=value2" 格式的 cookie 字符串
+    const cookies: { name: string; value: string; domain: string; path: string }[] = [];
+    
+    const pairs = cookieString.split(';');
+    for (const pair of pairs) {
+      const trimmed = pair.trim();
+      if (!trimmed) continue;
+      
+      const eqIndex = trimmed.indexOf('=');
+      if (eqIndex === -1) continue;
+      
+      const name = trimmed.substring(0, eqIndex).trim();
+      const value = trimmed.substring(eqIndex + 1).trim();
+      
+      if (name && value) {
+        cookies.push({
+          name,
+          value,
+          domain,
+          path: '/',
+        });
+      }
+    }
+    
+    return cookies;
+  }
+
   private formatGroup(group: AccountGroup): AccountGroupType {
     return {
       id: group.id,

+ 54 - 3
server/src/services/CommentService.ts

@@ -245,13 +245,18 @@ export class CommentService {
           decryptedCookies = account.cookieData;
         }
 
-        // 解析 Cookie
+        // 解析 Cookie - 支持两种格式
         let cookies: CookieData[];
         try {
+          // 先尝试 JSON 格式
           cookies = JSON.parse(decryptedCookies);
         } catch {
-          logger.error(`Invalid cookie format for account ${account.id}`);
-          continue;
+          // 如果 JSON 解析失败,尝试解析 "name=value; name2=value2" 格式
+          cookies = this.parseCookieString(decryptedCookies, account.platform);
+          if (cookies.length === 0) {
+            logger.error(`Invalid cookie format for account ${account.id}`);
+            continue;
+          }
         }
         
         // 获取评论数据
@@ -560,6 +565,52 @@ export class CommentService {
     }
   }
 
+  /**
+   * 将 cookie 字符串解析为 cookie 列表
+   */
+  private parseCookieString(cookieString: string, platform: string): CookieData[] {
+    // 获取平台对应的域名
+    const domainMap: Record<string, string> = {
+      douyin: '.douyin.com',
+      kuaishou: '.kuaishou.com',
+      xiaohongshu: '.xiaohongshu.com',
+      weixin_video: '.qq.com',
+      bilibili: '.bilibili.com',
+      toutiao: '.toutiao.com',
+      baijiahao: '.baidu.com',
+      qie: '.qq.com',
+      dayuhao: '.alibaba.com',
+    };
+    
+    const domain = domainMap[platform] || `.${platform}.com`;
+    
+    // 解析 "name=value; name2=value2" 格式的 cookie 字符串
+    const cookies: CookieData[] = [];
+    
+    const pairs = cookieString.split(';');
+    for (const pair of pairs) {
+      const trimmed = pair.trim();
+      if (!trimmed) continue;
+      
+      const eqIndex = trimmed.indexOf('=');
+      if (eqIndex === -1) continue;
+      
+      const name = trimmed.substring(0, eqIndex).trim();
+      const value = trimmed.substring(eqIndex + 1).trim();
+      
+      if (name && value) {
+        cookies.push({
+          name,
+          value,
+          domain,
+          path: '/',
+        });
+      }
+    }
+    
+    return cookies;
+  }
+
   private formatComment(comment: Comment): CommentType {
     return {
       id: comment.id,

+ 326 - 103
server/src/services/HeadlessBrowserService.ts

@@ -345,7 +345,15 @@ class HeadlessBrowserService {
 
     // 用于存储从 API 捕获的数据
     const capturedData: {
-      userInfo?: { nickname?: string; avatar?: string; uid?: string; sec_uid?: string; follower_count?: number };
+      userInfo?: {
+        nickname?: string;
+        avatar?: string;
+        uid?: string;
+        sec_uid?: string;
+        unique_id?: string;  // 抖音号(如 Ethanfly9392)
+        short_id?: string;   // 短ID
+        follower_count?: number;
+      };
       worksList?: Array<{
         awemeId: string;
         title: string;
@@ -409,22 +417,41 @@ class HeadlessBrowserService {
             }
           }
 
-          // 监听账号信息接口
-          if (url.includes('/account_base_info') || url.includes('/user/info')) {
+          // 监听账号信息接口 - 增加更多可能的接口
+          if (url.includes('/account_base_info') ||
+            url.includes('/user/info') ||
+            url.includes('/creator/user') ||
+            url.includes('/data/overview') ||
+            url.includes('/creator-micro/data') ||
+            url.includes('/home_data')) {
             const data = await response.json();
-            if (data?.user || data?.data?.user) {
-              const user = data.user || data.data?.user || {};
-              capturedData.userInfo = {
-                nickname: user.nickname || user.name,
-                avatar: user.avatar_url || user.avatar_thumb?.url_list?.[0],
-                uid: user.uid || user.user_id,
-                sec_uid: user.sec_uid,
-                follower_count: user.follower_count || user.fans_count,
-              };
-              logger.info(`[Douyin API] user info: nickname=${capturedData.userInfo.nickname}`);
+            logger.info(`[Douyin API] Captured response from: ${url.split('?')[0]}`);
+
+            // 尝试多种数据结构
+            const user = data?.user || data?.data?.user || data?.data || data;
+            if (user) {
+              const nickname = user.nickname || user.name || user.nick_name || user.user_name;
+              const avatar = user.avatar_url || user.avatar_thumb?.url_list?.[0] || user.avatar || user.avatar_larger?.url_list?.[0];
+              const uid = user.uid || user.user_id || user.id;
+              const fans = user.follower_count || user.fans_count || user.mplatform_followers_count;
+              // 获取抖音号(unique_id 或 short_id)
+              const uniqueId = user.unique_id || user.short_id || user.douyin_id;
+
+              if (nickname || uid || uniqueId) {
+                capturedData.userInfo = {
+                  nickname: nickname,
+                  avatar: avatar,
+                  uid: uid,
+                  sec_uid: user.sec_uid,
+                  unique_id: uniqueId,
+                  short_id: user.short_id,
+                  follower_count: fans,
+                };
+                logger.info(`[Douyin API] user info captured: nickname=${capturedData.userInfo.nickname}, uid=${capturedData.userInfo.uid}, unique_id=${capturedData.userInfo.unique_id}`);
+              }
             }
           }
-        } catch {
+        } catch (e) {
           // 忽略非 JSON 响应
         }
       });
@@ -436,8 +463,19 @@ class HeadlessBrowserService {
         timeout: 30000,
       });
 
+      // 等待页面加载完成
       await page.waitForTimeout(3000);
 
+      // 尝试等待网络空闲
+      try {
+        await page.waitForLoadState('networkidle', { timeout: 10000 });
+      } catch {
+        // 超时继续
+      }
+
+      // 额外等待确保 API 响应被捕获
+      await page.waitForTimeout(2000);
+
       // 检查登录状态 - 如果没有从 API 获取到,通过 URL 判断
       if (!isLoggedIn) {
         const currentUrl = page.url();
@@ -453,74 +491,166 @@ class HeadlessBrowserService {
       const accountData = await page.evaluate(() => {
         const result: { name?: string; avatar?: string; fans?: number; douyinId?: string } = {};
 
-        // 提取抖音号
-        const uniqueIdEl = document.querySelector('div[class*="unique_id"]');
-        if (uniqueIdEl) {
-          const text = uniqueIdEl.textContent?.trim() || '';
-          const match = text.match(/抖音号[::]\s*(\S+)/);
-          if (match) {
-            result.douyinId = match[1];
+        // 提取抖音号 - 多种方式
+        // 方式1:通过选择器查找包含抖音号的元素
+        const uniqueIdSelectors = [
+          'div[class*="unique"]',
+          'span[class*="unique"]',
+          'div[class*="douyin-id"]',
+          'span[class*="douyin-id"]',
+          '[class*="account-id"]',
+          '[class*="shortId"]',
+          '[class*="short-id"]',
+        ];
+        for (const selector of uniqueIdSelectors) {
+          const el = document.querySelector(selector);
+          if (el) {
+            const text = el.textContent?.trim() || '';
+            const match = text.match(/抖音号[::]\s*(\S+)/) || text.match(/ID[::]\s*(\S+)/);
+            if (match) {
+              result.douyinId = match[1];
+              break;
+            }
+            // 如果元素文本本身就是抖音号(无前缀)
+            if (text && !text.includes('抖音号') && /^[a-zA-Z0-9_]+$/.test(text)) {
+              result.douyinId = text;
+              break;
+            }
           }
         }
 
-        // 查找头像
-        const avatarImgs = Array.from(document.querySelectorAll('img'));
-        for (const img of avatarImgs) {
-          const src = img.src || img.getAttribute('src') || '';
-          if (src && (src.includes('aweme') || src.includes('douyinpic') || src.includes('bytedance'))) {
-            const parent = img.parentElement;
-            if (parent && img.width < 200) {
-              result.avatar = src;
+        // 方式2:全局搜索包含"抖音号"的文本
+        if (!result.douyinId) {
+          const allElements = Array.from(document.querySelectorAll('span, div, p'));
+          for (const el of allElements) {
+            const text = el.textContent?.trim() || '';
+            // 匹配 "抖音号:xxx" 或 "抖音号: xxx"
+            const match = text.match(/抖音号[::]\s*([a-zA-Z0-9_]+)/);
+            if (match && match[1]) {
+              result.douyinId = match[1];
               break;
             }
           }
         }
 
-        // 获取粉丝数
-        const fansById = document.querySelector('#guide_home_fans');
-        if (fansById) {
-          const numSpan = fansById.querySelector('span[class*="number"]');
-          if (numSpan) {
-            const numText = numSpan.textContent?.trim() || '';
-            const numMatch = numText.match(/^(\d+(?:\.\d+)?)\s*([万wW])?$/);
-            if (numMatch) {
-              let num = parseFloat(numMatch[1]);
-              if (numMatch[2]) num *= 10000;
+        // 查找头像 - 优先使用头像容器
+        const avatarSelectors = [
+          '[class*="avatar"] img',
+          '[class*="user-avatar"] img',
+          '[class*="profile"] img',
+          'img[class*="avatar"]',
+        ];
+        for (const selector of avatarSelectors) {
+          const img = document.querySelector(selector) as HTMLImageElement;
+          if (img?.src && (img.src.includes('aweme') || img.src.includes('douyinpic') || img.src.includes('bytedance'))) {
+            result.avatar = img.src;
+            break;
+          }
+        }
+
+        // 备用方案:查找所有图片
+        if (!result.avatar) {
+          const avatarImgs = Array.from(document.querySelectorAll('img'));
+          for (const img of avatarImgs) {
+            const src = img.src || img.getAttribute('src') || '';
+            if (src && (src.includes('aweme') || src.includes('douyinpic') || src.includes('bytedance'))) {
+              const rect = img.getBoundingClientRect();
+              if (rect.width > 30 && rect.width < 150 && rect.top < 300) {
+                result.avatar = src;
+                break;
+              }
+            }
+          }
+        }
+
+        // 获取粉丝数 - 多种选择器
+        const fansSelectors = [
+          '#guide_home_fans',
+          '[class*="fans"]',
+          '[class*="follower"]',
+          '[class*="data-item"]',
+        ];
+        for (const selector of fansSelectors) {
+          const el = document.querySelector(selector);
+          if (el) {
+            const text = el.textContent?.trim() || '';
+            // 匹配 "123" 或 "1.2万" 或 "粉丝 123"
+            const match = text.match(/(\d+(?:\.\d+)?)\s*([万wW])?/);
+            if (match) {
+              let num = parseFloat(match[1]);
+              if (match[2]) num *= 10000;
               result.fans = Math.floor(num);
+              break;
             }
           }
         }
 
-        // 查找用户名
-        const nameContainers = Array.from(document.querySelectorAll('[class*="name"], [class*="nick"], [class*="user"]'));
-        for (const container of nameContainers) {
-          const text = container.textContent?.trim() || '';
-          if (text &&
-            text.length >= 2 &&
-            text.length <= 20 &&
-            !text.includes('关注') &&
-            !text.includes('粉丝') &&
-            !text.includes('获赞') &&
-            !text.includes('加载') &&
-            !text.includes('创作') &&
-            !text.includes('发布') &&
-            !text.match(/^\d+$/)) {
-            const rect = container.getBoundingClientRect();
-            if (rect.top < 400 && rect.left < 500) {
+        // 查找用户名 - 更精确的选择器
+        const nameSelectors = [
+          '[class*="user-name"]',
+          '[class*="nickname"]',
+          '[class*="author-name"]',
+          '[class*="profile-name"]',
+          'h1[class*="name"]',
+          'h2[class*="name"]',
+        ];
+
+        for (const selector of nameSelectors) {
+          const el = document.querySelector(selector);
+          if (el) {
+            const text = el.textContent?.trim() || '';
+            if (text && text.length >= 2 && text.length <= 30) {
               result.name = text;
               break;
             }
           }
         }
 
+        // 备用方案:查找包含名字的容器
+        if (!result.name) {
+          const nameContainers = Array.from(document.querySelectorAll('[class*="name"], [class*="nick"], [class*="user"]'));
+          for (const container of nameContainers) {
+            const text = container.textContent?.trim() || '';
+            if (text &&
+              text.length >= 2 &&
+              text.length <= 20 &&
+              !text.includes('关注') &&
+              !text.includes('粉丝') &&
+              !text.includes('获赞') &&
+              !text.includes('加载') &&
+              !text.includes('创作') &&
+              !text.includes('发布') &&
+              !text.includes('抖音号') &&
+              !text.match(/^\d+$/)) {
+              const rect = container.getBoundingClientRect();
+              if (rect.top < 400 && rect.left < 500) {
+                result.name = text;
+                break;
+              }
+            }
+          }
+        }
+
         return result;
       });
 
-      // 优先使用 API 数据,否则使用页面数据
-      if (capturedData.userInfo?.uid) {
-        accountId = `douyin_${capturedData.userInfo.uid}`;
+      // 优先使用抖音号作为 ID(unique_id),其次是页面提取的抖音号,最后是 uid
+      if (capturedData.userInfo?.unique_id) {
+        // 优先使用抖音号(如 Ethanfly9392)
+        accountId = `douyin_${capturedData.userInfo.unique_id}`;
+        logger.info(`[Douyin] Using unique_id as accountId: ${accountId}`);
+      } else if (capturedData.userInfo?.short_id) {
+        // 其次使用短ID
+        accountId = `douyin_${capturedData.userInfo.short_id}`;
+        logger.info(`[Douyin] Using short_id as accountId: ${accountId}`);
       } else if (accountData.douyinId) {
+        // 使用页面提取的抖音号
         accountId = `douyin_${accountData.douyinId}`;
+        logger.info(`[Douyin] Using page douyinId as accountId: ${accountId}`);
+      } else if (capturedData.userInfo?.uid) {
+        // 最后使用内部uid
+        accountId = `douyin_${capturedData.userInfo.uid}`;
+        logger.info(`[Douyin] Using uid as accountId: ${accountId}`);
       }
 
       accountName = capturedData.userInfo?.nickname || accountData.name || accountName;
@@ -1514,71 +1644,164 @@ class HeadlessBrowserService {
     return allWorkComments;
   }
 
+
+  /**
+   * 直接调用评论 API 获取数据(支持分页获取所有评论)
+   * 优先使用创作者评论 API(更快),失败时才使用视频页面 API
+   */
+  private async fetchCommentsDirectApi(page: Page, awemeId: string): Promise<CommentItem[]> {
+    // 优先尝试创作者评论 API(不需要导航,更快)
+    logger.info(`[DirectAPI] Fetching comments for ${awemeId} via creator API...`);
+    let comments = await this.fetchCreatorCommentsDirectApi(page, awemeId);
+
+    if (comments.length > 0) {
+      logger.info(`[DirectAPI] Got ${comments.length} comments via creator API`);
+      return comments;
+    }
+
+    // 如果创作者 API 失败,尝试视频页面 API
+    logger.info(`[DirectAPI] Creator API returned 0 comments, trying video page API...`);
+    comments = await this.fetchVideoPageCommentsApi(page, awemeId);
+
+    return comments;
+  }
+
   /**
-   * 从页面获取当前作品的 videoId
+   * 通过视频页面获取评论(备用方案,较慢)
    */
-  private async getCurrentVideoIdFromPage(page: Page): Promise<string | null> {
+  private async fetchVideoPageCommentsApi(page: Page, awemeId: string): Promise<CommentItem[]> {
+    const comments: CommentItem[] = [];
+    const maxPages = 50;
+    let cursor = 0;
+    let hasMore = true;
+    let pageCount = 0;
+
     try {
-      // 尝试从页面 URL 或 DOM 中提取 aweme_id
-      const videoId = await page.evaluate(() => {
-        // 方法1: 从 URL 中提取
-        const url = window.location.href;
-        const urlMatch = url.match(/aweme_id=(\d+)/);
-        if (urlMatch) return urlMatch[1];
-
-        // 方法2: 从页面元素中提取 (如果有的话)
-        const dataEl = document.querySelector('[data-aweme-id]');
-        if (dataEl) {
-          return dataEl.getAttribute('data-aweme-id');
+      // 导航到视频页面
+      logger.info(`[VideoPageAPI] Navigating to video page for ${awemeId}...`);
+      await page.goto(`https://www.douyin.com/video/${awemeId}`, {
+        waitUntil: 'domcontentloaded',
+        timeout: 30000,
+      });
+      await page.waitForTimeout(2000);
+
+      while (hasMore && pageCount < maxPages) {
+        pageCount++;
+
+        const data = await page.evaluate(async ({ videoId, cursorValue }) => {
+          try {
+            const url = `https://www.douyin.com/aweme/v1/web/comment/list/?aweme_id=${videoId}&cursor=${cursorValue}&count=50&item_type=0&insert_ids=&whale_cut_token=&cut_version=1&rcFT=&aid=6383&device_platform=web_pc&verifyFp=&fp=&msToken=`;
+
+            const resp = await fetch(url, {
+              method: 'GET',
+              credentials: 'include',
+              headers: {
+                'Accept': 'application/json, text/plain, */*',
+                'Referer': `https://www.douyin.com/video/${videoId}`,
+              },
+            });
+
+            if (!resp.ok) {
+              return { error: `HTTP ${resp.status}`, comments: [] };
+            }
+
+            return resp.json();
+          } catch (e) {
+            return { error: String(e), comments: [] };
+          }
+        }, { videoId: awemeId, cursorValue: cursor });
+
+        if (data?.error) {
+          logger.warn(`[VideoPageAPI] API error for ${awemeId}: ${data.error}`);
         }
 
-        return null;
-      });
+        if (data?.comments && Array.isArray(data.comments)) {
+          for (const c of data.comments) {
+            comments.push({
+              commentId: String(c.cid || ''),
+              authorId: String(c.user?.uid || c.user?.sec_uid || ''),
+              authorName: String(c.user?.nickname || '匿名'),
+              authorAvatar: c.user?.avatar_thumb?.url_list?.[0] || '',
+              content: String(c.text || ''),
+              likeCount: Number(c.digg_count || 0),
+              commentTime: new Date(Number(c.create_time || 0) * 1000).toISOString(),
+              videoId: String(c.aweme_id || awemeId),
+            });
+          }
+          logger.info(`[VideoPageAPI] Page ${pageCount}: got ${data.comments.length} comments for ${awemeId}`);
+        }
 
-      return videoId;
-    } catch {
-      return null;
+        hasMore = data?.has_more === true || data?.has_more === 1;
+        cursor = data?.cursor || cursor + 50;
+
+        if (!data?.comments || data.comments.length === 0) {
+          hasMore = false;
+        }
+      }
+
+      logger.info(`[VideoPageAPI] Total fetched ${comments.length} comments for ${awemeId} from ${pageCount} pages`);
+    } catch (e) {
+      logger.warn(`[VideoPageAPI] Failed to fetch comments for ${awemeId}:`, e);
     }
+
+    return comments;
   }
 
   /**
-   * 直接调用评论 API 获取数据
+   * 创作者评论管理 API(备用方案)
+   * 获取的是需要回复的评论
    */
-  private async fetchCommentsDirectApi(page: Page, awemeId: string): Promise<CommentItem[]> {
+  private async fetchCreatorCommentsDirectApi(page: Page, awemeId: string): Promise<CommentItem[]> {
     const comments: CommentItem[] = [];
+    const maxPages = 50;
+    let cursor = 0;
+    let hasMore = true;
+    let pageCount = 0;
 
     try {
-      // 使用 page.evaluate 在页面上下文中调用 fetch API
-      const data = await page.evaluate(async (videoId) => {
-        const url = `https://creator.douyin.com/web/api/third_party/aweme/api/comment/read/aweme/v1/web/comment/list/select/?aweme_id=${videoId}&cursor=0&count=50&comment_select_options=0&sort_options=0&channel_id=618&app_id=2906&aid=2906&device_platform=webapp`;
-
-        const resp = await fetch(url, {
-          credentials: 'include',
-          headers: {
-            'Accept': 'application/json',
-          },
-        });
+      while (hasMore && pageCount < maxPages) {
+        pageCount++;
 
-        return resp.json();
-      }, awemeId);
+        const data = await page.evaluate(async ({ videoId, cursorValue }) => {
+          const url = `https://creator.douyin.com/web/api/third_party/aweme/api/comment/read/aweme/v1/web/comment/list/select/?aweme_id=${videoId}&cursor=${cursorValue}&count=50&comment_select_options=0&sort_options=0&channel_id=618&app_id=2906&aid=2906&device_platform=webapp`;
 
-      if (data?.comments && Array.isArray(data.comments)) {
-        for (const c of data.comments) {
-          comments.push({
-            commentId: String(c.cid || ''),
-            authorId: String(c.user?.uid || ''),
-            authorName: String(c.user?.nickname || '匿名'),
-            authorAvatar: c.user?.avatar_thumb?.url_list?.[0] || '',
-            content: String(c.text || ''),
-            likeCount: Number(c.digg_count || 0),
-            commentTime: new Date(Number(c.create_time || 0) * 1000).toISOString(),
-            videoId: String(c.aweme_id || awemeId),
+          const resp = await fetch(url, {
+            credentials: 'include',
+            headers: {
+              'Accept': 'application/json',
+            },
           });
+
+          return resp.json();
+        }, { videoId: awemeId, cursorValue: cursor });
+
+        if (data?.comments && Array.isArray(data.comments)) {
+          for (const c of data.comments) {
+            comments.push({
+              commentId: String(c.cid || ''),
+              authorId: String(c.user?.uid || ''),
+              authorName: String(c.user?.nickname || '匿名'),
+              authorAvatar: c.user?.avatar_thumb?.url_list?.[0] || '',
+              content: String(c.text || ''),
+              likeCount: Number(c.digg_count || 0),
+              commentTime: new Date(Number(c.create_time || 0) * 1000).toISOString(),
+              videoId: String(c.aweme_id || awemeId),
+            });
+          }
+          logger.info(`[CreatorAPI] Page ${pageCount}: got ${data.comments.length} comments for ${awemeId}`);
+        }
+
+        hasMore = data?.has_more === true || data?.has_more === 1;
+        cursor = data?.cursor || cursor + 50;
+
+        if (!data?.comments || data.comments.length === 0) {
+          hasMore = false;
         }
-        logger.info(`[DirectAPI] Fetched ${comments.length} comments for ${awemeId}`);
       }
+
+      logger.info(`[CreatorAPI] Total fetched ${comments.length} comments for ${awemeId} from ${pageCount} pages`);
     } catch (e) {
-      logger.warn(`[DirectAPI] Failed to fetch comments for ${awemeId}:`, e);
+      logger.warn(`[CreatorAPI] Failed to fetch comments for ${awemeId}:`, e);
     }
 
     return comments;

+ 79 - 4
server/src/services/WorkService.ts

@@ -88,6 +88,12 @@ export class WorkService {
    * 同步账号的作品
    */
   async syncWorks(userId: number, accountId?: number): Promise<{ synced: number; accounts: number }> {
+    logger.info(`[SyncWorks] Starting sync for userId: ${userId}, accountId: ${accountId || 'all'}`);
+    
+    // 先查看所有账号(调试用)
+    const allAccounts = await this.accountRepository.find({ where: { userId } });
+    logger.info(`[SyncWorks] All accounts for user ${userId}: ${allAccounts.map(a => `id=${a.id},status=${a.status},platform=${a.platform}`).join('; ')}`);
+    
     const queryBuilder = this.accountRepository
       .createQueryBuilder('account')
       .where('account.userId = :userId', { userId })
@@ -98,19 +104,24 @@ export class WorkService {
     }
 
     const accounts = await queryBuilder.getMany();
+    logger.info(`[SyncWorks] Found ${accounts.length} active accounts`);
+    
     let totalSynced = 0;
     let accountCount = 0;
 
     for (const account of accounts) {
       try {
+        logger.info(`[SyncWorks] Syncing account ${account.id} (${account.platform})`);
         const synced = await this.syncAccountWorks(userId, account);
         totalSynced += synced;
         accountCount++;
+        logger.info(`[SyncWorks] Account ${account.id} synced ${synced} works`);
       } catch (error) {
         logger.error(`Failed to sync works for account ${account.id}:`, error);
       }
     }
 
+    logger.info(`[SyncWorks] Complete: ${totalSynced} works synced from ${accountCount} accounts`);
     return { synced: totalSynced, accounts: accountCount };
   }
 
@@ -118,6 +129,8 @@ export class WorkService {
    * 同步单个账号的作品
    */
   private async syncAccountWorks(userId: number, account: PlatformAccount): Promise<number> {
+    logger.info(`[SyncAccountWorks] Starting for account ${account.id} (${account.platform})`);
+    
     if (!account.cookieData) {
       logger.warn(`Account ${account.id} has no cookie data`);
       return 0;
@@ -127,22 +140,33 @@ export class WorkService {
     let decryptedCookies: string;
     try {
       decryptedCookies = CookieManager.decrypt(account.cookieData);
+      logger.info(`[SyncAccountWorks] Cookie decrypted successfully`);
     } catch {
       decryptedCookies = account.cookieData;
+      logger.info(`[SyncAccountWorks] Using raw cookie data`);
     }
 
-    // 解析 Cookie
+    // 解析 Cookie - 支持两种格式
+    const platform = account.platform as PlatformType;
     let cookieList: { name: string; value: string; domain: string; path: string }[];
     try {
+      // 先尝试 JSON 格式
       cookieList = JSON.parse(decryptedCookies);
+      logger.info(`[SyncAccountWorks] Parsed ${cookieList.length} cookies from JSON format`);
     } catch {
-      logger.error(`Invalid cookie format for account ${account.id}`);
-      return 0;
+      // 如果 JSON 解析失败,尝试解析 "name=value; name2=value2" 格式
+      cookieList = this.parseCookieString(decryptedCookies, platform);
+      logger.info(`[SyncAccountWorks] Parsed ${cookieList.length} cookies from string format`);
+      if (cookieList.length === 0) {
+        logger.error(`Invalid cookie format for account ${account.id}`);
+        return 0;
+      }
     }
 
     // 获取作品列表
-    const platform = account.platform as PlatformType;
+    logger.info(`[SyncAccountWorks] Fetching account info from ${platform}...`);
     const accountInfo = await headlessBrowserService.fetchAccountInfo(platform, cookieList);
+    logger.info(`[SyncAccountWorks] Got ${accountInfo.worksList?.length || 0} works from API`);
 
     let syncedCount = 0;
     
@@ -336,6 +360,57 @@ export class WorkService {
   }
 
   /**
+   * 将 cookie 字符串解析为 cookie 列表
+   */
+  private parseCookieString(cookieString: string, platform: PlatformType): { 
+    name: string; 
+    value: string; 
+    domain: string; 
+    path: string;
+  }[] {
+    // 获取平台对应的域名
+    const domainMap: Record<string, string> = {
+      douyin: '.douyin.com',
+      kuaishou: '.kuaishou.com',
+      xiaohongshu: '.xiaohongshu.com',
+      weixin_video: '.qq.com',
+      bilibili: '.bilibili.com',
+      toutiao: '.toutiao.com',
+      baijiahao: '.baidu.com',
+      qie: '.qq.com',
+      dayuhao: '.alibaba.com',
+    };
+    
+    const domain = domainMap[platform] || `.${platform}.com`;
+    
+    // 解析 "name=value; name2=value2" 格式的 cookie 字符串
+    const cookies: { name: string; value: string; domain: string; path: string }[] = [];
+    
+    const pairs = cookieString.split(';');
+    for (const pair of pairs) {
+      const trimmed = pair.trim();
+      if (!trimmed) continue;
+      
+      const eqIndex = trimmed.indexOf('=');
+      if (eqIndex === -1) continue;
+      
+      const name = trimmed.substring(0, eqIndex).trim();
+      const value = trimmed.substring(eqIndex + 1).trim();
+      
+      if (name && value) {
+        cookies.push({
+          name,
+          value,
+          domain,
+          path: '/',
+        });
+      }
+    }
+    
+    return cookies;
+  }
+
+  /**
    * 格式化作品
    */
   private formatWork(work: Work): WorkType {

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů