Kaynağa Gözat

Refactor client and server code to support batch account status refresh and task management. Updated package configuration, switched to CommonJS in Electron files, and added new API endpoints for refreshing accounts and managing tasks. Enhanced UI with task indicators and dialogs for better user experience. Improved error handling and logging throughout the application.

Ethanfly 1 ay önce
ebeveyn
işleme
2a99ffe357
41 değiştirilmiş dosya ile 6549 ekleme ve 300 silme
  1. 5 6
      client/dist-electron/main.js
  2. 0 0
      client/dist-electron/main.js.map
  3. 2 1
      client/dist-electron/preload.js
  4. 5 10
      client/electron/main.ts
  5. 7 6
      client/electron/preload.ts
  6. 0 1
      client/package.json
  7. 5 0
      client/src/api/accounts.ts
  8. 2 0
      client/src/components.d.ts
  9. 246 0
      client/src/components/CaptchaDialog.vue
  10. 376 0
      client/src/components/TaskProgressDialog.vue
  11. 80 1
      client/src/layouts/MainLayout.vue
  12. 24 0
      client/src/stores/auth.ts
  13. 370 0
      client/src/stores/taskQueue.ts
  14. 44 29
      client/src/views/Accounts/index.vue
  15. 343 12
      client/src/views/Publish/index.vue
  16. 441 18
      client/src/views/Works/index.vue
  17. 6 0
      client/vite.config.ts
  18. 1 0
      matrix
  19. 3 1
      server/src/app.ts
  20. 126 26
      server/src/automation/browser.ts
  21. 28 2
      server/src/automation/platforms/base.ts
  22. 940 42
      server/src/automation/platforms/douyin.ts
  23. 10 0
      server/src/models/entities/Comment.ts
  24. 12 0
      server/src/routes/accounts.ts
  25. 25 1
      server/src/routes/comments.ts
  26. 3 0
      server/src/routes/index.ts
  27. 47 2
      server/src/routes/publish.ts
  28. 82 0
      server/src/routes/tasks.ts
  29. 53 6
      server/src/routes/works.ts
  30. 8 2
      server/src/scheduler/index.ts
  31. 25 0
      server/src/services/AccountService.ts
  32. 429 2
      server/src/services/CommentService.ts
  33. 1720 114
      server/src/services/HeadlessBrowserService.ts
  34. 322 10
      server/src/services/PublishService.ts
  35. 265 0
      server/src/services/TaskQueueService.ts
  36. 117 8
      server/src/services/WorkService.ts
  37. 193 0
      server/src/services/taskExecutors.ts
  38. 44 0
      server/src/websocket/index.ts
  39. 16 0
      shared/src/constants/api.ts
  40. 1 0
      shared/src/types/index.ts
  41. 123 0
      shared/src/types/task.ts

+ 5 - 6
client/dist-electron/main.js

@@ -1,7 +1,6 @@
-import { app, BrowserWindow, ipcMain, shell } from "electron";
-import { join } from "path";
-import { fileURLToPath } from "url";
-const __dirname$1 = fileURLToPath(new URL(".", import.meta.url));
+"use strict";
+const { app, BrowserWindow, ipcMain, shell } = require("electron");
+const { join } = require("path");
 let mainWindow = null;
 const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL;
 function createWindow() {
@@ -11,7 +10,7 @@ function createWindow() {
     minWidth: 1200,
     minHeight: 700,
     webPreferences: {
-      preload: join(__dirname$1, "preload.js"),
+      preload: join(__dirname, "preload.js"),
       nodeIntegration: false,
       contextIsolation: true
     },
@@ -26,7 +25,7 @@ function createWindow() {
     mainWindow.loadURL(VITE_DEV_SERVER_URL);
     mainWindow.webContents.openDevTools();
   } else {
-    mainWindow.loadFile(join(__dirname$1, "../dist/index.html"));
+    mainWindow.loadFile(join(__dirname, "../dist/index.html"));
   }
   mainWindow.webContents.setWindowOpenHandler(({ url }) => {
     shell.openExternal(url);

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
client/dist-electron/main.js.map


Dosya farkı çok büyük olduğundan ihmal edildi
+ 2 - 1
client/dist-electron/preload.js


+ 5 - 10
client/electron/main.ts

@@ -1,13 +1,8 @@
-import { app, BrowserWindow, ipcMain, shell } from 'electron';
-import { join } from 'path';
-import { fileURLToPath } from 'url';
+// 使用 CommonJS 格式
+const { app, BrowserWindow, ipcMain, shell } = require('electron');
+const { join } = require('path');
 
-const __dirname = fileURLToPath(new URL('.', import.meta.url));
-
-// 禁用硬件加速(解决某些系统上的兼容性问题)
-// app.disableHardwareAcceleration();
-
-let mainWindow: BrowserWindow | null = null;
+let mainWindow: typeof BrowserWindow.prototype | null = null;
 
 const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL;
 
@@ -41,7 +36,7 @@ function createWindow() {
   }
 
   // 处理外部链接
-  mainWindow.webContents.setWindowOpenHandler(({ url }) => {
+  mainWindow.webContents.setWindowOpenHandler(({ url }: { url: string }) => {
     shell.openExternal(url);
     return { action: 'deny' };
   });

+ 7 - 6
client/electron/preload.ts

@@ -1,23 +1,24 @@
-import { contextBridge, ipcRenderer } from 'electron';
+// 使用 require 避免 ESM 问题
+const { contextBridge, ipcRenderer } = require('electron');
 
 // 暴露给渲染进程的 API
 contextBridge.exposeInMainWorld('electronAPI', {
   // 应用信息
   getAppVersion: () => ipcRenderer.invoke('get-app-version'),
   getPlatform: () => ipcRenderer.invoke('get-platform'),
-  
+
   // 窗口控制
   minimizeWindow: () => ipcRenderer.send('window-minimize'),
   maximizeWindow: () => ipcRenderer.send('window-maximize'),
   closeWindow: () => ipcRenderer.send('window-close'),
-  
+
   // 文件操作
-  selectFile: (options?: { filters?: { name: string; extensions: string[] }[] }) => 
+  selectFile: (options?: { filters?: { name: string; extensions: string[] }[] }) =>
     ipcRenderer.invoke('select-file', options),
   selectFolder: () => ipcRenderer.invoke('select-folder'),
-  
+
   // 通知
-  showNotification: (title: string, body: string) => 
+  showNotification: (title: string, body: string) =>
     ipcRenderer.send('show-notification', { title, body }),
 });
 

+ 0 - 1
client/package.json

@@ -2,7 +2,6 @@
   "name": "@media-manager/client",
   "version": "1.0.0",
   "description": "多自媒体平台管理系统桌面客户端",
-  "type": "module",
   "main": "dist-electron/main.js",
   "scripts": {
     "dev": "vite",

+ 5 - 0
client/src/api/accounts.ts

@@ -55,6 +55,11 @@ export const accountsApi = {
     });
   },
 
+  // 批量刷新所有账号状态
+  refreshAllAccounts(): Promise<{ refreshed: number; failed: number }> {
+    return request.post('/api/accounts/refresh-all');
+  },
+
   // 检查账号 Cookie 是否有效
   checkAccountStatus(id: number): Promise<{ isValid: boolean; needReLogin: boolean }> {
     return request.get(`/api/accounts/${id}/check-status`);

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

@@ -7,6 +7,7 @@ export {}
 
 declare module 'vue' {
   export interface GlobalComponents {
+    CaptchaDialog: typeof import('./components/CaptchaDialog.vue')['default']
     ElAlert: typeof import('element-plus/es')['ElAlert']
     ElAside: typeof import('element-plus/es')['ElAside']
     ElAvatar: typeof import('element-plus/es')['ElAvatar']
@@ -54,6 +55,7 @@ declare module 'vue' {
     ElUpload: typeof import('element-plus/es')['ElUpload']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']
+    TaskProgressDialog: typeof import('./components/TaskProgressDialog.vue')['default']
   }
   export interface ComponentCustomProperties {
     vLoading: typeof import('element-plus/es')['ElLoadingDirective']

+ 246 - 0
client/src/components/CaptchaDialog.vue

@@ -0,0 +1,246 @@
+<template>
+  <el-dialog
+    v-model="visible"
+    :title="dialogTitle"
+    width="420px"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    :show-close="false"
+  >
+    <div class="captcha-content">
+      <!-- 短信验证码 -->
+      <template v-if="type === 'sms'">
+        <el-icon class="captcha-icon"><Message /></el-icon>
+        <p class="captcha-desc">
+          为确保是本人操作抖音账号,请输入手机号 
+          <span class="phone">{{ phone || '***' }}</span> 
+          收到的短信验证码
+        </p>
+        <p class="captcha-tip">验证码已发送,请注意查收短信</p>
+      </template>
+      
+      <!-- 图形验证码 -->
+      <template v-else-if="type === 'image'">
+        <el-icon class="captcha-icon"><Picture /></el-icon>
+        <p class="captcha-desc">
+          为保护帐号安全,请根据图片输入验证码
+        </p>
+        <div class="captcha-image-wrapper" v-if="imageBase64">
+          <img :src="imageBase64" alt="验证码" class="captcha-image" />
+          <el-button 
+            text 
+            type="primary" 
+            size="small" 
+            class="refresh-btn"
+            @click="$emit('refresh')"
+          >
+            <el-icon><Refresh /></el-icon>
+            看不清?刷新
+          </el-button>
+        </div>
+        <div v-else class="captcha-browser-notice">
+          <el-icon class="browser-icon"><Monitor /></el-icon>
+          <p class="notice-title">请在浏览器窗口中完成验证</p>
+          <p class="notice-desc">系统已打开浏览器窗口,请直接在浏览器中输入验证码</p>
+          <p class="notice-tip">完成后此弹框会自动关闭</p>
+        </div>
+      </template>
+      
+      <!-- 只有在有图片或者是短信验证码时才显示输入框 -->
+      <template v-if="type === 'sms' || imageBase64">
+        <el-input
+          v-model="captchaCode"
+          :placeholder="inputPlaceholder"
+          :maxlength="type === 'sms' ? 6 : 10"
+          class="captcha-input"
+          @keyup.enter="handleSubmit"
+        >
+          <template #prefix>
+            <el-icon><Lock /></el-icon>
+          </template>
+        </el-input>
+      </template>
+    </div>
+    
+    <template #footer>
+      <!-- 浏览器窗口模式只显示关闭按钮 -->
+      <template v-if="type === 'image' && !imageBase64">
+        <el-button @click="handleCancel">关闭提示</el-button>
+      </template>
+      <template v-else>
+        <el-button @click="handleCancel">取消</el-button>
+        <el-button 
+          type="primary" 
+          @click="handleSubmit"
+          :disabled="!captchaCode || captchaCode.length < minCodeLength"
+        >
+          确认
+        </el-button>
+      </template>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { ref, watch, computed } from 'vue';
+import { Message, Lock, Picture, Refresh, Monitor } from '@element-plus/icons-vue';
+import { ElMessage } from 'element-plus';
+
+const props = defineProps<{
+  modelValue: boolean;
+  captchaTaskId: string;
+  type?: 'sms' | 'image';
+  phone?: string;
+  imageBase64?: string;
+}>();
+
+const emit = defineEmits<{
+  (e: 'update:modelValue', value: boolean): void;
+  (e: 'submit', captchaTaskId: string, code: string): void;
+  (e: 'cancel'): void;
+  (e: 'refresh'): void;
+}>();
+
+const visible = ref(false);
+const captchaCode = ref('');
+
+const captchaType = computed(() => props.type || 'sms');
+
+const dialogTitle = computed(() => {
+  return captchaType.value === 'sms' ? '短信验证码' : '图形验证码';
+});
+
+const inputPlaceholder = computed(() => {
+  return captchaType.value === 'sms' ? '请输入短信验证码' : '请输入图片中的验证码';
+});
+
+const minCodeLength = computed(() => {
+  return captchaType.value === 'sms' ? 4 : 1;
+});
+
+watch(() => props.modelValue, (val) => {
+  visible.value = val;
+  if (val) {
+    captchaCode.value = '';
+  }
+});
+
+watch(visible, (val) => {
+  emit('update:modelValue', val);
+});
+
+function handleSubmit() {
+  if (!captchaCode.value || captchaCode.value.length < minCodeLength.value) {
+    ElMessage.warning('请输入正确的验证码');
+    return;
+  }
+  
+  emit('submit', props.captchaTaskId, captchaCode.value);
+  visible.value = false;
+}
+
+function handleCancel() {
+  emit('cancel');
+  visible.value = false;
+}
+</script>
+
+<style lang="scss" scoped>
+.captcha-content {
+  text-align: center;
+  padding: 20px 0;
+  
+  .captcha-icon {
+    font-size: 48px;
+    color: var(--el-color-primary);
+    margin-bottom: 16px;
+  }
+  
+  .captcha-desc {
+    font-size: 14px;
+    color: #666;
+    margin-bottom: 16px;
+    line-height: 1.6;
+    
+    .phone {
+      font-weight: bold;
+      color: var(--el-color-primary);
+    }
+  }
+  
+  .captcha-image-wrapper {
+    margin: 16px 0;
+    
+    .captcha-image {
+      max-width: 100%;
+      max-height: 150px;
+      border: 1px solid #e4e7ed;
+      border-radius: 4px;
+      display: block;
+      margin: 0 auto;
+    }
+    
+    .refresh-btn {
+      margin-top: 8px;
+    }
+  }
+  
+  .captcha-browser-notice {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    gap: 8px;
+    padding: 30px 0;
+    background: linear-gradient(135deg, #e3f2fd 0%, #f3e5f5 100%);
+    border-radius: 8px;
+    margin: 16px 0;
+    
+    .browser-icon {
+      font-size: 48px;
+      color: var(--el-color-primary);
+    }
+    
+    .notice-title {
+      font-size: 16px;
+      font-weight: 600;
+      color: #333;
+      margin: 8px 0 0 0;
+    }
+    
+    .notice-desc {
+      font-size: 14px;
+      color: #666;
+      margin: 4px 0 0 0;
+    }
+    
+    .notice-tip {
+      font-size: 12px;
+      color: #999;
+      margin: 8px 0 0 0;
+    }
+  }
+  
+  .captcha-input {
+    width: 220px;
+    margin-top: 16px;
+    
+    :deep(.el-input__inner) {
+      text-align: center;
+      font-size: 18px;
+      letter-spacing: 4px;
+    }
+  }
+  
+  .captcha-tip {
+    font-size: 12px;
+    color: #999;
+    margin-top: 12px;
+  }
+}
+
+@keyframes spin {
+  from { transform: rotate(0deg); }
+  to { transform: rotate(360deg); }
+}
+</style>

+ 376 - 0
client/src/components/TaskProgressDialog.vue

@@ -0,0 +1,376 @@
+<script setup lang="ts">
+import { computed } from 'vue';
+import { ElDialog, ElProgress, ElTag, ElButton, ElEmpty, ElScrollbar } from 'element-plus';
+import { 
+  Loading, 
+  Check, 
+  Close, 
+  Delete,
+  ChatDotRound,
+  VideoCamera,
+  User,
+  Upload,
+  ChatLineSquare,
+} from '@element-plus/icons-vue';
+import { useTaskQueueStore } from '@/stores/taskQueue';
+import type { Task, TaskType, TaskStatus } from '@media-manager/shared';
+import dayjs from 'dayjs';
+
+const taskStore = useTaskQueueStore();
+
+// 图标映射
+const iconMap: Record<TaskType, typeof ChatDotRound> = {
+  sync_comments: ChatDotRound,
+  sync_works: VideoCamera,
+  sync_account: User,
+  publish_video: Upload,
+  batch_reply: ChatLineSquare,
+  delete_work: Delete,
+};
+
+// 状态配置
+const statusConfig: Record<TaskStatus, { text: string; type: 'info' | 'warning' | 'success' | 'danger' }> = {
+  pending: { text: '等待中', type: 'info' },
+  running: { text: '执行中', type: 'warning' },
+  completed: { text: '已完成', type: 'success' },
+  failed: { text: '失败', type: 'danger' },
+  cancelled: { text: '已取消', type: 'info' },
+};
+
+// 按时间倒序排序(最新的在前)
+const sortByTimeDesc = (a: Task, b: Task) => {
+  const timeA = new Date(a.createdAt).getTime();
+  const timeB = new Date(b.createdAt).getTime();
+  return timeB - timeA;
+};
+
+// 按状态分组任务,每组内按时间倒序排列
+const groupedTasks = computed(() => {
+  const running = taskStore.tasks
+    .filter(t => t.status === 'running')
+    .sort(sortByTimeDesc);
+  const pending = taskStore.tasks
+    .filter(t => t.status === 'pending')
+    .sort(sortByTimeDesc);
+  const completed = taskStore.tasks
+    .filter(t => t.status === 'completed' || t.status === 'failed' || t.status === 'cancelled')
+    .sort(sortByTimeDesc);
+  return { running, pending, completed };
+});
+
+function getIcon(type: TaskType) {
+  return iconMap[type] || ChatDotRound;
+}
+
+function getStatusConfig(status: TaskStatus) {
+  return statusConfig[status] || statusConfig.pending;
+}
+
+function formatTime(time?: string) {
+  if (!time) return '-';
+  return dayjs(time).format('HH:mm:ss');
+}
+
+function handleCancel(task: Task) {
+  taskStore.cancelTask(task.id);
+}
+
+function handleClose() {
+  taskStore.closeDialog();
+}
+
+function handleClearCompleted() {
+  taskStore.clearCompletedTasks();
+}
+</script>
+
+<template>
+  <ElDialog
+    :model-value="taskStore.isDialogVisible"
+    title="任务队列"
+    width="560px"
+    :close-on-click-modal="true"
+    :close-on-press-escape="true"
+    @close="handleClose"
+  >
+    <div class="task-dialog-content">
+      <!-- 空状态 -->
+      <ElEmpty 
+        v-if="taskStore.tasks.length === 0" 
+        description="暂无任务"
+        :image-size="80"
+      />
+
+      <ElScrollbar v-else max-height="400px">
+        <!-- 执行中的任务 -->
+        <div v-if="groupedTasks.running.length > 0" class="task-group">
+          <div class="task-group-title">
+            <el-icon class="spin-icon"><Loading /></el-icon>
+            执行中 ({{ groupedTasks.running.length }})
+          </div>
+          <div 
+            v-for="task in groupedTasks.running" 
+            :key="task.id" 
+            class="task-item running"
+          >
+            <div class="task-header">
+              <div class="task-info">
+                <el-icon class="task-icon"><component :is="getIcon(task.type)" /></el-icon>
+                <span class="task-title">{{ task.title }}</span>
+              </div>
+              <ElTag size="small" :type="getStatusConfig(task.status).type">
+                {{ getStatusConfig(task.status).text }}
+              </ElTag>
+            </div>
+            <div class="task-progress">
+              <ElProgress 
+                :percentage="task.progress || 0" 
+                :stroke-width="8"
+                :show-text="true"
+              />
+            </div>
+            <div v-if="task.currentStep" class="task-step">
+              {{ task.currentStep }}
+            </div>
+          </div>
+        </div>
+
+        <!-- 等待中的任务 -->
+        <div v-if="groupedTasks.pending.length > 0" class="task-group">
+          <div class="task-group-title">
+            等待中 ({{ groupedTasks.pending.length }})
+          </div>
+          <div 
+            v-for="task in groupedTasks.pending" 
+            :key="task.id" 
+            class="task-item pending"
+          >
+            <div class="task-header">
+              <div class="task-info">
+                <el-icon class="task-icon"><component :is="getIcon(task.type)" /></el-icon>
+                <span class="task-title">{{ task.title }}</span>
+              </div>
+              <div class="task-actions">
+                <ElTag size="small" type="info">等待中</ElTag>
+                <ElButton 
+                  size="small" 
+                  type="danger" 
+                  text 
+                  @click="handleCancel(task)"
+                >
+                  取消
+                </ElButton>
+              </div>
+            </div>
+            <div class="task-meta">
+              创建于 {{ formatTime(task.createdAt) }}
+            </div>
+          </div>
+        </div>
+
+        <!-- 已完成的任务 -->
+        <div v-if="groupedTasks.completed.length > 0" class="task-group">
+          <div class="task-group-title">
+            已完成 ({{ groupedTasks.completed.length }})
+            <ElButton 
+              size="small" 
+              text 
+              type="primary"
+              @click="handleClearCompleted"
+            >
+              清空
+            </ElButton>
+          </div>
+          <div 
+            v-for="task in groupedTasks.completed" 
+            :key="task.id" 
+            class="task-item completed"
+            :class="{ 'is-failed': task.status === 'failed' }"
+          >
+            <div class="task-header">
+              <div class="task-info">
+                <el-icon class="task-icon"><component :is="getIcon(task.type)" /></el-icon>
+                <span class="task-title">{{ task.title }}</span>
+              </div>
+              <ElTag size="small" :type="getStatusConfig(task.status).type">
+                {{ getStatusConfig(task.status).text }}
+              </ElTag>
+            </div>
+            <div class="task-result">
+              <template v-if="task.status === 'completed'">
+                <el-icon class="result-icon success"><Check /></el-icon>
+                {{ task.result?.message || '完成' }}
+              </template>
+              <template v-else-if="task.status === 'failed'">
+                <el-icon class="result-icon failed"><Close /></el-icon>
+                {{ task.error || '失败' }}
+              </template>
+              <template v-else>
+                <el-icon class="result-icon"><Delete /></el-icon>
+                已取消
+              </template>
+            </div>
+            <div class="task-meta">
+              {{ formatTime(task.completedAt) }}
+            </div>
+          </div>
+        </div>
+      </ElScrollbar>
+    </div>
+
+    <template #footer>
+      <div class="dialog-footer">
+        <span class="footer-info">
+          共 {{ taskStore.tasks.length }} 个任务
+          <template v-if="taskStore.activeTaskCount > 0">
+            ,{{ taskStore.activeTaskCount }} 个进行中
+          </template>
+        </span>
+        <ElButton @click="handleClose">关闭</ElButton>
+      </div>
+    </template>
+  </ElDialog>
+</template>
+
+<style scoped lang="scss">
+.task-dialog-content {
+  min-height: 100px;
+}
+
+.task-group {
+  margin-bottom: 16px;
+
+  &:last-child {
+    margin-bottom: 0;
+  }
+}
+
+.task-group-title {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  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);
+
+  .spin-icon {
+    width: 14px;
+    height: 14px;
+    animation: spin 1s linear infinite;
+  }
+}
+
+.task-item {
+  padding: 12px;
+  border-radius: 8px;
+  background: var(--el-fill-color-light);
+  margin-bottom: 8px;
+
+  &:last-child {
+    margin-bottom: 0;
+  }
+
+  &.running {
+    background: linear-gradient(135deg, #e6f4ff 0%, #f0f5ff 100%);
+    border: 1px solid var(--el-color-primary-light-5);
+  }
+
+  &.completed {
+    opacity: 0.8;
+
+    &.is-failed {
+      background: #fff2f0;
+      border: 1px solid #ffccc7;
+    }
+  }
+}
+
+.task-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 8px;
+}
+
+.task-info {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.task-icon {
+  width: 18px;
+  height: 18px;
+  color: var(--el-color-primary);
+}
+
+.task-title {
+  font-size: 14px;
+  font-weight: 500;
+}
+
+.task-actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.task-progress {
+  margin-bottom: 6px;
+}
+
+.task-step {
+  font-size: 12px;
+  color: var(--el-text-color-secondary);
+}
+
+.task-result {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 13px;
+  color: var(--el-text-color-regular);
+
+  .result-icon {
+    width: 14px;
+    height: 14px;
+
+    &.success {
+      color: var(--el-color-success);
+    }
+
+    &.failed {
+      color: var(--el-color-danger);
+    }
+  }
+}
+
+.task-meta {
+  font-size: 12px;
+  color: var(--el-text-color-placeholder);
+  margin-top: 4px;
+}
+
+.dialog-footer {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.footer-info {
+  font-size: 13px;
+  color: var(--el-text-color-secondary);
+}
+
+@keyframes spin {
+  from {
+    transform: rotate(0deg);
+  }
+  to {
+    transform: rotate(360deg);
+  }
+}
+</style>

+ 80 - 1
client/src/layouts/MainLayout.vue

@@ -74,6 +74,23 @@
         </div>
         
         <div class="header-right">
+          <!-- 任务队列指示器 -->
+          <div class="task-indicator" @click="taskStore.openDialog">
+            <el-badge 
+              :value="taskStore.activeTaskCount" 
+              :hidden="!taskStore.hasActiveTasks"
+              :is-dot="taskStore.runningTasks.length > 0"
+            >
+              <el-icon class="header-icon" :class="{ 'is-running': taskStore.runningTasks.length > 0 }">
+                <Loading v-if="taskStore.runningTasks.length > 0" />
+                <List v-else />
+              </el-icon>
+            </el-badge>
+            <span v-if="taskStore.runningTasks.length > 0" class="task-text">
+              {{ taskStore.runningTasks[0]?.progress || 0 }}%
+            </span>
+          </div>
+
           <el-badge :value="unreadComments" :hidden="!unreadComments" class="notification-badge">
             <el-icon class="header-icon" @click="$router.push('/comments')">
               <Bell />
@@ -108,18 +125,46 @@
         </router-view>
       </el-main>
     </el-container>
+
+    <!-- 全局任务进度弹框 -->
+    <TaskProgressDialog />
+    
+    <!-- 验证码输入弹框 -->
+    <CaptchaDialog
+      v-model="taskStore.showCaptchaDialog"
+      :captcha-task-id="taskStore.captchaTaskId"
+      :type="taskStore.captchaType"
+      :phone="taskStore.captchaPhone"
+      :image-base64="taskStore.captchaImageBase64"
+      @submit="taskStore.submitCaptcha"
+      @cancel="taskStore.cancelCaptcha"
+    />
   </el-container>
 </template>
 
 <script setup lang="ts">
-import { ref, computed } from 'vue';
+import { ref, computed, onMounted, onUnmounted } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
 import { useAuthStore } from '@/stores/auth';
+import { useTaskQueueStore } from '@/stores/taskQueue';
 import { ElMessageBox } from 'element-plus';
+import { Loading, List } from '@element-plus/icons-vue';
+import TaskProgressDialog from '@/components/TaskProgressDialog.vue';
+import CaptchaDialog from '@/components/CaptchaDialog.vue';
 
 const route = useRoute();
 const router = useRouter();
 const authStore = useAuthStore();
+const taskStore = useTaskQueueStore();
+
+// 初始化任务队列 WebSocket
+onMounted(() => {
+  taskStore.connectWebSocket();
+});
+
+onUnmounted(() => {
+  taskStore.disconnectWebSocket();
+});
 
 const isCollapsed = ref(false);
 const unreadComments = ref(0); // TODO: 从 store 获取
@@ -238,6 +283,31 @@ async function handleCommand(command: string) {
       &:hover {
         color: $primary-color;
       }
+
+      &.is-running {
+        color: $primary-color;
+        animation: spin 1s linear infinite;
+      }
+    }
+
+    .task-indicator {
+      display: flex;
+      align-items: center;
+      gap: 6px;
+      cursor: pointer;
+      padding: 4px 8px;
+      border-radius: 4px;
+      transition: background-color 0.2s;
+
+      &:hover {
+        background-color: rgba(0, 0, 0, 0.05);
+      }
+
+      .task-text {
+        font-size: 12px;
+        color: $primary-color;
+        font-weight: 500;
+      }
     }
     
     .user-info {
@@ -253,6 +323,15 @@ async function handleCommand(command: string) {
   }
 }
 
+@keyframes spin {
+  from {
+    transform: rotate(0deg);
+  }
+  to {
+    transform: rotate(360deg);
+  }
+}
+
 .main-content {
   background: $bg-base;
   padding: $content-padding;

+ 24 - 0
client/src/stores/auth.ts

@@ -1,6 +1,7 @@
 import { defineStore } from 'pinia';
 import { ref, computed } from 'vue';
 import { authApi } from '@/api/auth';
+import { accountsApi } from '@/api/accounts';
 import type { User, LoginRequest } from '@media-manager/shared';
 import { useServerStore } from './server';
 
@@ -46,8 +47,23 @@ export const useAuthStore = defineStore('auth', () => {
     const result = await authApi.login(data);
     saveTokens(result.accessToken, result.refreshToken);
     user.value = result.user;
+    
+    // 登录成功后自动刷新所有账号状态
+    refreshAllAccountsInBackground();
+    
     return result;
   }
+  
+  // 后台刷新所有账号状态
+  async function refreshAllAccountsInBackground() {
+    try {
+      console.log('[Auth] Starting background account refresh...');
+      await accountsApi.refreshAllAccounts();
+      console.log('[Auth] Background account refresh completed');
+    } catch (error) {
+      console.warn('[Auth] Background account refresh failed:', error);
+    }
+  }
 
   // 注册
   async function register(data: { username: string; password: string; email?: string }) {
@@ -61,6 +77,10 @@ export const useAuthStore = defineStore('auth', () => {
 
     try {
       user.value = await authApi.getMe();
+      
+      // 应用启动时自动刷新所有账号状态
+      refreshAllAccountsInBackground();
+      
       return true;
     } catch {
       // Token 失效,尝试刷新
@@ -72,6 +92,10 @@ export const useAuthStore = defineStore('auth', () => {
           accessToken.value = result.accessToken;
           localStorage.setItem(`${serverKey}_accessToken`, result.accessToken);
           user.value = await authApi.getMe();
+          
+          // Token 刷新成功后也刷新账号状态
+          refreshAllAccountsInBackground();
+          
           return true;
         } catch {
           clearTokens();

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

@@ -0,0 +1,370 @@
+import { defineStore } from 'pinia';
+import { ref, computed } from 'vue';
+import type { Task, TaskType, CreateTaskRequest, TaskProgressUpdate } from '@media-manager/shared';
+import { TASK_TYPE_CONFIG, TASK_WS_EVENTS } from '@media-manager/shared';
+import { useAuthStore } from './auth';
+import { useServerStore } from './server';
+import request from '@/api/request';
+
+export const useTaskQueueStore = defineStore('taskQueue', () => {
+  // 状态
+  const tasks = ref<Task[]>([]);
+  const isDialogVisible = ref(false);
+  const ws = ref<WebSocket | null>(null);
+  const wsConnected = ref(false);
+  
+  // 验证码相关状态
+  const showCaptchaDialog = ref(false);
+  const captchaTaskId = ref('');
+  const captchaType = ref<'sms' | 'image'>('sms');
+  const captchaPhone = ref('');
+  const captchaImageBase64 = ref('');
+
+  // 计算属性
+  const activeTasks = computed(() => 
+    tasks.value.filter(t => t.status === 'pending' || t.status === 'running')
+  );
+
+  const runningTasks = computed(() => 
+    tasks.value.filter(t => t.status === 'running')
+  );
+
+  const hasActiveTasks = computed(() => activeTasks.value.length > 0);
+
+  const activeTaskCount = computed(() => activeTasks.value.length);
+
+  // 获取任务类型配置
+  const getTaskConfig = (type: TaskType) => TASK_TYPE_CONFIG[type];
+
+  // WebSocket 连接
+  function connectWebSocket() {
+    const authStore = useAuthStore();
+    const serverStore = useServerStore();
+    
+    if (!authStore.accessToken) {
+      console.warn('[TaskWS] No token available');
+      return;
+    }
+
+    if (ws.value && ws.value.readyState === WebSocket.OPEN) {
+      return;
+    }
+
+    const serverUrl = serverStore.currentServer?.url || 'http://localhost:3000';
+    const wsUrl = serverUrl.replace(/^http/, 'ws') + '/ws';
+
+    try {
+      ws.value = new WebSocket(wsUrl);
+
+      ws.value.onopen = () => {
+        console.log('[TaskWS] Connected');
+        ws.value?.send(JSON.stringify({ 
+          type: 'auth', 
+          payload: { token: authStore.accessToken } 
+        }));
+      };
+
+      ws.value.onmessage = (event) => {
+        try {
+          const data = JSON.parse(event.data);
+          handleWebSocketMessage(data);
+        } catch (e) {
+          console.error('[TaskWS] Parse error:', e);
+        }
+      };
+
+      ws.value.onclose = () => {
+        console.log('[TaskWS] Disconnected');
+        wsConnected.value = false;
+        // 5秒后重连
+        setTimeout(connectWebSocket, 5000);
+      };
+
+      ws.value.onerror = () => {
+        ws.value?.close();
+      };
+    } catch (e) {
+      console.error('[TaskWS] Setup error:', e);
+    }
+  }
+
+  function disconnectWebSocket() {
+    if (ws.value) {
+      ws.value.close();
+      ws.value = null;
+    }
+  }
+
+  // 处理 WebSocket 消息
+  function handleWebSocketMessage(data: { type?: string; payload?: Record<string, unknown> }) {
+    const event = (data.payload?.event as string) || data.type?.split(':')[1] || '';
+
+    switch (event) {
+      case 'success':
+        wsConnected.value = true;
+        // 连接成功后获取任务列表
+        fetchTasks();
+        break;
+
+      case 'created': {
+        const task = data.payload?.task as Task;
+        if (task && !tasks.value.find(t => t.id === task.id)) {
+          tasks.value.unshift(task);
+        }
+        break;
+      }
+
+      case 'started': {
+        const task = data.payload?.task as Task;
+        if (task) {
+          updateTask(task);
+        }
+        break;
+      }
+
+      case 'progress': {
+        const update = data.payload as TaskProgressUpdate & { taskId: string };
+        if (update?.taskId) {
+          const task = tasks.value.find(t => t.id === update.taskId);
+          if (task) {
+            task.progress = update.progress || task.progress;
+            task.currentStep = update.currentStep || task.currentStep;
+            task.currentStepIndex = update.currentStepIndex ?? task.currentStepIndex;
+          }
+        }
+        break;
+      }
+
+      case 'completed':
+      case 'failed':
+      case 'cancelled': {
+        const task = data.payload?.task as Task;
+        if (task) {
+          updateTask(task);
+        }
+        break;
+      }
+
+      case 'list': {
+        const taskList = data.payload?.tasks as Task[];
+        if (taskList) {
+          tasks.value = taskList;
+        }
+        break;
+      }
+
+      // 兼容旧的评论同步事件
+      case 'sync_started':
+      case 'sync_progress':
+      case 'synced':
+      case 'sync_failed':
+        // 这些事件由 Works 页面处理
+        break;
+    }
+    
+    // 处理验证码事件
+    // 1. 通过 type 判断
+    // 2. 或者 payload 中包含 captchaTaskId 字段(兼容 type 为 undefined 的情况)
+    const payload = data.payload as Record<string, unknown> | undefined;
+    if (data.type === 'captcha:required' || 
+        (payload && 'captchaTaskId' in payload && payload.captchaTaskId)) {
+      console.log('[TaskQueue] Captcha event detected, type:', data.type, 'payload:', payload);
+      handleCaptchaRequired(payload as { 
+        captchaTaskId: string; 
+        type?: 'sms' | 'image';
+        phone?: string;
+        imageBase64?: string;
+      });
+    }
+  }
+  
+  // 处理验证码请求
+  function handleCaptchaRequired(payload: { 
+    captchaTaskId: string; 
+    type?: 'sms' | 'image';
+    phone?: string;
+    imageBase64?: string;
+  }) {
+    console.log('[TaskQueue] Captcha required:', payload);
+    captchaTaskId.value = payload.captchaTaskId || '';
+    captchaType.value = payload.type || 'sms';
+    captchaPhone.value = payload.phone || '';
+    captchaImageBase64.value = payload.imageBase64 || '';
+    showCaptchaDialog.value = true;
+  }
+  
+  // 提交验证码
+  function submitCaptcha(taskId: string, code: string) {
+    if (ws.value && ws.value.readyState === WebSocket.OPEN) {
+      ws.value.send(JSON.stringify({
+        type: 'captcha:submit',
+        payload: {
+          captchaTaskId: taskId,
+          code,
+        },
+      }));
+      console.log('[TaskQueue] Captcha submitted:', taskId, code);
+    }
+    showCaptchaDialog.value = false;
+  }
+  
+  // 取消验证码输入
+  function cancelCaptcha() {
+    showCaptchaDialog.value = false;
+    // 发送空验证码表示取消
+    if (captchaTaskId.value && ws.value && ws.value.readyState === WebSocket.OPEN) {
+      ws.value.send(JSON.stringify({
+        type: 'captcha:submit',
+        payload: {
+          captchaTaskId: captchaTaskId.value,
+          code: '',
+        },
+      }));
+    }
+  }
+
+  function updateTask(task: Task) {
+    const index = tasks.value.findIndex(t => t.id === task.id);
+    if (index >= 0) {
+      tasks.value[index] = task;
+    } else {
+      tasks.value.unshift(task);
+    }
+  }
+
+  // API 操作
+  async function fetchTasks() {
+    try {
+      const result = await request.get('/api/tasks') as { 
+        success?: boolean; 
+        data?: { tasks: Task[] }; 
+        tasks?: Task[] 
+      };
+      // 兼容两种返回格式
+      tasks.value = result?.data?.tasks || result?.tasks || [];
+      console.log('[TaskQueue] Fetched tasks:', tasks.value.length);
+    } catch (e) {
+      console.error('Failed to fetch tasks:', e);
+    }
+  }
+
+  async function createTask(taskRequest: CreateTaskRequest): Promise<Task | null> {
+    try {
+      const result = await request.post('/api/tasks', taskRequest) as { 
+        success?: boolean; 
+        data?: { task: Task }; 
+        task?: Task 
+      };
+      // 兼容两种返回格式
+      const task = result?.data?.task || result?.task || null;
+      
+      // 如果任务创建成功,添加到本地列表
+      if (task && !tasks.value.find(t => t.id === task.id)) {
+        tasks.value.unshift(task);
+      }
+      
+      return task;
+    } catch (e) {
+      console.error('Failed to create task:', e);
+      return null;
+    }
+  }
+
+  async function cancelTask(taskId: string): Promise<boolean> {
+    try {
+      await request.post(`/api/tasks/${taskId}/cancel`);
+      const task = tasks.value.find(t => t.id === taskId);
+      if (task) {
+        task.status = 'cancelled';
+      }
+      return true;
+    } catch (e) {
+      console.error('Failed to cancel task:', e);
+      return false;
+    }
+  }
+
+  // 快捷方法:创建同步评论任务
+  async function syncComments(accountId?: number, accountName?: string) {
+    return createTask({
+      type: 'sync_comments',
+      title: accountName ? `同步评论 - ${accountName}` : '同步所有评论',
+      accountId,
+    });
+  }
+
+  // 快捷方法:创建同步作品任务
+  async function syncWorks(accountId?: number, accountName?: string) {
+    return createTask({
+      type: 'sync_works',
+      title: accountName ? `同步作品 - ${accountName}` : '同步所有作品',
+      accountId,
+    });
+  }
+
+  // 快捷方法:创建同步账号任务
+  async function syncAccount(accountId: number, accountName?: string) {
+    return createTask({
+      type: 'sync_account',
+      title: `同步账号 - ${accountName || accountId}`,
+      accountId,
+    });
+  }
+
+  // 弹框控制
+  function openDialog() {
+    isDialogVisible.value = true;
+    // 打开时刷新任务列表
+    fetchTasks();
+  }
+
+  function closeDialog() {
+    isDialogVisible.value = false;
+  }
+
+  function toggleDialog() {
+    isDialogVisible.value = !isDialogVisible.value;
+  }
+
+  // 清理已完成任务
+  function clearCompletedTasks() {
+    tasks.value = tasks.value.filter(t => 
+      t.status === 'pending' || t.status === 'running'
+    );
+  }
+
+  return {
+    // 状态
+    tasks,
+    isDialogVisible,
+    wsConnected,
+    // 验证码状态
+    showCaptchaDialog,
+    captchaTaskId,
+    captchaType,
+    captchaPhone,
+    captchaImageBase64,
+    // 计算属性
+    activeTasks,
+    runningTasks,
+    hasActiveTasks,
+    activeTaskCount,
+    // 方法
+    getTaskConfig,
+    connectWebSocket,
+    disconnectWebSocket,
+    fetchTasks,
+    createTask,
+    cancelTask,
+    syncComments,
+    syncWorks,
+    syncAccount,
+    openDialog,
+    closeDialog,
+    toggleDialog,
+    clearCompletedTasks,
+    // 验证码方法
+    submitCaptcha,
+    cancelCaptcha,
+  };
+});

+ 44 - 29
client/src/views/Accounts/index.vue

@@ -3,6 +3,10 @@
     <div class="page-header">
       <h2>账号管理</h2>
       <div class="header-actions">
+        <el-button @click="refreshAllAccounts" :disabled="!accounts.length">
+          <el-icon><Refresh /></el-icon>
+          刷新所有
+        </el-button>
         <el-button type="primary" @click="showBrowserLoginDialog = true">
           <el-icon><Monitor /></el-icon>
           浏览器登录
@@ -430,6 +434,28 @@ 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';
+
+const taskStore = useTaskQueueStore();
+
+// 监听任务列表变化,当 sync_account 任务完成时自动刷新账号列表
+watch(() => taskStore.tasks, (newTasks, oldTasks) => {
+  const hasSyncAccountComplete = newTasks.some(task => {
+    if (task.type !== 'sync_account') return false;
+    const oldTask = oldTasks?.find(t => t.id === task.id);
+    // 任务状态变为 completed 或 failed
+    if (oldTask && oldTask.status !== task.status && 
+        (task.status === 'completed' || task.status === 'failed')) {
+      return true;
+    }
+    return false;
+  });
+  
+  if (hasSyncAccountComplete) {
+    console.log('[Accounts] Sync account task completed, refreshing list...');
+    loadAccounts();
+  }
+}, { deep: true });
 
 const loading = ref(false);
 const submitting = ref(false);
@@ -575,37 +601,26 @@ async function refreshAccount(id: number) {
     return;
   }
   
-  // 显示刷新对话框并开始动画
-  showRefreshDialog.value = true;
-  refreshState.status = 'loading';
-  refreshState.progress = 0;
-  refreshState.step = 1;
-  refreshState.error = '';
-  refreshState.account = null;
-  refreshState.platform = account.platform as PlatformType;
-  
-  startRefreshAnimation();
+  // 使用任务队列
+  await taskStore.syncAccount(id, account.accountName);
+  ElMessage.success('账号刷新任务已创建');
+  taskStore.openDialog();
+}
+
+// 刷新所有账号
+async function refreshAllAccounts() {
+  if (!accounts.value.length) {
+    ElMessage.warning('暂无账号');
+    return;
+  }
   
-  try {
-    const result = await accountsApi.refreshAccount(id);
-    
-    stopRefreshAnimation();
-    
-    if (result.needReLogin) {
-      // Cookie 已过期
-      refreshState.status = 'expired';
-    } else {
-      // 刷新成功
-      refreshState.status = 'success';
-      refreshState.account = result.account;
-    }
-    
-    loadAccounts();
-  } catch (error) {
-    stopRefreshAnimation();
-    refreshState.status = 'failed';
-    refreshState.error = (error as Error).message || '刷新失败';
+  // 为每个账号创建刷新任务
+  for (const account of accounts.value) {
+    await taskStore.syncAccount(account.id, account.accountName);
   }
+  
+  ElMessage.success(`已创建 ${accounts.value.length} 个账号刷新任务`);
+  taskStore.openDialog();
 }
 
 // 启动刷新进度动画

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

@@ -2,14 +2,32 @@
   <div class="publish-page">
     <div class="page-header">
       <h2>发布管理</h2>
-      <el-button type="primary" @click="showCreateDialog = true">
-        <el-icon><Plus /></el-icon>
-        新建发布
-      </el-button>
+      <div class="header-actions">
+        <el-input
+          v-model="searchKeyword"
+          placeholder="搜索标题..."
+          style="width: 200px; margin-right: 12px"
+          clearable
+          @clear="loadTasks"
+          @keyup.enter="loadTasks"
+        >
+          <template #prefix>
+            <el-icon><Search /></el-icon>
+          </template>
+        </el-input>
+        <el-button @click="loadTasks" :loading="loading">
+          <el-icon><Refresh /></el-icon>
+          刷新
+        </el-button>
+        <el-button type="primary" @click="showCreateDialog = true">
+          <el-icon><Plus /></el-icon>
+          新建发布
+        </el-button>
+      </div>
     </div>
     
     <div class="page-card">
-      <el-table :data="tasks" v-loading="loading">
+      <el-table :data="filteredTasks" v-loading="loading">
         <el-table-column label="视频" min-width="200">
           <template #default="{ row }">
             <div class="video-info">
@@ -60,7 +78,7 @@
               取消
             </el-button>
             <el-button
-              v-if="row.status === 'failed'"
+              v-if="row.status === 'failed' || row.status === 'processing'"
               type="warning"
               link
               size="small"
@@ -68,6 +86,15 @@
             >
               重试
             </el-button>
+            <el-button
+              v-if="row.status !== 'processing'"
+              type="danger"
+              link
+              size="small"
+              @click="deleteTask(row.id)"
+            >
+              删除
+            </el-button>
           </template>
         </el-table-column>
       </el-table>
@@ -141,25 +168,172 @@
         </el-button>
       </template>
     </el-dialog>
+    
+    <!-- 任务详情对话框 -->
+    <el-dialog v-model="showDetailDialog" title="发布详情" width="700px">
+      <template v-if="currentTask">
+        <el-descriptions :column="2" border>
+          <el-descriptions-item label="任务ID">{{ currentTask.id }}</el-descriptions-item>
+          <el-descriptions-item label="状态">
+            <el-tag :type="getStatusType(currentTask.status)">
+              {{ getStatusText(currentTask.status) }}
+            </el-tag>
+          </el-descriptions-item>
+          <el-descriptions-item label="标题" :span="2">{{ currentTask.title }}</el-descriptions-item>
+          <el-descriptions-item label="描述" :span="2">{{ currentTask.description || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="视频文件" :span="2">{{ currentTask.videoFilename || currentTask.videoPath }}</el-descriptions-item>
+          <el-descriptions-item label="标签" :span="2">
+            <el-tag v-for="tag in (currentTask.tags || [])" :key="tag" size="small" style="margin-right: 4px">
+              {{ tag }}
+            </el-tag>
+            <span v-if="!currentTask.tags?.length">-</span>
+          </el-descriptions-item>
+          <el-descriptions-item label="目标账号">{{ currentTask.targetAccounts?.length || 0 }} 个</el-descriptions-item>
+          <el-descriptions-item label="定时发布">
+            {{ currentTask.scheduledAt ? formatDate(currentTask.scheduledAt) : '立即发布' }}
+          </el-descriptions-item>
+          <el-descriptions-item label="创建时间">{{ formatDate(currentTask.createdAt) }}</el-descriptions-item>
+          <el-descriptions-item label="发布时间">
+            {{ currentTask.publishedAt ? formatDate(currentTask.publishedAt) : '-' }}
+          </el-descriptions-item>
+        </el-descriptions>
+        
+        <!-- 发布结果 -->
+        <div v-if="taskDetail?.results?.length" class="publish-results">
+          <h4>发布结果</h4>
+          <el-table :data="taskDetail.results" size="small">
+            <el-table-column label="账号" prop="accountId" width="80" />
+            <el-table-column label="平台" width="100">
+              <template #default="{ row }">
+                {{ getPlatformName(row.platform) }}
+              </template>
+            </el-table-column>
+            <el-table-column label="状态" width="100">
+              <template #default="{ row }">
+                <el-tag :type="row.status === 'success' ? 'success' : (row.status === 'failed' ? 'danger' : 'info')" size="small">
+                  {{ row.status === 'success' ? '成功' : (row.status === 'failed' ? '失败' : '待发布') }}
+                </el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column label="错误信息" prop="errorMessage" show-overflow-tooltip />
+            <el-table-column label="发布时间" width="160">
+              <template #default="{ row }">
+                {{ row.publishedAt ? formatDate(row.publishedAt) : '-' }}
+              </template>
+            </el-table-column>
+          </el-table>
+        </div>
+      </template>
+      
+      <template #footer>
+        <el-button @click="showDetailDialog = false">关闭</el-button>
+        <el-button type="primary" @click="openEditDialog">
+          <el-icon><Edit /></el-icon>
+          修改并重新发布
+        </el-button>
+      </template>
+    </el-dialog>
+    
+    <!-- 修改重新发布对话框 -->
+    <el-dialog v-model="showEditDialog" title="修改并重新发布" width="600px">
+      <el-alert type="info" :closable="false" style="margin-bottom: 16px">
+        修改内容后将创建一条新的发布任务
+      </el-alert>
+      
+      <el-form :model="editForm" label-width="100px">
+        <el-form-item label="视频文件">
+          <div>
+            <span>{{ editForm.videoFilename || '使用原视频' }}</span>
+            <el-upload
+              action=""
+              :auto-upload="false"
+              :on-change="handleEditFileChange"
+              :show-file-list="false"
+              style="display: inline-block; margin-left: 12px"
+            >
+              <el-button size="small">更换视频</el-button>
+            </el-upload>
+          </div>
+        </el-form-item>
+        
+        <el-form-item label="标题">
+          <el-input v-model="editForm.title" placeholder="视频标题" />
+        </el-form-item>
+        
+        <el-form-item label="描述">
+          <el-input v-model="editForm.description" type="textarea" :rows="3" placeholder="视频描述" />
+        </el-form-item>
+        
+        <el-form-item label="标签">
+          <el-select v-model="editForm.tags" multiple filterable allow-create placeholder="添加标签" style="width: 100%">
+          </el-select>
+        </el-form-item>
+        
+        <el-form-item label="目标账号">
+          <el-checkbox-group v-model="editForm.targetAccounts">
+            <el-checkbox
+              v-for="account in accounts"
+              :key="account.id"
+              :label="account.id"
+            >
+              {{ account.accountName }} ({{ getPlatformName(account.platform) }})
+            </el-checkbox>
+          </el-checkbox-group>
+        </el-form-item>
+        
+        <el-form-item label="定时发布">
+          <el-date-picker
+            v-model="editForm.scheduledAt"
+            type="datetime"
+            placeholder="选择时间(留空则立即发布)"
+          />
+        </el-form-item>
+      </el-form>
+      
+      <template #footer>
+        <el-button @click="showEditDialog = false">取消</el-button>
+        <el-button type="primary" @click="handleRepublish" :loading="submitting">
+          创建新发布
+        </el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, onMounted } from 'vue';
-import { Plus } from '@element-plus/icons-vue';
+import { ref, reactive, computed, onMounted, watch } from 'vue';
+import { Plus, Refresh, Search, Edit } from '@element-plus/icons-vue';
 import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus';
 import { accountsApi } from '@/api/accounts';
 import request from '@/api/request';
 import { PLATFORMS } from '@media-manager/shared';
-import type { PublishTask, PlatformAccount, PlatformType } from '@media-manager/shared';
+import type { PublishTask, PublishTaskDetail, PlatformAccount, PlatformType } from '@media-manager/shared';
+import { useTaskQueueStore } from '@/stores/taskQueue';
 import dayjs from 'dayjs';
 
+const taskStore = useTaskQueueStore();
+
 const loading = ref(false);
 const submitting = ref(false);
 const showCreateDialog = ref(false);
+const showDetailDialog = ref(false);
+const showEditDialog = ref(false);
+const searchKeyword = ref('');
 
 const tasks = ref<PublishTask[]>([]);
 const accounts = ref<PlatformAccount[]>([]);
+const currentTask = ref<PublishTask | null>(null);
+const taskDetail = ref<PublishTaskDetail | null>(null);
+
+// 过滤后的任务列表
+const filteredTasks = computed(() => {
+  if (!searchKeyword.value) return tasks.value;
+  const keyword = searchKeyword.value.toLowerCase();
+  return tasks.value.filter(t => 
+    t.title?.toLowerCase().includes(keyword) ||
+    t.videoFilename?.toLowerCase().includes(keyword)
+  );
+});
 
 const pagination = reactive({
   page: 1,
@@ -176,6 +350,17 @@ const createForm = reactive({
   scheduledAt: null as Date | null,
 });
 
+const editForm = reactive({
+  videoFile: null as File | null,
+  videoPath: '',
+  videoFilename: '',
+  title: '',
+  description: '',
+  tags: [] as string[],
+  targetAccounts: [] as number[],
+  scheduledAt: null as Date | null,
+});
+
 function getPlatformName(platform: PlatformType) {
   return PLATFORMS[platform]?.name || platform;
 }
@@ -244,9 +429,38 @@ async function handleCreate() {
   
   submitting.value = true;
   try {
-    // TODO: 先上传视频,再创建任务
+    // 1. 上传视频
+    const formData = new FormData();
+    formData.append('video', createForm.videoFile);
+    
+    const uploadResult = await request.post('/api/upload/video', formData, {
+      headers: {
+        'Content-Type': 'multipart/form-data',
+      },
+    });
+    
+    // 2. 创建发布任务
+    await request.post('/api/publish', {
+      videoPath: uploadResult.path,
+      videoFilename: uploadResult.originalname,
+      title: createForm.title,
+      description: createForm.description,
+      tags: createForm.tags,
+      targetAccounts: createForm.targetAccounts,
+      scheduledAt: createForm.scheduledAt ? createForm.scheduledAt.toISOString() : null,
+    });
+    
     ElMessage.success('发布任务创建成功');
     showCreateDialog.value = false;
+    
+    // 重置表单
+    createForm.videoFile = null;
+    createForm.title = '';
+    createForm.description = '';
+    createForm.tags = [];
+    createForm.targetAccounts = [];
+    createForm.scheduledAt = null;
+    
     loadTasks();
   } catch {
     // 错误已处理
@@ -255,8 +469,83 @@ async function handleCreate() {
   }
 }
 
-function viewDetail(task: PublishTask) {
-  ElMessage.info('详情功能开发中');
+async function viewDetail(task: PublishTask) {
+  currentTask.value = task;
+  showDetailDialog.value = true;
+  
+  // 加载任务详情(包含发布结果)
+  try {
+    const detail = await request.get(`/api/publish/${task.id}`);
+    taskDetail.value = detail;
+  } catch {
+    // 错误已处理
+  }
+}
+
+function openEditDialog() {
+  if (!currentTask.value) return;
+  
+  // 复制当前任务信息到编辑表单
+  editForm.videoFile = null;
+  editForm.videoPath = currentTask.value.videoPath || '';
+  editForm.videoFilename = currentTask.value.videoFilename || '';
+  editForm.title = currentTask.value.title || '';
+  editForm.description = currentTask.value.description || '';
+  editForm.tags = [...(currentTask.value.tags || [])];
+  editForm.targetAccounts = [...(currentTask.value.targetAccounts || [])];
+  editForm.scheduledAt = null;
+  
+  showDetailDialog.value = false;
+  showEditDialog.value = true;
+}
+
+function handleEditFileChange(file: UploadFile) {
+  editForm.videoFile = file.raw || null;
+  if (file.raw) {
+    editForm.videoFilename = file.raw.name;
+  }
+}
+
+async function handleRepublish() {
+  if (!editForm.title || editForm.targetAccounts.length === 0) {
+    ElMessage.warning('请填写完整信息');
+    return;
+  }
+  
+  submitting.value = true;
+  try {
+    let videoPath = editForm.videoPath;
+    
+    // 如果选择了新视频,先上传
+    if (editForm.videoFile) {
+      const formData = new FormData();
+      formData.append('video', editForm.videoFile);
+      
+      const uploadResult = await request.post('/api/upload/video', formData, {
+        headers: { 'Content-Type': 'multipart/form-data' },
+      });
+      videoPath = uploadResult.path;
+    }
+    
+    // 创建新的发布任务
+    await request.post('/api/publish', {
+      videoPath,
+      videoFilename: editForm.videoFilename,
+      title: editForm.title,
+      description: editForm.description,
+      tags: editForm.tags,
+      targetAccounts: editForm.targetAccounts,
+      scheduledAt: editForm.scheduledAt ? editForm.scheduledAt.toISOString() : null,
+    });
+    
+    ElMessage.success('新发布任务已创建');
+    showEditDialog.value = false;
+    loadTasks();
+  } catch {
+    // 错误已处理
+  } finally {
+    submitting.value = false;
+  }
 }
 
 async function cancelTask(id: number) {
@@ -280,6 +569,33 @@ async function retryTask(id: number) {
   }
 }
 
+async function deleteTask(id: number) {
+  try {
+    await ElMessageBox.confirm('确定要删除该任务吗?删除后不可恢复。', '提示', {
+      type: 'warning',
+    });
+    await request.delete(`/api/publish/${id}`);
+    ElMessage.success('任务已删除');
+    loadTasks();
+  } catch {
+    // 取消或错误
+  }
+}
+
+// 监听 taskStore 的任务列表变化,当发布任务完成时刷新列表
+watch(() => taskStore.tasks, (newTasks, oldTasks) => {
+  // 检查是否有发布任务状态变化
+  const hasPublishTaskChange = newTasks.some(task => {
+    if (task.type !== 'publish_video') return false;
+    const oldTask = oldTasks?.find(t => t.id === task.id);
+    return !oldTask || oldTask.status !== task.status;
+  });
+  
+  if (hasPublishTaskChange) {
+    loadTasks();
+  }
+}, { deep: true });
+
 onMounted(() => {
   loadTasks();
   loadAccounts();
@@ -298,6 +614,11 @@ onMounted(() => {
   h2 {
     margin: 0;
   }
+  
+  .header-actions {
+    display: flex;
+    align-items: center;
+  }
 }
 
 .video-info {
@@ -311,4 +632,14 @@ onMounted(() => {
     margin-top: 4px;
   }
 }
+
+.publish-results {
+  margin-top: 20px;
+  
+  h4 {
+    margin: 0 0 12px;
+    font-size: 14px;
+    color: var(--el-text-color-primary);
+  }
+}
 </style>

+ 441 - 18
client/src/views/Works/index.vue

@@ -47,6 +47,10 @@
         <el-icon><Refresh /></el-icon>
         同步作品
       </el-button>
+      <el-button type="success" @click="syncAllComments" :loading="syncingComments">
+        <el-icon><ChatDotSquare /></el-icon>
+        同步评论
+      </el-button>
     </div>
     
     <!-- 作品列表 -->
@@ -171,13 +175,21 @@
         <el-button type="primary" @click="viewComments(currentWork!)">
           查看评论
         </el-button>
+        <el-button 
+          type="danger" 
+          @click="deletePlatformWork(currentWork!)"
+          v-if="currentWork?.platform === 'douyin'"
+        >
+          <el-icon><Delete /></el-icon>
+          删除平台作品
+        </el-button>
       </template>
     </el-dialog>
     
     <!-- 评论抽屉 -->
     <el-drawer
       v-model="showCommentsDrawer"
-      :title="`评论 - ${commentsWork?.title || '作品'}`"
+      :title="commentsWork ? `评论 - ${commentsWork.title || '作品'}` : '所有评论'"
       size="500px"
       destroy-on-close
     >
@@ -191,6 +203,14 @@
           </div>
         </div>
       </div>
+      <div class="comments-drawer-header" v-else>
+        <div class="work-brief">
+          <div class="work-brief-title">所有评论</div>
+          <div class="work-brief-meta">
+            <span>共 {{ commentsPagination.total }} 条评论</span>
+          </div>
+        </div>
+      </div>
       
       <el-divider />
       
@@ -261,27 +281,121 @@
         </el-button>
       </template>
     </el-dialog>
+    
+    <!-- 评论同步对话框 -->
+    <el-dialog 
+      v-model="showSyncDialog" 
+      title="同步评论" 
+      width="450px"
+      :close-on-click-modal="false"
+      :close-on-press-escape="false"
+      :show-close="syncState.status !== 'syncing'"
+    >
+      <div class="sync-status">
+        <!-- 同步中 -->
+        <template v-if="syncState.status === 'syncing'">
+          <div class="sync-animation">
+            <el-icon class="is-loading" :size="48" color="#409eff"><Loading /></el-icon>
+          </div>
+          <div class="sync-text">
+            正在同步评论<span class="sync-dots">{{ syncDots }}</span>
+          </div>
+          <el-progress 
+            :percentage="Math.floor(syncState.progress)" 
+            :stroke-width="8"
+            style="margin: 16px 0"
+          />
+          <div class="sync-hint">
+            正在从平台获取评论数据,请耐心等待...
+          </div>
+          <div class="sync-steps">
+            <span :class="{ active: syncState.step >= 1 }">连接平台</span>
+            <span :class="{ active: syncState.step >= 2 }">获取作品</span>
+            <span :class="{ active: syncState.step >= 3 }">提取评论</span>
+          </div>
+        </template>
+        
+        <!-- 同步成功 -->
+        <template v-else-if="syncState.status === 'success'">
+          <div class="sync-animation">
+            <el-icon :size="48" color="#67c23a"><CircleCheckFilled /></el-icon>
+          </div>
+          <div class="sync-text success">同步完成</div>
+          <div class="sync-result">
+            <p>成功同步 <strong>{{ syncState.syncedCount }}</strong> 条评论</p>
+            <p v-if="syncState.accountsCount">涉及 {{ syncState.accountsCount }} 个账号</p>
+          </div>
+          <el-button type="primary" link @click="viewAllComments" style="margin-top: 12px">
+            查看所有评论
+          </el-button>
+        </template>
+        
+        <!-- 同步失败 -->
+        <template v-else-if="syncState.status === 'failed'">
+          <div class="sync-animation">
+            <el-icon :size="48" color="#f56c6c"><CircleCloseFilled /></el-icon>
+          </div>
+          <div class="sync-text error">同步失败</div>
+          <div class="sync-error">{{ syncState.error }}</div>
+        </template>
+        
+        <!-- 无评论 -->
+        <template v-else-if="syncState.status === 'empty'">
+          <div class="sync-animation">
+            <el-icon :size="48" color="#909399"><WarningFilled /></el-icon>
+          </div>
+          <div class="sync-text">未获取到新评论</div>
+          <div class="sync-hint">可能原因:平台暂无新评论,或 Cookie 已过期</div>
+        </template>
+      </div>
+      
+      <template #footer v-if="syncState.status !== 'syncing'">
+        <el-button type="primary" @click="closeSyncDialog">确定</el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, onMounted, computed } from 'vue';
-import { Search, Refresh, VideoPlay, Star, ChatDotSquare, Share } from '@element-plus/icons-vue';
+import { ref, reactive, onMounted, onUnmounted, computed } 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';
 import request from '@/api/request';
 import { accountsApi } from '@/api/accounts';
-import { PLATFORMS, PLATFORM_TYPES } from '@media-manager/shared';
+import { PLATFORMS, PLATFORM_TYPES, WS_EVENTS } from '@media-manager/shared';
 import type { Work, WorkStats, PlatformAccount, PlatformType, Comment } from '@media-manager/shared';
+import { useServerStore } from '@/stores/server';
+import { useAuthStore } from '@/stores/auth';
+import { useTaskQueueStore } from '@/stores/taskQueue';
 import dayjs from 'dayjs';
 
+const serverStore = useServerStore();
+const authStore = useAuthStore();
+const taskStore = useTaskQueueStore();
+
 const loading = ref(false);
 const refreshing = ref(false);
+const syncingComments = ref(false);
 const showDetailDialog = ref(false);
 const showCommentsDrawer = ref(false);
 const showReplyDialog = ref(false);
+const showSyncDialog = ref(false);
 const commentsLoading = ref(false);
 const replying = ref(false);
 
+// 评论同步状态
+const syncDots = ref('');
+const syncState = reactive({
+  status: 'syncing' as 'syncing' | 'success' | 'failed' | 'empty',
+  progress: 0,
+  step: 1,
+  syncedCount: 0,
+  accountsCount: 0,
+  error: '',
+});
+let syncTimer: ReturnType<typeof setInterval> | null = null;
+
 const works = ref<Work[]>([]);
 const accounts = ref<PlatformAccount[]>([]);
 const currentWork = ref<Work | null>(null);
@@ -411,20 +525,224 @@ async function refreshAllWorks() {
   
   refreshing.value = true;
   try {
-    await request.post('/api/works/sync');
-    ElMessage.success('作品同步任务已启动,请稍后刷新查看');
+    // 使用任务队列
+    await taskStore.syncWorks();
+    ElMessage.success('作品同步任务已创建,请在任务队列中查看进度');
+    // 打开任务队列弹框
+    taskStore.openDialog();
     // 延迟刷新
     setTimeout(() => {
       loadWorks();
       loadStats();
-    }, 3000);
-  } catch {
-    // 错误已处理
+    }, 5000);
+  } catch (error) {
+    ElMessage.error((error as Error)?.message || '创建同步任务失败');
   } finally {
     refreshing.value = false;
   }
 }
 
+// WebSocket 连接用于接收同步结果
+let ws: WebSocket | null = null;
+let wsReconnectTimer: ReturnType<typeof setTimeout> | null = null;
+let syncTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
+
+function setupWebSocket() {
+  // 检查是否已连接
+  if (ws && ws.readyState === WebSocket.OPEN) {
+    console.log('[WS] Already connected');
+    return;
+  }
+  
+  // 检查 token 是否可用
+  const token = authStore.accessToken;
+  if (!token) {
+    console.warn('[WS] No token available, cannot setup WebSocket');
+    return;
+  }
+  
+  const serverUrl = serverStore.currentServer?.url || 'http://localhost:3000';
+  const wsUrl = serverUrl.replace(/^http/, 'ws') + '/ws';
+  
+  console.log('[WS] Connecting to:', wsUrl);
+  console.log('[WS] Token available:', token.slice(0, 20) + '...');
+  
+  try {
+    ws = new WebSocket(wsUrl);
+    
+    ws.onopen = () => {
+      console.log('[WS] Connected, sending auth...');
+      if (ws) {
+        ws.send(JSON.stringify({ type: WS_EVENTS.AUTH, payload: { token } }));
+      }
+    };
+    
+    ws.onmessage = (event) => {
+      console.log('[WS] Raw message received:', event.data);
+      try {
+        const data = JSON.parse(event.data);
+        console.log('[WS] Parsed message:', data);
+        console.log('[WS] Message type:', data.type);
+        handleWebSocketMessage(data);
+        console.log('[WS] After handling, syncState:', JSON.stringify(syncState));
+      } catch (e) {
+        console.error('[WS] Error processing message:', e);
+      }
+    };
+    
+    ws.onclose = () => {
+      console.log('[WS] Disconnected');
+      // 5秒后尝试重连
+      wsReconnectTimer = setTimeout(setupWebSocket, 5000);
+    };
+    
+    ws.onerror = (e) => {
+      console.error('[WS] Error:', e);
+      ws?.close();
+    };
+  } catch (e) {
+    console.error('[WS] Setup error:', e);
+  }
+}
+
+function handleWebSocketMessage(data: { type?: string; payload?: Record<string, unknown> }) {
+  console.log('[WS] Message received:', JSON.stringify(data));
+  
+  // 优先从 payload.event 获取事件类型,其次从 type 获取
+  const event = (data.payload?.event as string) || data.type || '';
+  console.log('[WS] Event:', event);
+  
+  // 清除超时定时器
+  if (syncTimeoutTimer && (event === 'synced' || event === 'sync_failed')) {
+    clearTimeout(syncTimeoutTimer);
+    syncTimeoutTimer = null;
+  }
+  
+  switch (event) {
+    case 'sync_started':
+      console.log('[WS] Sync started');
+      syncState.progress = 5;
+      syncState.step = 1;
+      break;
+    
+    case 'sync_progress':
+      if (data.payload) {
+        const { current, total, progress } = data.payload as { current: number; total: number; progress: number };
+        console.log(`[WS] Progress: ${current}/${total} (${progress}%)`);
+        syncState.progress = Math.min(90, Math.round(progress * 0.9));
+        if (current >= 1) syncState.step = 2;
+        if (current > 1) syncState.step = 3;
+      }
+      break;
+      
+    case 'synced':
+      console.log('[WS] Sync completed:', data.payload);
+      stopSyncAnimation();
+      syncState.progress = 100;
+      syncState.step = 3;
+      syncState.syncedCount = (data.payload?.syncedCount as number) || 0;
+      syncState.accountsCount = (data.payload?.accountCount as number) || 0;
+      syncState.status = syncState.syncedCount > 0 ? 'success' : 'empty';
+      syncingComments.value = false;
+      if (commentsWork.value) loadComments();
+      break;
+      
+    case 'sync_failed':
+      console.log('[WS] Sync failed:', data.payload);
+      stopSyncAnimation();
+      syncState.status = 'failed';
+      syncState.error = (data.payload?.message as string) || '同步失败';
+      syncingComments.value = false;
+      break;
+  }
+}
+
+// 超时处理:如果2分钟内没收到 WebSocket 消息,显示完成状态
+function startSyncTimeout() {
+  if (syncTimeoutTimer) {
+    clearTimeout(syncTimeoutTimer);
+  }
+  syncTimeoutTimer = setTimeout(() => {
+    if (syncState.status === 'syncing') {
+      console.log('[Sync] Timeout, assuming completed');
+      stopSyncAnimation();
+      syncState.status = 'success';
+      syncState.syncedCount = 0;
+      syncingComments.value = false;
+      ElMessage.info('同步已完成,请刷新页面查看结果');
+    }
+  }, 120000); // 2分钟超时
+}
+
+function cleanupWebSocket() {
+  if (wsReconnectTimer) {
+    clearTimeout(wsReconnectTimer);
+    wsReconnectTimer = null;
+  }
+  if (syncTimeoutTimer) {
+    clearTimeout(syncTimeoutTimer);
+    syncTimeoutTimer = null;
+  }
+  if (ws) {
+    ws.close();
+    ws = null;
+  }
+}
+
+async function syncAllComments() {
+  if (!accounts.value.length) {
+    ElMessage.warning('请先添加平台账号');
+    return;
+  }
+
+  // 使用任务队列
+  try {
+    await request.post('/api/comments/sync');
+    ElMessage.success('评论同步任务已创建,请在任务队列中查看进度');
+    // 打开任务队列弹框
+    taskStore.openDialog();
+  } catch (error) {
+    ElMessage.error((error as Error)?.message || '创建同步任务失败');
+  }
+}
+
+function startSyncAnimation() {
+  let dotCount = 0;
+  syncTimer = setInterval(() => {
+    dotCount = (dotCount + 1) % 4;
+    syncDots.value = '.'.repeat(dotCount);
+    
+    // 只在没有收到 WebSocket 进度时才慢慢增加进度(作为备用)
+    // 真实进度由 WebSocket 消息更新
+    if (syncState.progress < 20 && syncState.step === 1) {
+      // 连接平台阶段,缓慢增加
+      syncState.progress += 1;
+    }
+  }, 500);
+}
+
+function stopSyncAnimation() {
+  if (syncTimer) {
+    clearInterval(syncTimer);
+    syncTimer = null;
+  }
+  syncState.progress = 100;
+  syncState.step = 3;
+}
+
+function closeSyncDialog() {
+  showSyncDialog.value = false;
+  stopSyncAnimation();
+}
+
+function viewAllComments() {
+  closeSyncDialog();
+  commentsWork.value = null; // 不筛选特定作品
+  commentsPagination.page = 1;
+  showCommentsDrawer.value = true;
+  loadComments();
+}
+
 function openWorkDetail(work: Work) {
   currentWork.value = work;
   showDetailDialog.value = true;
@@ -438,18 +756,39 @@ function viewComments(work: Work) {
   loadComments();
 }
 
+async function deletePlatformWork(work: Work) {
+  try {
+    await ElMessageBox.confirm(
+      '确定要从平台删除该作品吗?此操作不可恢复!',
+      '删除确认',
+      { type: 'warning' }
+    );
+    
+    showDetailDialog.value = false;
+    
+    // 调用删除平台作品 API
+    await request.post(`/api/works/${work.id}/delete-platform`);
+    ElMessage.success('删除任务已创建,请在任务队列中查看进度');
+    taskStore.openDialog();
+  } catch {
+    // 取消或错误
+  }
+}
+
 async function loadComments() {
-  if (!commentsWork.value) return;
-  
   commentsLoading.value = true;
   try {
-    const result = await request.get('/api/comments', {
-      params: {
-        workId: commentsWork.value.id,
-        page: commentsPagination.page,
-        pageSize: commentsPagination.pageSize,
-      },
-    });
+    const params: Record<string, unknown> = {
+      page: commentsPagination.page,
+      pageSize: commentsPagination.pageSize,
+    };
+    
+    // 如果有选中的作品,按作品筛选;否则查询所有评论
+    if (commentsWork.value) {
+      params.workId = commentsWork.value.id;
+    }
+    
+    const result = await request.get('/api/comments', { params });
     comments.value = result.items || [];
     commentsPagination.total = result.total || 0;
   } catch {
@@ -491,6 +830,12 @@ onMounted(() => {
   loadAccounts();
   loadWorks();
   loadStats();
+  // WebSocket 连接改为在需要时才建立(点击同步评论按钮时)
+});
+
+onUnmounted(() => {
+  stopSyncAnimation();
+  cleanupWebSocket();
 });
 </script>
 
@@ -821,4 +1166,82 @@ onMounted(() => {
     margin: 8px 0 0;
   }
 }
+
+// 同步对话框样式
+.sync-status {
+  text-align: center;
+  padding: 20px 0;
+  
+  .sync-animation {
+    margin-bottom: 16px;
+  }
+  
+  .sync-text {
+    font-size: 18px;
+    font-weight: 500;
+    color: $text-primary;
+    margin-bottom: 8px;
+    
+    &.success { color: #67c23a; }
+    &.error { color: #f56c6c; }
+    
+    .sync-dots {
+      display: inline-block;
+      width: 24px;
+      text-align: left;
+    }
+  }
+  
+  .sync-hint {
+    font-size: 13px;
+    color: $text-secondary;
+    margin: 12px 0;
+  }
+  
+  .sync-steps {
+    display: flex;
+    justify-content: center;
+    gap: 24px;
+    margin-top: 16px;
+    
+    span {
+      font-size: 13px;
+      color: $text-placeholder;
+      position: relative;
+      
+      &.active {
+        color: $primary-color;
+        font-weight: 500;
+      }
+      
+      &:not(:last-child)::after {
+        content: '→';
+        position: absolute;
+        right: -16px;
+        color: $text-placeholder;
+      }
+    }
+  }
+  
+  .sync-result {
+    margin-top: 16px;
+    font-size: 14px;
+    color: $text-regular;
+    
+    p {
+      margin: 4px 0;
+    }
+    
+    strong {
+      color: $primary-color;
+      font-size: 20px;
+    }
+  }
+  
+  .sync-error {
+    margin-top: 12px;
+    font-size: 13px;
+    color: $text-secondary;
+  }
+}
 </style>

+ 6 - 0
client/vite.config.ts

@@ -45,6 +45,9 @@ export default defineConfig(({ command }) => {
               outDir: 'dist-electron',
               rollupOptions: {
                 external: ['electron'],
+                output: {
+                  format: 'cjs',
+                },
               },
             },
           },
@@ -61,6 +64,9 @@ export default defineConfig(({ command }) => {
               outDir: 'dist-electron',
               rollupOptions: {
                 external: ['electron'],
+                output: {
+                  format: 'cjs',
+                },
               },
             },
           },

+ 1 - 0
matrix

@@ -0,0 +1 @@
+Subproject commit 56bb5976a458e652d30e8e09314a007d34973817

+ 3 - 1
server/src/app.ts

@@ -12,6 +12,7 @@ import { initDatabase } from './models/index.js';
 import { initRedis } from './config/redis.js';
 import { logger } from './utils/logger.js';
 import { taskScheduler } from './scheduler/index.js';
+import { registerTaskExecutors } from './services/taskExecutors.js';
 
 const app = express();
 const httpServer = createServer(app);
@@ -78,8 +79,9 @@ async function bootstrap() {
     logger.warn('Redis connection failed - some features may not work');
   }
 
-  // 只有在数据库连接成功时才启动调度器
+  // 只有在数据库连接成功时才启动调度器和注册任务执行器
   if (dbConnected) {
+    registerTaskExecutors();
     taskScheduler.start();
   }
 

+ 126 - 26
server/src/automation/browser.ts

@@ -1,34 +1,58 @@
 import { chromium, type Browser } from 'playwright';
 import { logger } from '../utils/logger.js';
 
+export interface BrowserOptions {
+  headless?: boolean;
+}
+
 /**
- * 浏览器管理器 - 单例模式
+ * 浏览器管理器
+ * 支持有头和无头两种模式的浏览器实例
  */
 export class BrowserManager {
-  private static browser: Browser | null = null;
-  private static isInitializing = false;
+  // 有头浏览器(用于用户交互,如登录)
+  private static headfulBrowser: Browser | null = null;
+  private static isHeadfulInitializing = false;
+  
+  // 无头浏览器(用于后台任务,如发布视频)
+  private static headlessBrowser: Browser | null = null;
+  private static isHeadlessInitializing = false;
   
   /**
    * 获取浏览器实例
+   * @param options.headless 是否使用无头模式,默认 false
+   */
+  static async getBrowser(options?: BrowserOptions): Promise<Browser> {
+    const headless = options?.headless ?? false;
+    
+    if (headless) {
+      return this.getHeadlessBrowser();
+    } else {
+      return this.getHeadfulBrowser();
+    }
+  }
+  
+  /**
+   * 获取有头浏览器实例(用于用户交互)
    */
-  static async getBrowser(): Promise<Browser> {
-    if (this.browser && this.browser.isConnected()) {
-      return this.browser;
+  private static async getHeadfulBrowser(): Promise<Browser> {
+    if (this.headfulBrowser && this.headfulBrowser.isConnected()) {
+      return this.headfulBrowser;
     }
     
     // 防止并发初始化
-    if (this.isInitializing) {
+    if (this.isHeadfulInitializing) {
       await new Promise(resolve => setTimeout(resolve, 1000));
-      return this.getBrowser();
+      return this.getHeadfulBrowser();
     }
     
-    this.isInitializing = true;
+    this.isHeadfulInitializing = true;
     
     try {
-      logger.info('Launching browser...');
+      logger.info('Launching headful browser...');
       
-      this.browser = await chromium.launch({
-        headless: process.env.NODE_ENV === 'production',
+      this.headfulBrowser = await chromium.launch({
+        headless: false,
         args: [
           '--no-sandbox',
           '--disable-setuid-sandbox',
@@ -39,37 +63,113 @@ export class BrowserManager {
         ],
       });
       
-      this.browser.on('disconnected', () => {
-        logger.warn('Browser disconnected');
-        this.browser = null;
+      this.headfulBrowser.on('disconnected', () => {
+        logger.warn('Headful browser disconnected');
+        this.headfulBrowser = null;
       });
       
-      logger.info('Browser launched successfully');
-      return this.browser;
+      logger.info('Headful browser launched successfully');
+      return this.headfulBrowser;
     } catch (error) {
-      logger.error('Failed to launch browser:', error);
+      logger.error('Failed to launch headful browser:', error);
       throw error;
     } finally {
-      this.isInitializing = false;
+      this.isHeadfulInitializing = false;
+    }
+  }
+  
+  /**
+   * 获取无头浏览器实例(用于后台任务)
+   */
+  private static async getHeadlessBrowser(): Promise<Browser> {
+    if (this.headlessBrowser && this.headlessBrowser.isConnected()) {
+      return this.headlessBrowser;
+    }
+    
+    // 防止并发初始化
+    if (this.isHeadlessInitializing) {
+      await new Promise(resolve => setTimeout(resolve, 1000));
+      return this.getHeadlessBrowser();
+    }
+    
+    this.isHeadlessInitializing = true;
+    
+    try {
+      logger.info('Launching headless browser...');
+      
+      this.headlessBrowser = await chromium.launch({
+        headless: true,
+        args: [
+          '--no-sandbox',
+          '--disable-setuid-sandbox',
+          '--disable-dev-shm-usage',
+          '--disable-accelerated-2d-canvas',
+          '--disable-gpu',
+          '--window-size=1920,1080',
+        ],
+      });
+      
+      this.headlessBrowser.on('disconnected', () => {
+        logger.warn('Headless browser disconnected');
+        this.headlessBrowser = null;
+      });
+      
+      logger.info('Headless browser launched successfully');
+      return this.headlessBrowser;
+    } catch (error) {
+      logger.error('Failed to launch headless browser:', error);
+      throw error;
+    } finally {
+      this.isHeadlessInitializing = false;
     }
   }
   
   /**
    * 关闭浏览器
+   * @param options.headless 指定关闭哪种浏览器,不指定则关闭所有
    */
-  static async closeBrowser(): Promise<void> {
-    if (this.browser) {
-      await this.browser.close();
-      this.browser = null;
-      logger.info('Browser closed');
+  static async closeBrowser(options?: BrowserOptions): Promise<void> {
+    if (options?.headless === true) {
+      // 只关闭无头浏览器
+      if (this.headlessBrowser) {
+        await this.headlessBrowser.close();
+        this.headlessBrowser = null;
+        logger.info('Headless browser closed');
+      }
+    } else if (options?.headless === false) {
+      // 只关闭有头浏览器
+      if (this.headfulBrowser) {
+        await this.headfulBrowser.close();
+        this.headfulBrowser = null;
+        logger.info('Headful browser closed');
+      }
+    } else {
+      // 关闭所有浏览器
+      if (this.headfulBrowser) {
+        await this.headfulBrowser.close();
+        this.headfulBrowser = null;
+        logger.info('Headful browser closed');
+      }
+      if (this.headlessBrowser) {
+        await this.headlessBrowser.close();
+        this.headlessBrowser = null;
+        logger.info('Headless browser closed');
+      }
     }
   }
   
   /**
    * 检查浏览器状态
    */
-  static isConnected(): boolean {
-    return this.browser?.isConnected() ?? false;
+  static isConnected(options?: BrowserOptions): boolean {
+    if (options?.headless === true) {
+      return this.headlessBrowser?.isConnected() ?? false;
+    } else if (options?.headless === false) {
+      return this.headfulBrowser?.isConnected() ?? false;
+    }
+    // 任一浏览器连接即返回 true
+    return (this.headfulBrowser?.isConnected() ?? false) || 
+           (this.headlessBrowser?.isConnected() ?? false);
   }
 }
 

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

@@ -72,6 +72,11 @@ export interface CommentData {
   parentCommentId?: string;
 }
 
+export interface InitBrowserOptions {
+  proxyConfig?: ProxyConfig;
+  headless?: boolean;  // 是否使用无头模式
+}
+
 /**
  * 平台适配器基类
  */
@@ -82,17 +87,32 @@ export abstract class BasePlatformAdapter {
   protected browser: Browser | null = null;
   protected context: BrowserContext | null = null;
   protected page: Page | null = null;
+  protected isHeadless: boolean = false;  // 记录当前是否为无头模式
   
   /**
    * 初始化浏览器
+   * @param options.proxyConfig 代理配置
+   * @param options.headless 是否使用无头模式(后台运行),默认 false
    */
-  async initBrowser(proxyConfig?: ProxyConfig): Promise<void> {
+  async initBrowser(options?: InitBrowserOptions | ProxyConfig): Promise<void> {
     // 如果已有浏览器上下文,先关闭
     if (this.context) {
       await this.closeBrowser();
     }
     
-    this.browser = await BrowserManager.getBrowser();
+    // 兼容旧的调用方式(直接传 proxyConfig)
+    let proxyConfig: ProxyConfig | undefined;
+    let headless = false;
+    
+    if (options && 'headless' in options) {
+      proxyConfig = options.proxyConfig;
+      headless = options.headless ?? false;
+    } else {
+      proxyConfig = options as ProxyConfig | undefined;
+    }
+    
+    this.isHeadless = headless;
+    this.browser = await BrowserManager.getBrowser({ headless });
     
     const contextOptions: Record<string, unknown> = {
       viewport: { width: 1920, height: 1080 },
@@ -114,6 +134,7 @@ export abstract class BasePlatformAdapter {
   
   /**
    * 关闭浏览器
+   * 对于 headful 模式,会关闭整个浏览器窗口
    */
   async closeBrowser(): Promise<void> {
     if (this.page) {
@@ -124,6 +145,11 @@ export abstract class BasePlatformAdapter {
       await this.context.close();
       this.context = null;
     }
+    // 关闭对应的浏览器实例(特别是 headful 模式的浏览器窗口)
+    if (!this.isHeadless) {
+      await BrowserManager.closeBrowser({ headless: false });
+    }
+    this.browser = null;
   }
   
   /**

+ 940 - 42
server/src/automation/platforms/douyin.ts

@@ -98,7 +98,8 @@ export class DouyinAdapter extends BasePlatformAdapter {
    */
   async checkLoginStatus(cookies: string): Promise<boolean> {
     try {
-      await this.initBrowser();
+      // 使用无头浏览器后台运行
+      await this.initBrowser({ headless: true });
       await this.setCookies(cookies);
       
       if (!this.page) throw new Error('Page not initialized');
@@ -146,7 +147,8 @@ export class DouyinAdapter extends BasePlatformAdapter {
    */
   async getAccountInfo(cookies: string): Promise<AccountProfile> {
     try {
-      await this.initBrowser();
+      // 使用无头浏览器后台运行
+      await this.initBrowser({ headless: true });
       await this.setCookies(cookies);
       
       if (!this.page) throw new Error('Page not initialized');
@@ -354,69 +356,801 @@ export class DouyinAdapter extends BasePlatformAdapter {
   }
   
   /**
+   * 验证码信息类型
+   */
+  private captchaTypes = {
+    SMS: 'sms',      // 短信验证码
+    IMAGE: 'image',  // 图形验证码
+  } as const;
+
+  /**
+   * 处理验证码弹框(支持短信验证码和图形验证码)
+   * @param onCaptchaRequired 验证码回调
+   * @returns 'success' | 'failed' | 'not_needed'
+   */
+  private async handleCaptchaIfNeeded(
+    onCaptchaRequired?: (captchaInfo: { 
+      taskId: string; 
+      type: 'sms' | 'image';
+      phone?: string;
+      imageBase64?: string;
+    }) => Promise<string>
+  ): Promise<'success' | 'failed' | 'not_needed' | 'need_retry_headful'> {
+    if (!this.page) return 'not_needed';
+    
+    try {
+      // 1. 先检测图形验证码弹框("请完成身份验证后继续")
+      logger.info('[Douyin Publish] Checking for captcha...');
+      const imageCaptchaResult = await this.handleImageCaptcha(onCaptchaRequired);
+      if (imageCaptchaResult !== 'not_needed') {
+        logger.info(`[Douyin Publish] Image captcha result: ${imageCaptchaResult}`);
+        return imageCaptchaResult;
+      }
+      
+      // 2. 再检测短信验证码弹框
+      const smsCaptchaResult = await this.handleSmsCaptcha(onCaptchaRequired);
+      if (smsCaptchaResult !== 'not_needed') {
+        logger.info(`[Douyin Publish] SMS captcha result: ${smsCaptchaResult}`);
+      }
+      return smsCaptchaResult;
+      
+    } catch (error) {
+      logger.error('[Douyin Publish] Captcha handling error:', error);
+      return 'not_needed';
+    }
+  }
+
+  /**
+   * 处理图形验证码
+   * @returns 'need_retry_headful' 表示在 headless 模式检测到验证码,需要用 headful 模式重新发布
+   */
+  private async handleImageCaptcha(
+    onCaptchaRequired?: (captchaInfo: { 
+      taskId: string; 
+      type: 'sms' | 'image';
+      phone?: string;
+      imageBase64?: string;
+    }) => Promise<string>
+  ): Promise<'success' | 'failed' | 'not_needed' | 'need_retry_headful'> {
+    if (!this.page) return 'not_needed';
+    
+    try {
+      // 图形验证码检测 - 使用多种方式检测
+      // 标题:"请完成身份验证后继续"
+      // 提示:"为保护帐号安全,请根据图片输入验证码"
+      
+      let hasImageCaptcha = false;
+      
+      // 方式1: 使用 getByText 直接查找可见的文本
+      const captchaTitle = this.page.getByText('请完成身份验证后继续', { exact: false });
+      const captchaHint = this.page.getByText('请根据图片输入验证码', { exact: false });
+      
+      const titleCount = await captchaTitle.count().catch(() => 0);
+      const hintCount = await captchaHint.count().catch(() => 0);
+      
+      logger.info(`[Douyin Publish] Image captcha check - title elements: ${titleCount}, hint elements: ${hintCount}`);
+      
+      if (titleCount > 0) {
+        const isVisible = await captchaTitle.first().isVisible().catch(() => false);
+        logger.info(`[Douyin Publish] Image captcha title visible: ${isVisible}`);
+        if (isVisible) {
+          hasImageCaptcha = true;
+        }
+      }
+      
+      if (!hasImageCaptcha && hintCount > 0) {
+        const isVisible = await captchaHint.first().isVisible().catch(() => false);
+        logger.info(`[Douyin Publish] Image captcha hint visible: ${isVisible}`);
+        if (isVisible) {
+          hasImageCaptcha = true;
+        }
+      }
+      
+      // 方式2: 检查页面 HTML 内容作为备用
+      if (!hasImageCaptcha) {
+        const pageContent = await this.page.content().catch(() => '');
+        if (pageContent.includes('请完成身份验证后继续') || pageContent.includes('请根据图片输入验证码')) {
+          logger.info('[Douyin Publish] Image captcha text found in page content');
+          // 再次尝试使用选择器
+          const modalCandidates = [
+            'div[class*="modal"]:visible',
+            'div[role="dialog"]:visible',
+            '[class*="verify-modal"]',
+            '[class*="captcha"]',
+          ];
+          for (const selector of modalCandidates) {
+            const modal = this.page.locator(selector).first();
+            if (await modal.count().catch(() => 0) > 0 && await modal.isVisible().catch(() => false)) {
+              hasImageCaptcha = true;
+              logger.info(`[Douyin Publish] Image captcha modal found via: ${selector}`);
+              break;
+            }
+          }
+        }
+      }
+      
+      if (!hasImageCaptcha) {
+        return 'not_needed';
+      }
+      
+      logger.info('[Douyin Publish] Image captcha modal detected!');
+      
+      // 截图保存当前状态
+      try {
+        const screenshotPath = `uploads/debug/image_captcha_${Date.now()}.png`;
+        await this.page.screenshot({ path: screenshotPath, fullPage: true });
+        logger.info(`[Douyin Publish] Image captcha screenshot saved: ${screenshotPath}`);
+      } catch {}
+      
+      // 如果当前是 headless 模式,返回特殊状态让调用方用 headful 模式重试
+      if (this.isHeadless) {
+        logger.info('[Douyin Publish] Captcha detected in HEADLESS mode, need to restart with HEADFUL');
+        return 'need_retry_headful';
+      }
+      
+      // 已经是 headful 模式,通知前端并等待用户完成验证
+      logger.info('[Douyin Publish] In HEADFUL mode, waiting for user to complete captcha in browser window...');
+      
+      // 通知前端验证码需要手动输入
+      if (onCaptchaRequired) {
+        const taskId = `captcha_manual_${Date.now()}`;
+        onCaptchaRequired({ 
+          taskId, 
+          type: 'image',
+          imageBase64: '',
+        }).catch(() => {});
+      }
+      
+      // 等待验证码弹框消失(用户在浏览器窗口中完成验证)
+      const captchaTimeout = 180000; // 3 分钟超时
+      const captchaStartTime = Date.now();
+      
+      while (Date.now() - captchaStartTime < captchaTimeout) {
+        await this.page.waitForTimeout(2000);
+        
+        const captchaTitle = this.page.getByText('请完成身份验证后继续', { exact: false });
+        const captchaHint = this.page.getByText('请根据图片输入验证码', { exact: false });
+        
+        const titleVisible = await captchaTitle.count() > 0 && await captchaTitle.first().isVisible().catch(() => false);
+        const hintVisible = await captchaHint.count() > 0 && await captchaHint.first().isVisible().catch(() => false);
+        
+        if (!titleVisible && !hintVisible) {
+          logger.info('[Douyin Publish] Captcha completed by user!');
+          await this.page.waitForTimeout(2000);
+          return 'success';
+        }
+        
+        const elapsed = Math.floor((Date.now() - captchaStartTime) / 1000);
+        logger.info(`[Douyin Publish] Waiting for captcha (${elapsed}s)...`);
+      }
+      
+      logger.error('[Douyin Publish] Captcha timeout');
+      return 'failed';
+      
+    } catch (error) {
+      logger.error('[Douyin Publish] Image captcha handling error:', error);
+      return 'not_needed';
+    }
+  }
+
+  /**
+   * 处理短信验证码
+   */
+  private async handleSmsCaptcha(
+    onCaptchaRequired?: (captchaInfo: { 
+      taskId: string; 
+      type: 'sms' | 'image';
+      phone?: string;
+      imageBase64?: string;
+    }) => Promise<string>
+  ): Promise<'success' | 'failed' | 'not_needed'> {
+    if (!this.page) return 'not_needed';
+    
+    try {
+      // 短信验证码弹框选择器
+      const smsCaptchaSelectors = [
+        '.second-verify-panel',
+        '.uc-ui-verify_sms-verify',
+        '.uc-ui-verify-new_header-title:has-text("接收短信验证码")',
+        'article.uc-ui-verify_sms-verify',
+      ];
+      
+      let hasSmsCaptcha = false;
+      for (const selector of smsCaptchaSelectors) {
+        const element = this.page.locator(selector).first();
+        const count = await element.count().catch(() => 0);
+        if (count > 0) {
+          const isVisible = await element.isVisible().catch(() => false);
+          if (isVisible) {
+            hasSmsCaptcha = true;
+            logger.info(`[Douyin Publish] SMS captcha detected with selector: ${selector}`);
+            break;
+          }
+        }
+      }
+      
+      if (!hasSmsCaptcha) {
+        return 'not_needed';
+      }
+      
+      logger.info('[Douyin Publish] SMS captcha modal detected!');
+      
+      // 截图保存
+      try {
+        const screenshotPath = `uploads/debug/sms_captcha_${Date.now()}.png`;
+        await this.page.screenshot({ path: screenshotPath, fullPage: true });
+        logger.info(`[Douyin Publish] SMS captcha screenshot saved: ${screenshotPath}`);
+      } catch {}
+      
+      // 获取手机号
+      let phone = '';
+      try {
+        const phoneElement = this.page.locator('.var_TextPrimary').first();
+        if (await phoneElement.count() > 0) {
+          phone = await phoneElement.textContent() || '';
+        }
+        if (!phone) {
+          const pageText = await this.page.locator('.second-verify-panel, .uc-ui-verify_sms-verify').first().textContent() || '';
+          const phoneMatch = pageText.match(/1\d{2}\*{4,6}\d{2}/);
+          if (phoneMatch) phone = phoneMatch[0];
+        }
+        logger.info(`[Douyin Publish] Found phone number: ${phone}`);
+      } catch {}
+      
+      // 点击获取验证码按钮
+      const getCaptchaBtnSelectors = [
+        '.uc-ui-input_right p:has-text("获取验证码")',
+        '.uc-ui-input_right:has-text("获取验证码")',
+        'p:has-text("获取验证码")',
+      ];
+      
+      for (const selector of getCaptchaBtnSelectors) {
+        const btn = this.page.locator(selector).first();
+        if (await btn.count() > 0 && await btn.isVisible()) {
+          try {
+            await btn.click();
+            logger.info(`[Douyin Publish] Clicked "获取验证码" button: ${selector}`);
+            await this.page.waitForTimeout(1500);
+            break;
+          } catch {}
+        }
+      }
+      
+      if (!onCaptchaRequired) {
+        logger.error('[Douyin Publish] SMS captcha required but no callback provided');
+        return 'failed';
+      }
+      
+      const taskId = `captcha_${Date.now()}`;
+      logger.info(`[Douyin Publish] Requesting SMS captcha from user, taskId: ${taskId}, phone: ${phone}`);
+      
+      const captchaCode = await onCaptchaRequired({ 
+        taskId, 
+        type: 'sms',
+        phone,
+      });
+      
+      if (!captchaCode) {
+        logger.error('[Douyin Publish] No SMS captcha code received');
+        return 'failed';
+      }
+      
+      logger.info(`[Douyin Publish] Received SMS captcha code: ${captchaCode}`);
+      
+      // 填写验证码
+      const inputSelectors = [
+        '.second-verify-panel input[type="number"]',
+        '.uc-ui-verify_sms-verify input[type="number"]',
+        '.uc-ui-input input[placeholder="请输入验证码"]',
+        'input[placeholder="请输入验证码"]',
+        '.uc-ui-input_textbox input',
+      ];
+      
+      let inputFilled = false;
+      for (const selector of inputSelectors) {
+        const input = this.page.locator(selector).first();
+        if (await input.count() > 0 && await input.isVisible()) {
+          await input.click();
+          await input.fill('');
+          await input.type(captchaCode, { delay: 50 });
+          inputFilled = true;
+          logger.info(`[Douyin Publish] SMS captcha code filled via: ${selector}`);
+          break;
+        }
+      }
+      
+      if (!inputFilled) {
+        logger.error('[Douyin Publish] SMS captcha input not found');
+        return 'failed';
+      }
+      
+      await this.page.waitForTimeout(500);
+      
+      // 点击验证按钮
+      const verifyBtnSelectors = [
+        '.uc-ui-verify_sms-verify_button:has-text("验证"):not(.disabled)',
+        '.uc-ui-button:has-text("验证"):not(.disabled)',
+        '.second-verify-panel .uc-ui-button:has-text("验证")',
+        'div.uc-ui-button:has-text("验证")',
+      ];
+      
+      for (const selector of verifyBtnSelectors) {
+        const btn = this.page.locator(selector).first();
+        if (await btn.count() > 0 && await btn.isVisible()) {
+          try {
+            const isDisabled = await btn.evaluate((el: HTMLElement) => el.classList.contains('disabled'));
+            if (!isDisabled) {
+              await btn.click();
+              logger.info(`[Douyin Publish] Clicked SMS verify button: ${selector}`);
+              break;
+            }
+          } catch {}
+        }
+      }
+      
+      // 等待结果
+      await this.page.waitForTimeout(3000);
+      
+      // 检查弹框是否消失
+      let stillHasCaptcha = false;
+      for (const selector of smsCaptchaSelectors) {
+        const element = this.page.locator(selector).first();
+        const isVisible = await element.isVisible().catch(() => false);
+        if (isVisible) {
+          stillHasCaptcha = true;
+          break;
+        }
+      }
+      
+      if (!stillHasCaptcha) {
+        logger.info('[Douyin Publish] SMS captcha verified successfully');
+        return 'success';
+      }
+      
+      logger.warn('[Douyin Publish] SMS captcha modal still visible');
+      return 'failed';
+      
+    } catch (error) {
+      logger.error('[Douyin Publish] SMS captcha handling error:', error);
+      return 'not_needed';
+    }
+  }
+  
+  /**
    * 发布视频
+   * 参考 https://github.com/kebenxiaoming/matrix 项目实现
+   * @param onCaptchaRequired 验证码回调,返回用户输入的验证码
+   * @param options.headless 是否使用无头模式,默认 true
    */
-  async publishVideo(cookies: string, params: PublishParams): Promise<PublishResult> {
+  async publishVideo(
+    cookies: string, 
+    params: PublishParams, 
+    onProgress?: (progress: number, message: string) => void,
+    onCaptchaRequired?: (captchaInfo: { taskId: string; phone?: string }) => Promise<string>,
+    options?: { headless?: boolean }
+  ): Promise<PublishResult> {
+    const useHeadless = options?.headless ?? true;
+    
     try {
-      await this.initBrowser();
+      await this.initBrowser({ headless: useHeadless });
       await this.setCookies(cookies);
       
+      if (!useHeadless) {
+        logger.info('[Douyin Publish] Running in HEADFUL mode - browser window is visible');
+        onProgress?.(1, '已打开浏览器窗口,请注意查看...');
+      }
+      
       if (!this.page) throw new Error('Page not initialized');
       
+      // 检查视频文件是否存在
+      const fs = await import('fs');
+      if (!fs.existsSync(params.videoPath)) {
+        throw new Error(`视频文件不存在: ${params.videoPath}`);
+      }
+      
+      onProgress?.(5, '正在打开上传页面...');
+      logger.info(`[Douyin Publish] Starting upload for: ${params.videoPath}`);
+      
       // 访问上传页面
-      await this.page.goto(this.publishUrl);
-      await this.page.waitForLoadState('networkidle');
+      await this.page.goto(this.publishUrl, {
+        waitUntil: 'domcontentloaded',
+        timeout: 60000,
+      });
+      
+      // 等待页面加载
+      await this.page.waitForTimeout(3000);
+      logger.info(`[Douyin Publish] Page loaded: ${this.page.url()}`);
       
-      // 上传视频
-      const fileInput = await this.page.$('input[type="file"]');
-      if (!fileInput) {
-        throw new Error('File input not found');
+      onProgress?.(10, '正在选择视频文件...');
+      
+      // 参考 matrix: 点击上传区域触发文件选择
+      // 选择器: div.container-drag-info-Tl0RGH
+      const uploadDivSelectors = [
+        'div[class*="container-drag-info"]',
+        'div[class*="upload-btn"]',
+        'div[class*="drag-area"]',
+        '[class*="upload"] [class*="drag"]',
+      ];
+      
+      let uploadTriggered = false;
+      for (const selector of uploadDivSelectors) {
+        try {
+          const uploadDiv = this.page.locator(selector).first();
+          if (await uploadDiv.count() > 0) {
+            logger.info(`[Douyin Publish] Found upload div: ${selector}`);
+            
+            // 使用 expect_file_chooser 方式上传(参考 matrix)
+            const [fileChooser] = await Promise.all([
+              this.page.waitForEvent('filechooser', { timeout: 10000 }),
+              uploadDiv.click(),
+            ]);
+            
+            await fileChooser.setFiles(params.videoPath);
+            uploadTriggered = true;
+            logger.info(`[Douyin Publish] File selected via file chooser`);
+            break;
+          }
+        } catch (e) {
+          logger.warn(`[Douyin Publish] Failed with selector ${selector}:`, e);
+        }
       }
-      await fileInput.setInputFiles(params.videoPath);
       
-      // 等待上传完成
-      await this.page.waitForSelector('.upload-success', { timeout: 300000 });
+      // 如果点击方式失败,尝试直接设置 input
+      if (!uploadTriggered) {
+        logger.info('[Douyin Publish] Trying direct input method...');
+        const fileInput = await this.page.$('input[type="file"]');
+        if (fileInput) {
+          await fileInput.setInputFiles(params.videoPath);
+          uploadTriggered = true;
+          logger.info('[Douyin Publish] File set via input element');
+        }
+      }
+      
+      if (!uploadTriggered) {
+        throw new Error('无法触发文件上传');
+      }
       
-      // 填写标题
-      await this.type('.title-input', params.title);
+      onProgress?.(15, '视频上传中,等待跳转到发布页面...');
       
-      // 填写描述
-      if (params.description) {
-        await this.type('.description-input', params.description);
+      // 参考 matrix: 等待页面跳转到发布页面
+      // URL: https://creator.douyin.com/creator-micro/content/post/video?enter_from=publish_page
+      const maxWaitTime = 180000; // 3分钟
+      const startTime = Date.now();
+      
+      while (Date.now() - startTime < maxWaitTime) {
+        await this.page.waitForTimeout(2000);
+        const currentUrl = this.page.url();
+        
+        if (currentUrl.includes('/content/post/video')) {
+          logger.info('[Douyin Publish] Entered video post page');
+          break;
+        }
+        
+        // 检查上传进度
+        const progressText = await this.page.locator('[class*="progress"]').first().textContent().catch(() => '');
+        if (progressText) {
+          const match = progressText.match(/(\d+)%/);
+          if (match) {
+            const progress = parseInt(match[1]);
+            onProgress?.(15 + Math.floor(progress * 0.3), `视频上传中: ${progress}%`);
+          }
+        }
+        
+        // 检查是否上传失败
+        const failText = await this.page.locator('div:has-text("上传失败")').first().count().catch(() => 0);
+        if (failText > 0) {
+          throw new Error('视频上传失败');
+        }
       }
       
-      // 添加标签
-      if (params.tags?.length) {
-        for (const tag of params.tags) {
-          await this.type('.tag-input', `#${tag} `);
+      if (!this.page.url().includes('/content/post/video')) {
+        throw new Error('等待进入发布页面超时');
+      }
+      
+      onProgress?.(50, '正在填写视频信息...');
+      await this.page.waitForTimeout(2000);
+      
+      // 参考 matrix: 填充标题
+      // 先尝试找到标题输入框
+      logger.info('[Douyin Publish] Filling title...');
+      
+      // 方式1: 找到 "作品标题" 旁边的 input
+      const titleInput = this.page.getByText('作品标题').locator('..').locator('xpath=following-sibling::div[1]').locator('input');
+      if (await titleInput.count() > 0) {
+        await titleInput.fill(params.title.slice(0, 30));
+        logger.info('[Douyin Publish] Title filled via input');
+      } else {
+        // 方式2: 使用 .notranslate 编辑器(参考 matrix)
+        const editorContainer = this.page.locator('.notranslate, [class*="editor"] [contenteditable="true"]').first();
+        if (await editorContainer.count() > 0) {
+          await editorContainer.click();
+          await this.page.keyboard.press('Control+A');
+          await this.page.keyboard.press('Backspace');
+          await this.page.keyboard.type(params.title, { delay: 30 });
+          await this.page.keyboard.press('Enter');
+          logger.info('[Douyin Publish] Title filled via editor');
         }
       }
       
-      // 上传封面
-      if (params.coverPath) {
-        const coverInput = await this.page.$('.cover-upload input[type="file"]');
-        if (coverInput) {
-          await coverInput.setInputFiles(params.coverPath);
+      onProgress?.(60, '正在添加话题标签...');
+      
+      // 参考 matrix: 添加话题标签
+      // 使用 .zone-container 选择器
+      if (params.tags && params.tags.length > 0) {
+        const tagContainer = '.zone-container, [class*="mention-container"], [class*="hash-tag"]';
+        
+        for (let i = 0; i < params.tags.length; i++) {
+          const tag = params.tags[i];
+          logger.info(`[Douyin Publish] Adding tag ${i + 1}: ${tag}`);
+          
+          try {
+            await this.page.type(tagContainer, `#${tag}`, { delay: 50 });
+            await this.page.keyboard.press('Space');
+            await this.page.waitForTimeout(500);
+          } catch (e) {
+            // 如果失败,尝试在编辑器中添加
+            try {
+              await this.page.keyboard.type(` #${tag} `, { delay: 50 });
+              await this.page.waitForTimeout(500);
+            } catch {
+              logger.warn(`[Douyin Publish] Failed to add tag: ${tag}`);
+            }
+          }
         }
       }
       
-      // 点击发布
-      await this.click('.publish-button');
+      onProgress?.(70, '等待视频处理完成...');
       
-      // 等待发布完成
-      await this.page.waitForSelector('.publish-success', { timeout: 60000 });
+      // 参考 matrix: 等待 "重新上传" 按钮出现,表示视频上传完成
+      const uploadCompleteMaxWait = 600000; // 增加到 10 分钟
+      const uploadStartTime = Date.now();
+      let videoProcessed = false;
       
-      // 获取视频链接
-      const videoUrl = await this.page.$eval('.video-url', el => el.getAttribute('href') || '');
+      while (Date.now() - uploadStartTime < uploadCompleteMaxWait) {
+        // 检查多种完成标志
+        const reuploadCount = await this.page.locator('div').filter({ hasText: '重新上传' }).count().catch(() => 0);
+        const replaceCount = await this.page.locator('div:has-text("替换"), button:has-text("替换")').count().catch(() => 0);
+        const completeCount = await this.page.locator('[class*="upload-complete"], [class*="upload-success"]').count().catch(() => 0);
+        
+        if (reuploadCount > 0 || replaceCount > 0 || completeCount > 0) {
+          logger.info('[Douyin Publish] Video upload completed');
+          videoProcessed = true;
+          break;
+        }
+        
+        // 检查发布按钮是否可用(也是上传完成的标志)
+        const publishBtnEnabled = await this.page.getByRole('button', { name: '发布', exact: true }).isEnabled().catch(() => false);
+        if (publishBtnEnabled) {
+          logger.info('[Douyin Publish] Publish button is enabled, video should be ready');
+          videoProcessed = true;
+          break;
+        }
+        
+        // 检查上传失败
+        const failCount = await this.page.locator('div:has-text("上传失败")').count().catch(() => 0);
+        if (failCount > 0) {
+          throw new Error('视频处理失败');
+        }
+        
+        const elapsed = Math.floor((Date.now() - uploadStartTime) / 1000);
+        logger.info(`[Douyin Publish] Waiting for video processing... (${elapsed}s)`);
+        await this.page.waitForTimeout(3000);
+        onProgress?.(70 + Math.min(14, Math.floor(elapsed / 20)), `等待视频处理完成 (${elapsed}s)...`);
+      }
       
-      await this.closeBrowser();
+      if (!videoProcessed) {
+        logger.warn('[Douyin Publish] Video processing timeout, but will try to publish anyway');
+      }
+      
+      // 点击 "我知道了" 弹窗(如果存在)
+      const knownBtn = this.page.getByRole('button', { name: '我知道了' });
+      if (await knownBtn.count() > 0) {
+        await knownBtn.first().click();
+        await this.page.waitForTimeout(1000);
+      }
+      
+      onProgress?.(85, '正在发布...');
+      await this.page.waitForTimeout(3000);
+      
+      // 参考 matrix: 点击发布按钮
+      logger.info('[Douyin Publish] Looking for publish button...');
+      
+      // 尝试多种方式找到发布按钮
+      let publishClicked = false;
+      
+      // 方式1: 使用 getByRole
+      const publishBtn = this.page.getByRole('button', { name: '发布', exact: true });
+      if (await publishBtn.count() > 0) {
+        // 等待按钮可点击
+        try {
+          await publishBtn.waitFor({ state: 'visible', timeout: 10000 });
+          const isEnabled = await publishBtn.isEnabled();
+          if (isEnabled) {
+            await publishBtn.click();
+            publishClicked = true;
+            logger.info('[Douyin Publish] Publish button clicked via getByRole');
+          }
+        } catch (e) {
+          logger.warn('[Douyin Publish] getByRole method failed:', e);
+        }
+      }
+      
+      // 方式2: 使用选择器
+      if (!publishClicked) {
+        const selectors = [
+          'button:has-text("发布")',
+          '[class*="publish-btn"]',
+          'button[class*="primary"]:has-text("发布")',
+          '.semi-button-primary:has-text("发布")',
+        ];
+        
+        for (const selector of selectors) {
+          const btn = this.page.locator(selector).first();
+          if (await btn.count() > 0) {
+            try {
+              const isEnabled = await btn.isEnabled();
+              if (isEnabled) {
+                await btn.click();
+                publishClicked = true;
+                logger.info(`[Douyin Publish] Publish button clicked via selector: ${selector}`);
+                break;
+              }
+            } catch {}
+          }
+        }
+      }
+      
+      if (!publishClicked) {
+        // 截图帮助调试
+        try {
+          const screenshotPath = `uploads/debug/no_publish_btn_${Date.now()}.png`;
+          await this.page.screenshot({ path: screenshotPath, fullPage: true });
+          logger.info(`[Douyin Publish] Screenshot saved: ${screenshotPath}`);
+        } catch {}
+        throw new Error('未找到可点击的发布按钮');
+      }
+      
+      logger.info('[Douyin Publish] Publish button clicked, waiting for result...');
+      
+      // 点击发布后截图
+      await this.page.waitForTimeout(2000);
+      try {
+        const screenshotPath = `uploads/debug/after_publish_click_${Date.now()}.png`;
+        await this.page.screenshot({ path: screenshotPath, fullPage: true });
+        logger.info(`[Douyin Publish] After click screenshot saved: ${screenshotPath}`);
+      } catch {}
+      
+      // 检查是否有确认弹窗需要处理
+      const confirmSelectors = [
+        'button:has-text("确认发布")',
+        'button:has-text("确定")',
+        'button:has-text("确认")',
+        '.semi-modal button:has-text("发布")',
+        '[class*="modal"] button[class*="primary"]',
+      ];
+      
+      for (const selector of confirmSelectors) {
+        const confirmBtn = this.page.locator(selector).first();
+        if (await confirmBtn.count() > 0 && await confirmBtn.isVisible()) {
+          logger.info(`[Douyin Publish] Found confirm button: ${selector}`);
+          await confirmBtn.click();
+          await this.page.waitForTimeout(2000);
+          logger.info('[Douyin Publish] Confirm button clicked');
+          break;
+        }
+      }
+      
+      // 检查是否需要验证码
+      const captchaHandled = await this.handleCaptchaIfNeeded(onCaptchaRequired);
+      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, '检测到验证码,正在打开浏览器窗口重新发布...');
+        await this.closeBrowser();
+        // 递归调用,使用 headful 模式
+        return this.publishVideo(cookies, params, onProgress, onCaptchaRequired, { headless: false });
+      }
+      
+      onProgress?.(90, '等待发布完成...');
+      
+      // 参考 matrix: 等待跳转到管理页面表示发布成功
+      // URL: https://creator.douyin.com/creator-micro/content/manage
+      const publishMaxWait = 180000; // 3 分钟
+      const publishStartTime = Date.now();
+      
+      // 记录点击发布时的 URL,用于检测是否跳转
+      const publishPageUrl = this.page.url();
+      logger.info(`[Douyin Publish] Publish page URL: ${publishPageUrl}`);
+      
+      while (Date.now() - publishStartTime < publishMaxWait) {
+        await this.page.waitForTimeout(3000);
+        const currentUrl = this.page.url();
+        
+        const elapsed = Math.floor((Date.now() - publishStartTime) / 1000);
+        logger.info(`[Douyin Publish] Waiting for redirect (${elapsed}s), current URL: ${currentUrl}`);
+        
+        // 在等待过程中也检测验证码弹框
+        const captchaResult = await this.handleCaptchaIfNeeded(onCaptchaRequired);
+        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, '检测到验证码,正在打开浏览器窗口重新发布...');
+          await this.closeBrowser();
+          return this.publishVideo(cookies, params, onProgress, onCaptchaRequired, { headless: false });
+        }
+        
+        // 检查是否跳转到管理页面 - 这是最可靠的成功标志
+        if (currentUrl.includes('/content/manage')) {
+          logger.info('[Douyin Publish] Publish success! Redirected to manage page');
+          onProgress?.(100, '发布成功!');
+          
+          await this.closeBrowser();
+          return {
+            success: true,
+            videoUrl: currentUrl,
+          };
+        }
+        
+        // 检查是否有成功提示弹窗(Toast/Modal)
+        // 使用更精确的选择器,避免匹配按钮文字
+        const successToast = await this.page.locator('.semi-toast-content:has-text("发布成功"), .semi-modal-body:has-text("发布成功"), [class*="toast"]:has-text("发布成功"), [class*="message"]:has-text("发布成功")').count().catch(() => 0);
+        if (successToast > 0) {
+          logger.info('[Douyin Publish] Found success toast/modal');
+          // 等待一下看是否会跳转
+          await this.page.waitForTimeout(5000);
+          const newUrl = this.page.url();
+          if (newUrl.includes('/content/manage')) {
+            logger.info('[Douyin Publish] Redirected to manage page after success toast');
+            onProgress?.(100, '发布成功!');
+            await this.closeBrowser();
+            return {
+              success: true,
+              videoUrl: newUrl,
+            };
+          }
+        }
+        
+        // 检查是否有明确的错误提示弹窗
+        const errorToast = await this.page.locator('.semi-toast-error, [class*="toast-error"], .semi-modal-body:has-text("失败")').first().textContent().catch(() => '');
+        if (errorToast && errorToast.includes('失败')) {
+          logger.error(`[Douyin Publish] Error toast found: ${errorToast}`);
+          throw new Error(`发布失败: ${errorToast}`);
+        }
+        
+        // 更新进度
+        onProgress?.(90 + Math.min(9, Math.floor(elapsed / 20)), `等待发布完成 (${elapsed}s)...`);
+      }
+      
+      // 如果超时,最后检查一次当前页面状态
+      const finalUrl = this.page.url();
+      logger.info(`[Douyin Publish] Timeout! Final URL: ${finalUrl}`);
+      
+      if (finalUrl.includes('/content/manage')) {
+        onProgress?.(100, '发布成功!');
+        await this.closeBrowser();
+        return {
+          success: true,
+          videoUrl: finalUrl,
+        };
+      }
+      
+      // 截图保存用于调试
+      try {
+        const screenshotPath = `uploads/debug/publish_timeout_${Date.now()}.png`;
+        await this.page.screenshot({ path: screenshotPath, fullPage: true });
+        logger.info(`[Douyin Publish] Timeout screenshot saved: ${screenshotPath}`);
+      } catch {}
+      
+      throw new Error('发布超时,页面未跳转到管理页面,请手动检查是否发布成功');
       
-      return {
-        success: true,
-        videoUrl,
-      };
     } catch (error) {
-      logger.error('Douyin publishVideo error:', error);
+      logger.error('[Douyin Publish] Error:', error);
       await this.closeBrowser();
       return {
         success: false,
@@ -430,7 +1164,8 @@ export class DouyinAdapter extends BasePlatformAdapter {
    */
   async getComments(cookies: string, videoId: string): Promise<CommentData[]> {
     try {
-      await this.initBrowser();
+      // 使用无头浏览器后台运行
+      await this.initBrowser({ headless: true });
       await this.setCookies(cookies);
       
       if (!this.page) throw new Error('Page not initialized');
@@ -467,7 +1202,8 @@ export class DouyinAdapter extends BasePlatformAdapter {
    */
   async replyComment(cookies: string, commentId: string, content: string): Promise<boolean> {
     try {
-      await this.initBrowser();
+      // 使用无头浏览器后台运行
+      await this.initBrowser({ headless: true });
       await this.setCookies(cookies);
       
       if (!this.page) throw new Error('Page not initialized');
@@ -492,7 +1228,8 @@ export class DouyinAdapter extends BasePlatformAdapter {
    */
   async getAnalytics(cookies: string, dateRange: DateRange): Promise<AnalyticsData> {
     try {
-      await this.initBrowser();
+      // 使用无头浏览器后台运行
+      await this.initBrowser({ headless: true });
       await this.setCookies(cookies);
       
       if (!this.page) throw new Error('Page not initialized');
@@ -522,6 +1259,167 @@ export class DouyinAdapter extends BasePlatformAdapter {
   }
   
   /**
+   * 删除已发布的作品
+   */
+  async deleteWork(
+    cookies: string, 
+    videoId: string,
+    onCaptchaRequired?: (captchaInfo: { taskId: string; imageUrl?: string }) => Promise<string>
+  ): Promise<{ success: boolean; errorMessage?: string }> {
+    try {
+      // 使用无头浏览器后台运行
+      await this.initBrowser({ headless: true });
+      await this.setCookies(cookies);
+      
+      if (!this.page) throw new Error('Page not initialized');
+      
+      logger.info(`[Douyin Delete] Starting delete for video: ${videoId}`);
+      
+      // 访问内容管理页面
+      await this.page.goto('https://creator.douyin.com/creator-micro/content/manage', {
+        waitUntil: 'networkidle',
+        timeout: 60000,
+      });
+      
+      await this.page.waitForTimeout(3000);
+      
+      // 找到对应视频的操作按钮
+      // 视频列表通常有 data-aweme-id 属性或者可以通过视频 ID 定位
+      const videoCard = this.page.locator(`[data-aweme-id="${videoId}"], [data-video-id="${videoId}"]`).first();
+      
+      // 如果没找到,尝试通过其他方式定位
+      let found = await videoCard.count() > 0;
+      
+      if (!found) {
+        // 尝试遍历视频列表找到对应的
+        const videoCards = this.page.locator('[class*="video-card"], [class*="content-item"], [class*="aweme-item"]');
+        const count = await videoCards.count();
+        
+        for (let i = 0; i < count; i++) {
+          const card = videoCards.nth(i);
+          const html = await card.innerHTML().catch(() => '');
+          if (html.includes(videoId)) {
+            // 找到对应的视频卡片,点击更多操作
+            const moreBtn = card.locator('[class*="more"], [class*="action"]').first();
+            if (await moreBtn.count() > 0) {
+              await moreBtn.click();
+              found = true;
+              break;
+            }
+          }
+        }
+      }
+      
+      if (!found) {
+        // 直接访问视频详情页尝试删除
+        await this.page.goto(`https://creator.douyin.com/creator-micro/content/manage?aweme_id=${videoId}`, {
+          waitUntil: 'networkidle',
+        });
+        await this.page.waitForTimeout(2000);
+      }
+      
+      // 查找并点击"更多"按钮或"..."
+      const moreSelectors = [
+        'button:has-text("更多")',
+        '[class*="more-action"]',
+        '[class*="dropdown-trigger"]',
+        'button[class*="more"]',
+        '.semi-dropdown-trigger',
+      ];
+      
+      for (const selector of moreSelectors) {
+        const moreBtn = this.page.locator(selector).first();
+        if (await moreBtn.count() > 0) {
+          await moreBtn.click();
+          await this.page.waitForTimeout(500);
+          break;
+        }
+      }
+      
+      // 查找并点击"删除"选项
+      const deleteSelectors = [
+        'div:has-text("删除"):not(:has(*))',
+        '[class*="dropdown-item"]:has-text("删除")',
+        'li:has-text("删除")',
+        'span:has-text("删除")',
+      ];
+      
+      for (const selector of deleteSelectors) {
+        const deleteBtn = this.page.locator(selector).first();
+        if (await deleteBtn.count() > 0) {
+          await deleteBtn.click();
+          logger.info('[Douyin Delete] Delete button clicked');
+          break;
+        }
+      }
+      
+      await this.page.waitForTimeout(1000);
+      
+      // 检查是否需要验证码
+      const captchaVisible = await this.page.locator('[class*="captcha"], [class*="verify"]').count() > 0;
+      
+      if (captchaVisible && onCaptchaRequired) {
+        logger.info('[Douyin Delete] Captcha required');
+        
+        // 点击发送验证码
+        const sendCodeBtn = this.page.locator('button:has-text("发送验证码"), button:has-text("获取验证码")').first();
+        if (await sendCodeBtn.count() > 0) {
+          await sendCodeBtn.click();
+          logger.info('[Douyin Delete] Verification code sent');
+        }
+        
+        // 通过回调获取验证码
+        const taskId = `delete_${videoId}_${Date.now()}`;
+        const code = await onCaptchaRequired({ taskId });
+        
+        if (code) {
+          // 输入验证码
+          const codeInput = this.page.locator('input[placeholder*="验证码"], input[type="text"]').first();
+          if (await codeInput.count() > 0) {
+            await codeInput.fill(code);
+            logger.info('[Douyin Delete] Verification code entered');
+          }
+          
+          // 点击确认按钮
+          const confirmBtn = this.page.locator('button:has-text("确定"), button:has-text("确认")').first();
+          if (await confirmBtn.count() > 0) {
+            await confirmBtn.click();
+            await this.page.waitForTimeout(2000);
+          }
+        }
+      }
+      
+      // 确认删除(可能有二次确认弹窗)
+      const confirmDeleteSelectors = [
+        'button:has-text("确认删除")',
+        'button:has-text("确定")',
+        '.semi-modal-footer button:has-text("确定")',
+      ];
+      
+      for (const selector of confirmDeleteSelectors) {
+        const confirmBtn = this.page.locator(selector).first();
+        if (await confirmBtn.count() > 0) {
+          await confirmBtn.click();
+          await this.page.waitForTimeout(1000);
+        }
+      }
+      
+      logger.info('[Douyin Delete] Delete completed');
+      await this.closeBrowser();
+      
+      return { success: true };
+      
+    } catch (error) {
+      logger.error('[Douyin Delete] Error:', error);
+      await this.closeBrowser();
+      return {
+        success: false,
+        errorMessage: error instanceof Error ? error.message : '删除失败',
+      };
+    }
+  }
+  
+  /**
    * 解析数量字符串
    */
   private parseCount(text: string): number {

+ 10 - 0
server/src/models/entities/Comment.ts

@@ -5,12 +5,15 @@ import {
   CreateDateColumn,
   ManyToOne,
   JoinColumn,
+  Index,
 } from 'typeorm';
 import type { PlatformType } from '@media-manager/shared';
 import { User } from './User.js';
 import { PlatformAccount } from './PlatformAccount.js';
+import { Work } from './Work.js';
 
 @Entity('comments')
+@Index(['userId', 'workId'])
 export class Comment {
   @PrimaryGeneratedColumn()
   id!: number;
@@ -21,6 +24,9 @@ export class Comment {
   @Column({ type: 'int', name: 'account_id' })
   accountId!: number;
 
+  @Column({ type: 'int', name: 'work_id', nullable: true })
+  workId!: number | null;
+
   @Column({ type: 'varchar', length: 50, nullable: true })
   platform!: PlatformType | null;
 
@@ -77,4 +83,8 @@ export class Comment {
   @ManyToOne(() => PlatformAccount, (account) => account.comments, { onDelete: 'CASCADE' })
   @JoinColumn({ name: 'account_id' })
   account!: PlatformAccount;
+
+  @ManyToOne(() => Work, { onDelete: 'SET NULL', nullable: true })
+  @JoinColumn({ name: 'work_id' })
+  work?: Work;
 }

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

@@ -186,6 +186,18 @@ router.get(
   })
 );
 
+// 批量刷新所有账号状态
+router.post(
+  '/refresh-all',
+  asyncHandler(async (req, res) => {
+    const result = await accountService.refreshAllAccounts(req.user!.userId);
+    res.json({ 
+      success: true, 
+      data: result,
+    });
+  })
+);
+
 // 获取扫码登录二维码
 router.post(
   '/qrcode',

+ 25 - 1
server/src/routes/comments.ts

@@ -4,6 +4,7 @@ import { CommentService } from '../services/CommentService.js';
 import { authenticate } from '../middleware/auth.js';
 import { asyncHandler } from '../middleware/error.js';
 import { validateRequest } from '../middleware/validate.js';
+import { taskQueueService } from '../services/TaskQueueService.js';
 
 const router = Router();
 const commentService = new CommentService();
@@ -14,11 +15,12 @@ router.use(authenticate);
 router.get(
   '/',
   asyncHandler(async (req, res) => {
-    const { page = 1, pageSize = 20, accountId, platform, isRead, keyword } = req.query;
+    const { page = 1, pageSize = 20, accountId, workId, platform, isRead, keyword } = req.query;
     const result = await commentService.getComments(req.user!.userId, {
       page: Number(page),
       pageSize: Number(pageSize),
       accountId: accountId ? Number(accountId) : undefined,
+      workId: workId ? Number(workId) : undefined,
       platform: platform as string,
       isRead: isRead !== undefined ? isRead === 'true' : undefined,
       keyword: keyword as string,
@@ -85,4 +87,26 @@ router.post(
   })
 );
 
+// 同步评论(从平台获取最新评论)- 使用任务队列
+router.post(
+  '/sync',
+  asyncHandler(async (req, res) => {
+    const { accountId, accountName } = req.body;
+    const userId = req.user!.userId;
+    
+    // 创建任务加入队列
+    const task = taskQueueService.createTask(userId, {
+      type: 'sync_comments',
+      title: accountName ? `同步评论 - ${accountName}` : '同步所有评论',
+      accountId: accountId ? Number(accountId) : undefined,
+    });
+    
+    res.json({ 
+      success: true, 
+      message: '评论同步任务已创建',
+      data: { taskId: task.id, status: 'pending' },
+    });
+  })
+);
+
 export default router;

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

@@ -9,6 +9,8 @@ import uploadRoutes from './upload.js';
 import systemRoutes from './system.js';
 import aiRoutes from './ai.js';
 import worksRoutes from './works.js';
+import tasksRoutes from './tasks.js';
+import { authenticate } from '../middleware/auth.js';
 
 export function setupRoutes(app: Express): void {
   app.use('/api/auth', authRoutes);
@@ -22,4 +24,5 @@ export function setupRoutes(app: Express): void {
   app.use('/api/upload', uploadRoutes);
   app.use('/api/system', systemRoutes);
   app.use('/api/ai', aiRoutes);
+  app.use('/api/tasks', authenticate, tasksRoutes);
 }

+ 47 - 2
server/src/routes/publish.ts

@@ -1,6 +1,7 @@
 import { Router } from 'express';
 import { body, param } from 'express-validator';
 import { PublishService } from '../services/PublishService.js';
+import { taskQueueService } from '../services/TaskQueueService.js';
 import { authenticate } from '../middleware/auth.js';
 import { asyncHandler } from '../middleware/error.js';
 import { validateRequest } from '../middleware/validate.js';
@@ -50,7 +51,23 @@ router.post(
     validateRequest,
   ],
   asyncHandler(async (req, res) => {
-    const task = await publishService.createTask(req.user!.userId, req.body);
+    const userId = req.user!.userId;
+    
+    // 1. 创建数据库记录
+    const task = await publishService.createTask(userId, req.body);
+    
+    // 2. 如果不是定时任务,加入任务队列
+    if (!req.body.scheduledAt) {
+      taskQueueService.createTask(userId, {
+        type: 'publish_video',
+        title: `发布视频: ${req.body.title}`,
+        description: `发布到 ${req.body.targetAccounts.length} 个账号`,
+        data: {
+          publishTaskId: task.id,
+        },
+      });
+    }
+    
     res.status(201).json({ success: true, data: task });
   })
 );
@@ -76,9 +93,37 @@ router.post(
     validateRequest,
   ],
   asyncHandler(async (req, res) => {
-    const task = await publishService.retryTask(req.user!.userId, Number(req.params.id));
+    const userId = req.user!.userId;
+    const taskId = Number(req.params.id);
+    
+    // 1. 更新数据库状态
+    const task = await publishService.retryTask(userId, taskId);
+    
+    // 2. 加入任务队列重新执行
+    taskQueueService.createTask(userId, {
+      type: 'publish_video',
+      title: `重试发布: ${task.title}`,
+      description: `重新发布到 ${task.targetAccounts.length} 个账号`,
+      data: {
+        publishTaskId: task.id,
+      },
+    });
+    
     res.json({ success: true, data: task });
   })
 );
 
+// 删除任务
+router.delete(
+  '/:id',
+  [
+    param('id').isInt().withMessage('任务ID无效'),
+    validateRequest,
+  ],
+  asyncHandler(async (req, res) => {
+    await publishService.deleteTask(req.user!.userId, Number(req.params.id));
+    res.json({ success: true, message: '任务已删除' });
+  })
+);
+
 export default router;

+ 82 - 0
server/src/routes/tasks.ts

@@ -0,0 +1,82 @@
+import { Router } from 'express';
+import { asyncHandler } from '../middleware/error.js';
+import { taskQueueService } from '../services/TaskQueueService.js';
+import type { CreateTaskRequest } from '@media-manager/shared';
+
+const router = Router();
+
+/**
+ * 获取当前用户的任务列表
+ */
+router.get(
+  '/',
+  asyncHandler(async (req, res) => {
+    const userId = req.user!.userId;
+    const tasks = taskQueueService.getUserTasks(userId);
+    res.json({
+      success: true,
+      data: { tasks },
+    });
+  })
+);
+
+/**
+ * 获取活跃任务
+ */
+router.get(
+  '/active',
+  asyncHandler(async (req, res) => {
+    const userId = req.user!.userId;
+    const tasks = taskQueueService.getActiveTasks(userId);
+    res.json({
+      success: true,
+      data: { tasks },
+    });
+  })
+);
+
+/**
+ * 创建新任务
+ */
+router.post(
+  '/',
+  asyncHandler(async (req, res) => {
+    const userId = req.user!.userId;
+    const request = req.body as CreateTaskRequest;
+    
+    const task = taskQueueService.createTask(userId, request);
+    
+    res.json({
+      success: true,
+      message: '任务已创建',
+      data: { task },
+    });
+  })
+);
+
+/**
+ * 取消任务
+ */
+router.post(
+  '/:taskId/cancel',
+  asyncHandler(async (req, res) => {
+    const userId = req.user!.userId;
+    const { taskId } = req.params;
+    
+    const success = taskQueueService.cancelTask(userId, taskId);
+    
+    if (success) {
+      res.json({
+        success: true,
+        message: '任务已取消',
+      });
+    } else {
+      res.status(400).json({
+        success: false,
+        message: '无法取消任务',
+      });
+    }
+  })
+);
+
+export default router;

+ 53 - 6
server/src/routes/works.ts

@@ -2,6 +2,7 @@ import { Router } from 'express';
 import { workService } from '../services/WorkService.js';
 import { asyncHandler } from '../middleware/error.js';
 import { authenticate } from '../middleware/auth.js';
+import { taskQueueService } from '../services/TaskQueueService.js';
 
 const router = Router();
 
@@ -43,20 +44,29 @@ router.get(
 );
 
 /**
- * 同步作品
+ * 同步作品 - 使用任务队列
  */
 router.post(
   '/sync',
   asyncHandler(async (req, res) => {
     const userId = req.user!.userId;
-    const { accountId } = req.body;
+    const { accountId, accountName } = req.body;
 
-    // 异步执行同步,立即返回
-    workService.syncWorks(userId, accountId).catch(err => {
-      console.error('Sync works error:', err);
+    // 创建同步作品任务
+    const task = taskQueueService.createTask(userId, {
+      type: 'sync_works',
+      title: accountName ? `同步作品 - ${accountName}` : '同步所有作品',
+      accountId: accountId ? Number(accountId) : undefined,
     });
 
-    res.json({ success: true, data: null, message: '同步任务已启动' });
+    res.json({ 
+      success: true, 
+      message: '作品同步任务已创建', 
+      data: { 
+        taskId: task.id, 
+        status: 'pending' 
+      } 
+    });
   })
 );
 
@@ -73,4 +83,41 @@ router.get(
   })
 );
 
+/**
+ * 删除平台上的作品
+ */
+router.post(
+  '/:id/delete-platform',
+  asyncHandler(async (req, res) => {
+    const userId = req.user!.userId;
+    const workId = parseInt(req.params.id);
+    
+    // 创建删除任务
+    const task = taskQueueService.createTask(userId, {
+      type: 'delete_work',
+      title: '删除平台作品',
+      data: { workId },
+    });
+    
+    res.json({ 
+      success: true, 
+      message: '删除任务已创建',
+      data: { taskId: task.id }
+    });
+  })
+);
+
+/**
+ * 删除本地作品记录
+ */
+router.delete(
+  '/:id',
+  asyncHandler(async (req, res) => {
+    const userId = req.user!.userId;
+    const workId = parseInt(req.params.id);
+    await workService.deleteWork(userId, workId);
+    res.json({ success: true, message: '作品已删除' });
+  })
+);
+
 export default router;

+ 8 - 2
server/src/scheduler/index.ts

@@ -21,12 +21,18 @@ export class TaskScheduler {
     // 每分钟检查定时发布任务
     this.scheduleJob('check-publish-tasks', '* * * * *', this.checkPublishTasks.bind(this));
     
-    // 每小时刷新账号状态
-    this.scheduleJob('refresh-accounts', '0 * * * *', this.refreshAccounts.bind(this));
+    // 每10分钟刷新账号状态
+    this.scheduleJob('refresh-accounts', '*/10 * * * *', this.refreshAccounts.bind(this));
     
     // 每天凌晨2点采集数据
     this.scheduleJob('collect-analytics', '0 2 * * *', this.collectAnalytics.bind(this));
     
+    // 服务器启动时立即刷新一次账号状态
+    setTimeout(() => {
+      logger.info('Initial account refresh on startup...');
+      this.refreshAccounts().catch(err => logger.error('Initial refresh failed:', err));
+    }, 5000); // 延迟5秒,等待其他服务初始化完成
+    
     logger.info('Task scheduler started');
   }
   

+ 25 - 0
server/src/services/AccountService.ts

@@ -395,6 +395,31 @@ export class AccountService {
     }
   }
 
+  /**
+   * 批量刷新所有账号状态
+   */
+  async refreshAllAccounts(userId: number): Promise<{ refreshed: number; failed: number }> {
+    const accounts = await this.accountRepository.find({
+      where: { userId },
+    });
+
+    let refreshed = 0;
+    let failed = 0;
+
+    for (const account of accounts) {
+      try {
+        await this.refreshAccount(userId, account.id);
+        refreshed++;
+      } catch (error) {
+        logger.error(`Failed to refresh account ${account.id}:`, error);
+        failed++;
+      }
+    }
+
+    logger.info(`Refreshed ${refreshed} accounts for user ${userId}, ${failed} failed`);
+    return { refreshed, failed };
+  }
+
   async getQRCode(platform: string): Promise<QRCodeInfo> {
     // TODO: 调用对应平台适配器获取二维码
     // 这里返回模拟数据

+ 429 - 2
server/src/services/CommentService.ts

@@ -1,17 +1,22 @@
-import { AppDataSource, Comment } from '../models/index.js';
+import { AppDataSource, Comment, PlatformAccount, Work } from '../models/index.js';
 import { AppError } from '../middleware/error.js';
 import { ERROR_CODES, HTTP_STATUS, WS_EVENTS } from '@media-manager/shared';
 import type {
   Comment as CommentType,
   CommentStats,
   PaginatedData,
+  PlatformType,
 } from '@media-manager/shared';
 import { wsManager } from '../websocket/index.js';
+import { headlessBrowserService, type WorkComments, type CookieData } from './HeadlessBrowserService.js';
+import { CookieManager } from '../automation/cookie.js';
+import { logger } from '../utils/logger.js';
 
 interface GetCommentsParams {
   page: number;
   pageSize: number;
   accountId?: number;
+  workId?: number;
   platform?: string;
   isRead?: boolean;
   keyword?: string;
@@ -21,7 +26,7 @@ export class CommentService {
   private commentRepository = AppDataSource.getRepository(Comment);
 
   async getComments(userId: number, params: GetCommentsParams): Promise<PaginatedData<CommentType>> {
-    const { page, pageSize, accountId, platform, isRead, keyword } = params;
+    const { page, pageSize, accountId, workId, platform, isRead, keyword } = params;
     const skip = (page - 1) * pageSize;
 
     const queryBuilder = this.commentRepository
@@ -31,6 +36,11 @@ export class CommentService {
     if (accountId) {
       queryBuilder.andWhere('comment.accountId = :accountId', { accountId });
     }
+    if (workId) {
+      // 直接使用 workId 查询
+      queryBuilder.andWhere('comment.workId = :workId', { workId });
+      logger.info(`Querying comments for workId: ${workId}`);
+    }
     if (platform) {
       queryBuilder.andWhere('comment.platform = :platform', { platform });
     }
@@ -44,11 +54,16 @@ export class CommentService {
       );
     }
 
+    // 打印查询 SQL 用于调试
+    logger.info(`Comment query: userId=${userId}, accountId=${accountId}, workId=${workId}, platform=${platform}`);
+    
     const [comments, total] = await queryBuilder
       .orderBy('comment.commentTime', 'DESC')
       .skip(skip)
       .take(pageSize)
       .getManyAndCount();
+    
+    logger.info(`Found ${total} comments`);
 
     return {
       items: comments.map(this.formatComment),
@@ -134,11 +149,423 @@ export class CommentService {
     return { success, failed };
   }
 
+  /**
+   * 异步同步评论(后台执行,通过 WebSocket 通知结果)
+   */
+  syncCommentsAsync(userId: number, accountId?: number): void {
+    // 通知用户同步已开始 - 直接使用字符串,并在 payload 中加入 event 字段
+    wsManager.sendToUser(userId, 'comment:sync_started', {
+      event: 'sync_started',
+      accountId,
+      message: '正在同步评论...',
+    });
+
+    // 进度回调:在处理每个作品时发送进度更新
+    const onProgress = (current: number, total: number, workTitle: string) => {
+      const progress = total > 0 ? Math.round((current / total) * 100) : 0;
+      wsManager.sendToUser(userId, 'comment:sync_progress', {
+        event: 'sync_progress',
+        accountId,
+        current,
+        total,
+        progress,
+        workTitle,
+        message: `正在同步: ${workTitle || `作品 ${current}/${total}`}`,
+      });
+    };
+
+    // 后台执行同步任务
+    this.syncComments(userId, accountId, onProgress)
+      .then((result) => {
+        logger.info(`Comment sync completed: synced ${result.synced} comments from ${result.accounts} accounts`);
+        // 同步完成,通知用户
+        wsManager.sendToUser(userId, 'comment:synced', {
+          event: 'synced',
+          accountId,
+          syncedCount: result.synced,
+          accountCount: result.accounts,
+          message: `同步完成,共同步 ${result.synced} 条评论`,
+        });
+      })
+      .catch((error) => {
+        logger.error('Comment sync failed:', error);
+        // 同步失败,通知用户
+        wsManager.sendToUser(userId, 'comment:sync_failed', {
+          event: 'sync_failed',
+          accountId,
+          message: error instanceof Error ? error.message : '同步失败,请稍后重试',
+        });
+      });
+  }
+
+  /**
+   * 同步指定账号的评论
+   * @param onProgress 进度回调 (current, total, workTitle)
+   */
+  async syncComments(
+    userId: number, 
+    accountId?: number,
+    onProgress?: (current: number, total: number, workTitle: string) => void
+  ): Promise<{ synced: number; accounts: number }> {
+    const accountRepository = AppDataSource.getRepository(PlatformAccount);
+    
+    // 获取需要同步的账号列表
+    const whereCondition: { userId: number; id?: number; platform?: string } = { userId };
+    if (accountId) {
+      whereCondition.id = accountId;
+    }
+    
+    const accounts = await accountRepository.find({ where: whereCondition });
+    
+    if (accounts.length === 0) {
+      throw new AppError('没有找到可同步的账号', HTTP_STATUS.NOT_FOUND, ERROR_CODES.ACCOUNT_NOT_FOUND);
+    }
+
+    let totalSynced = 0;
+    let syncedAccounts = 0;
+
+    for (const account of accounts) {
+      try {
+        // 只处理支持的平台
+        if (account.platform !== 'douyin') {
+          logger.info(`Skipping unsupported platform: ${account.platform}`);
+          continue;
+        }
+
+        // 解密 Cookie
+        if (!account.cookieData) {
+          logger.warn(`Account ${account.id} has no cookies`);
+          continue;
+        }
+
+        let decryptedCookies: string;
+        try {
+          decryptedCookies = CookieManager.decrypt(account.cookieData);
+        } catch {
+          decryptedCookies = account.cookieData;
+        }
+
+        // 解析 Cookie
+        let cookies: CookieData[];
+        try {
+          cookies = JSON.parse(decryptedCookies);
+        } catch {
+          logger.error(`Invalid cookie format for account ${account.id}`);
+          continue;
+        }
+        
+        // 获取评论数据
+        logger.info(`Syncing comments for account ${account.id} (${account.platform})...`);
+        const workComments = await headlessBrowserService.fetchDouyinCommentsViaApi(cookies);
+        
+        // 获取该账号的所有作品,用于关联
+        const workRepository = AppDataSource.getRepository(Work);
+        const accountWorks = await workRepository.find({
+          where: { userId, accountId: account.id },
+        });
+        
+        logger.info(`Found ${accountWorks.length} works for account ${account.id}`);
+        
+        // 保存评论到数据库
+        let accountSynced = 0;
+        const totalWorks = workComments.length;
+        
+        // 创建 platformVideoId -> workId 的快速映射
+        const videoIdToWorkMap = new Map<string, { id: number; title: string }>();
+        for (const work of accountWorks) {
+          if (work.platformVideoId) {
+            videoIdToWorkMap.set(work.platformVideoId, { id: work.id, title: work.title });
+            // 同时存储不带前缀的版本(如果 platformVideoId 是 "douyin_xxx" 格式)
+            if (work.platformVideoId.includes('_')) {
+              const parts = work.platformVideoId.split('_');
+              if (parts.length >= 2) {
+                videoIdToWorkMap.set(parts.slice(1).join('_'), { id: work.id, title: work.title });
+              }
+            }
+          }
+        }
+        
+        logger.info(`Created videoId mapping with ${videoIdToWorkMap.size} entries`);
+
+        for (let workIndex = 0; workIndex < workComments.length; workIndex++) {
+          const workComment = workComments[workIndex];
+          
+          // 发送进度更新
+          if (onProgress) {
+            onProgress(workIndex + 1, totalWorks, workComment.videoTitle || `作品 ${workIndex + 1}`);
+          }
+          
+          let workId: number | null = null;
+          const commentVideoId = workComment.videoId?.toString() || '';
+          const commentVideoTitle = workComment.videoTitle?.trim() || '';
+          
+          logger.info(`Trying to match work for videoId: "${commentVideoId}", title: "${commentVideoTitle}"`);
+          
+          // 1. 【首选】通过 platformVideoId (aweme_id) 匹配 - 最可靠的方式
+          if (commentVideoId) {
+            // 直接匹配
+            if (videoIdToWorkMap.has(commentVideoId)) {
+              const matched = videoIdToWorkMap.get(commentVideoId)!;
+              workId = matched.id;
+              logger.info(`Matched work by videoId: ${commentVideoId} -> workId: ${workId}, title: "${matched.title}"`);
+            }
+            
+            // 尝试带平台前缀匹配
+            if (!workId) {
+              const prefixedId = `douyin_${commentVideoId}`;
+              if (videoIdToWorkMap.has(prefixedId)) {
+                const matched = videoIdToWorkMap.get(prefixedId)!;
+                workId = matched.id;
+                logger.info(`Matched work by prefixed videoId: ${prefixedId} -> workId: ${workId}`);
+              }
+            }
+            
+            // 遍历匹配(处理各种格式)
+            if (!workId) {
+              const matchedWork = accountWorks.find(w => {
+                if (!w.platformVideoId) return false;
+                // 尝试各种匹配方式
+                return w.platformVideoId === commentVideoId ||
+                       w.platformVideoId === `douyin_${commentVideoId}` ||
+                       w.platformVideoId.endsWith(`_${commentVideoId}`) ||
+                       w.platformVideoId.includes(commentVideoId);
+              });
+              if (matchedWork) {
+                workId = matchedWork.id;
+                logger.info(`Matched work by videoId iteration: ${commentVideoId} -> workId: ${workId}, platformVideoId: ${matchedWork.platformVideoId}`);
+              }
+            }
+          }
+          
+          // 2. 通过标题精确匹配
+          if (!workId && commentVideoTitle) {
+            let matchedWork = accountWorks.find(w => {
+              if (!w.title) return false;
+              return w.title.trim() === commentVideoTitle;
+            });
+            
+            // 去除空白字符后匹配
+            if (!matchedWork) {
+              const normalizedCommentTitle = commentVideoTitle.replace(/\s+/g, '');
+              matchedWork = accountWorks.find(w => {
+                if (!w.title) return false;
+                return w.title.trim().replace(/\s+/g, '') === normalizedCommentTitle;
+              });
+            }
+            
+            // 包含匹配
+            if (!matchedWork) {
+              matchedWork = accountWorks.find(w => {
+                if (!w.title) return false;
+                const workTitle = w.title.trim();
+                const shortCommentTitle = commentVideoTitle.slice(0, 50);
+                const shortWorkTitle = workTitle.slice(0, 50);
+                return workTitle.includes(shortCommentTitle) || 
+                       commentVideoTitle.includes(shortWorkTitle) ||
+                       shortWorkTitle.includes(shortCommentTitle) ||
+                       shortCommentTitle.includes(shortWorkTitle);
+              });
+            }
+            
+            // 模糊匹配
+            if (!matchedWork) {
+              const normalizeTitle = (title: string) => {
+                return title.replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, '').toLowerCase();
+              };
+              const normalizedCommentTitle = normalizeTitle(commentVideoTitle);
+              matchedWork = accountWorks.find(w => {
+                if (!w.title) return false;
+                const normalizedWorkTitle = normalizeTitle(w.title);
+                return normalizedWorkTitle.slice(0, 40) === normalizedCommentTitle.slice(0, 40) ||
+                       normalizedWorkTitle.includes(normalizedCommentTitle.slice(0, 30)) ||
+                       normalizedCommentTitle.includes(normalizedWorkTitle.slice(0, 30));
+              });
+            }
+            
+            if (matchedWork) {
+              workId = matchedWork.id;
+              logger.info(`Matched work by title: "${matchedWork.title}" -> workId: ${workId}`);
+            }
+          }
+          
+          // 3. 如果只有一个作品,直接关联
+          if (!workId && accountWorks.length === 1) {
+            workId = accountWorks[0].id;
+            logger.info(`Only one work, using default: workId: ${workId}`);
+          }
+          
+          logger.info(`Final work mapping: videoId="${commentVideoId}", title="${commentVideoTitle}", workId=${workId}`);
+          
+          for (const comment of workComment.comments) {
+            try {
+              // 过滤无效评论内容 - 放宽限制,只过滤纯操作按钮文本
+              if (!comment.content || 
+                  /^(回复|删除|举报|点赞|分享|收藏)$/.test(comment.content.trim())) {
+                logger.debug(`Skipping invalid comment content: ${comment.content}`);
+                continue;
+              }
+              
+              // 检查评论是否已存在(基于内容+作者+账号的去重)
+              const existing = await this.commentRepository
+                .createQueryBuilder('comment')
+                .where('comment.accountId = :accountId', { accountId: account.id })
+                .andWhere('comment.authorName = :authorName', { authorName: comment.authorName })
+                .andWhere('comment.content = :content', { content: comment.content })
+                .getOne();
+
+              if (!existing) {
+                const newComment = this.commentRepository.create({
+                  userId,
+                  accountId: account.id,
+                  workId, // 关联作品 ID
+                  platform: account.platform as PlatformType,
+                  videoId: workComment.videoId,
+                  commentId: comment.commentId,
+                  authorId: comment.authorId,
+                  authorName: comment.authorName,
+                  authorAvatar: comment.authorAvatar,
+                  content: comment.content,
+                  likeCount: comment.likeCount,
+                  commentTime: comment.commentTime ? new Date(comment.commentTime) : new Date(),
+                  isRead: false,
+                  isTop: false,
+                });
+
+                await this.commentRepository.save(newComment);
+                accountSynced++;
+                logger.info(`Saved comment: "${comment.content.slice(0, 30)}..." -> workId: ${workId}`);
+              } else {
+                // 如果评论已存在但没有 workId,更新它
+                if (!existing.workId && workId) {
+                  await this.commentRepository.update(existing.id, { workId });
+                  logger.info(`Updated existing comment workId: ${existing.id} -> ${workId}`);
+                }
+              }
+            } catch (saveError) {
+              logger.warn(`Failed to save comment ${comment.commentId}:`, saveError);
+            }
+          }
+        }
+
+        if (accountSynced > 0) {
+          totalSynced += accountSynced;
+          syncedAccounts++;
+          logger.info(`Synced ${accountSynced} comments for account ${account.id}`);
+          // 注意:不在这里发送 COMMENT_SYNCED,而是由 syncCommentsAsync 统一发送
+        }
+      } catch (accountError) {
+        logger.error(`Failed to sync comments for account ${account.id}:`, accountError);
+      }
+    }
+
+    // 尝试修复没有 workId 的现有评论
+    await this.fixOrphanedComments(userId);
+
+    return { synced: totalSynced, accounts: syncedAccounts };
+  }
+
+  /**
+   * 修复没有 workId 的评论
+   */
+  private async fixOrphanedComments(userId: number): Promise<void> {
+    try {
+      const workRepository = AppDataSource.getRepository(Work);
+      
+      // 获取所有没有 workId 的评论
+      const orphanedComments = await this.commentRepository.find({
+        where: { userId, workId: undefined as unknown as number },
+      });
+      
+      if (orphanedComments.length === 0) return;
+      
+      logger.info(`Found ${orphanedComments.length} comments without workId, trying to fix...`);
+      
+      // 获取用户的所有作品
+      const works = await workRepository.find({ where: { userId } });
+      
+      // 创建多种格式的 videoId -> workId 映射
+      const videoIdToWork = new Map<string, { id: number; title: string }>();
+      for (const work of works) {
+        if (work.platformVideoId) {
+          // 存储原始 platformVideoId
+          videoIdToWork.set(work.platformVideoId, { id: work.id, title: work.title });
+          
+          // 如果是 "douyin_xxx" 格式,也存储纯 ID
+          if (work.platformVideoId.startsWith('douyin_')) {
+            const pureId = work.platformVideoId.replace('douyin_', '');
+            videoIdToWork.set(pureId, { id: work.id, title: work.title });
+          }
+          
+          // 如果是纯数字 ID,也存储带前缀的版本
+          if (/^\d+$/.test(work.platformVideoId)) {
+            videoIdToWork.set(`douyin_${work.platformVideoId}`, { id: work.id, title: work.title });
+          }
+        }
+      }
+      
+      let fixedCount = 0;
+      
+      for (const comment of orphanedComments) {
+        let matchedWorkId: number | null = null;
+        
+        // 1. 尝试通过 videoId 精确匹配
+        if (comment.videoId) {
+          if (videoIdToWork.has(comment.videoId)) {
+            matchedWorkId = videoIdToWork.get(comment.videoId)!.id;
+          }
+          // 尝试带前缀匹配
+          if (!matchedWorkId) {
+            const prefixedId = `douyin_${comment.videoId}`;
+            if (videoIdToWork.has(prefixedId)) {
+              matchedWorkId = videoIdToWork.get(prefixedId)!.id;
+            }
+          }
+          // 尝试去掉前缀匹配
+          if (!matchedWorkId && comment.videoId.includes('_')) {
+            const pureId = comment.videoId.split('_').pop()!;
+            if (videoIdToWork.has(pureId)) {
+              matchedWorkId = videoIdToWork.get(pureId)!.id;
+            }
+          }
+          // 遍历查找包含关系
+          if (!matchedWorkId) {
+            const matchedWork = works.find(w => 
+              w.platformVideoId?.includes(comment.videoId!) || 
+              comment.videoId!.includes(w.platformVideoId || '')
+            );
+            if (matchedWork) {
+              matchedWorkId = matchedWork.id;
+            }
+          }
+        }
+        
+        // 2. 尝试通过账号匹配(如果该账号只有一个作品)
+        if (!matchedWorkId) {
+          const accountWorks = works.filter(w => w.accountId === comment.accountId);
+          if (accountWorks.length === 1) {
+            matchedWorkId = accountWorks[0].id;
+          }
+        }
+        
+        if (matchedWorkId) {
+          await this.commentRepository.update(comment.id, { workId: matchedWorkId });
+          fixedCount++;
+          logger.info(`Fixed comment ${comment.id} (videoId: ${comment.videoId}) -> workId: ${matchedWorkId}`);
+        }
+      }
+      
+      logger.info(`Fixed ${fixedCount}/${orphanedComments.length} orphaned comments`);
+    } catch (error) {
+      logger.warn('Failed to fix orphaned comments:', error);
+    }
+  }
+
   private formatComment(comment: Comment): CommentType {
     return {
       id: comment.id,
       userId: comment.userId,
       accountId: comment.accountId,
+      workId: comment.workId || undefined,
       platform: comment.platform!,
       videoId: comment.videoId || '',
       platformVideoUrl: comment.platformVideoUrl,

+ 1720 - 114
server/src/services/HeadlessBrowserService.ts

@@ -3,13 +3,25 @@ import { chromium, type BrowserContext, type Page } from 'playwright';
 import { logger } from '../utils/logger.js';
 import type { PlatformType } from '@media-manager/shared';
 
-// 平台 API 配置
+// 抖音 API 接口配置
+const DOUYIN_API = {
+  // 检查用户登录状态 - 返回 result: true 表示已登录(需要在浏览器上下文中调用)
+  CHECK_USER: '/aweme/v1/creator/check/user/',
+  // 获取作品列表(新接口,支持分页)
+  WORK_LIST: 'https://creator.douyin.com/janus/douyin/creator/pc/work_list',
+  // 获取评论列表
+  COMMENT_LIST: 'https://creator.douyin.com/web/api/third_party/aweme/api/comment/read/aweme/v1/web/comment/list/select/',
+  // 创作者首页(用于触发登录检查)
+  CREATOR_HOME: 'https://creator.douyin.com/creator-micro/home',
+};
+
+// 平台 API 配置(用于直接 HTTP 请求检查)
 const PLATFORM_API_CONFIG: Record<string, {
   checkUrl: string;
   isValidResponse: (data: unknown) => boolean;
 }> = {
   douyin: {
-    // 抖音检查 Cookie 有效性的 API
+    // 使用账号基础信息接口检查 Cookie 有效性
     checkUrl: 'https://creator.douyin.com/web/api/creator/mcn/account_base_info?show_mcn_status=1',
     isValidResponse: (data: unknown) => {
       const resp = data as { status_code?: number; BaseResp?: { StatusCode?: number } };
@@ -41,6 +53,27 @@ export interface WorkItem {
   shareCount: number;
 }
 
+export interface CommentItem {
+  commentId: string;
+  authorId: string;
+  authorName: string;
+  authorAvatar: string;
+  content: string;
+  likeCount: number;
+  commentTime: string;
+  parentCommentId?: string;
+  videoId?: string;
+  videoTitle?: string;
+  videoCoverUrl?: string;
+}
+
+export interface WorkComments {
+  videoId: string;
+  videoTitle: string;
+  videoCoverUrl: string;
+  comments: CommentItem[];
+}
+
 export interface CookieData {
   name: string;
   value: string;
@@ -132,6 +165,11 @@ class HeadlessBrowserService {
    * 通过浏览器检查 Cookie 是否有效(检查是否被重定向到登录页)
    */
   private async checkCookieValidByBrowser(platform: PlatformType, cookies: CookieData[]): Promise<boolean> {
+    // 对于抖音平台,使用 check/user 接口检查
+    if (platform === 'douyin') {
+      return this.checkDouyinLoginByApi(cookies);
+    }
+
     const browser = await chromium.launch({ headless: true });
 
     try {
@@ -174,6 +212,75 @@ class HeadlessBrowserService {
   }
 
   /**
+   * 抖音登录状态检查 - 通过监听 check/user 接口
+   * 访问创作者首页,监听 check/user 接口返回的 result 字段判断登录状态
+   */
+  async checkDouyinLoginByApi(cookies: CookieData[]): Promise<boolean> {
+    const browser = await chromium.launch({ headless: true });
+    let isLoggedIn = false;
+    let checkCompleted = false;
+
+    try {
+      const context = await browser.newContext({
+        viewport: { width: 1920, height: 1080 },
+        locale: 'zh-CN',
+        timezoneId: 'Asia/Shanghai',
+        userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
+      });
+
+      await context.addCookies(cookies);
+      const page = await context.newPage();
+
+      // 监听 check/user 接口响应
+      page.on('response', async (response) => {
+        const url = response.url();
+        if (url.includes(DOUYIN_API.CHECK_USER)) {
+          try {
+            const data = await response.json();
+            // result: true 表示已登录
+            isLoggedIn = data?.result === true && data?.status_code === 0;
+            checkCompleted = true;
+            logger.info(`[Douyin] check/user API response: result=${data?.result}, status_code=${data?.status_code}, isLoggedIn=${isLoggedIn}`);
+          } catch {
+            // 忽略解析错误
+          }
+        }
+      });
+
+      // 访问创作者首页,触发 check/user 接口
+      await page.goto(DOUYIN_API.CREATOR_HOME, {
+        waitUntil: 'domcontentloaded',
+        timeout: 30000,
+      });
+
+      // 等待接口响应或超时
+      const startTime = Date.now();
+      while (!checkCompleted && Date.now() - startTime < 10000) {
+        await page.waitForTimeout(500);
+      }
+
+      // 如果没有收到 check/user 响应,检查 URL 是否被重定向到登录页
+      if (!checkCompleted) {
+        const currentUrl = page.url();
+        isLoggedIn = !currentUrl.includes('login') && !currentUrl.includes('passport');
+        logger.info(`[Douyin] No check/user response, fallback to URL check: ${currentUrl}, isLoggedIn=${isLoggedIn}`);
+      }
+
+      await page.close();
+      await context.close();
+      await browser.close();
+
+      return isLoggedIn;
+    } catch (error) {
+      logger.error('[Douyin] checkDouyinLoginByApi error:', error);
+      try {
+        await browser.close();
+      } catch { }
+      return false;
+    }
+  }
+
+  /**
    * 获取账号信息(使用无头浏览器)
    */
   async fetchAccountInfo(platform: PlatformType, cookies: CookieData[]): Promise<AccountInfo> {
@@ -219,7 +326,9 @@ class HeadlessBrowserService {
   }
 
   /**
-   * 获取抖音账号信息
+   * 获取抖音账号信息 - 通过 API 方式获取
+   * 1. 监听 check/user 接口验证登录状态
+   * 2. 通过 work_list API 获取作品数和作品列表
    */
   private async fetchDouyinAccountInfo(
     page: Page,
@@ -232,6 +341,21 @@ class HeadlessBrowserService {
     let fansCount = 0;
     let worksCount = 0;
     let worksList: WorkItem[] = [];
+    let isLoggedIn = false;
+
+    // 用于存储从 API 捕获的数据
+    const capturedData: {
+      userInfo?: { nickname?: string; avatar?: string; uid?: string; sec_uid?: string; follower_count?: number };
+      worksList?: Array<{
+        awemeId: string;
+        title: string;
+        coverUrl: string;
+        duration: number;
+        createTime: number;
+        statistics: { play_count: number; digg_count: number; comment_count: number; share_count: number };
+      }>;
+      total?: number;
+    } = {};
 
     try {
       // 从 Cookie 获取用户 ID
@@ -242,15 +366,90 @@ class HeadlessBrowserService {
         accountId = `douyin_${uidCookie.value}`;
       }
 
-      // 访问主页获取基本信息
-      await page.goto('https://creator.douyin.com/creator-micro/home', {
+      // 设置 API 响应监听器
+      page.on('response', async (response) => {
+        const url = response.url();
+        try {
+          // 监听 check/user 接口 - 验证登录状态
+          if (url.includes(DOUYIN_API.CHECK_USER)) {
+            const data = await response.json();
+            isLoggedIn = data?.result === true && data?.status_code === 0;
+            logger.info(`[Douyin API] check/user: isLoggedIn=${isLoggedIn}`);
+          }
+
+          // 监听 work_list 接口 - 获取作品列表
+          if (url.includes('/work_list') || url.includes('/janus/douyin/creator/pc/work_list')) {
+            const data = await response.json();
+            if (data?.aweme_list) {
+              // 获取总数
+              if (data.total !== undefined) {
+                capturedData.total = data.total;
+              }
+              // 解析作品列表
+              capturedData.worksList = data.aweme_list.map((aweme: Record<string, unknown>) => {
+                const statistics = aweme.statistics as Record<string, unknown> || {};
+                const cover = aweme.Cover as { url_list?: string[] } || aweme.video as { cover?: { url_list?: string[] } };
+                const coverUrl = cover?.url_list?.[0] || (cover as { cover?: { url_list?: string[] } })?.cover?.url_list?.[0] || '';
+
+                return {
+                  awemeId: String(aweme.aweme_id || ''),
+                  title: String(aweme.item_title || aweme.desc || '').split('\n')[0].slice(0, 50) || '无标题',
+                  coverUrl,
+                  duration: Number(aweme.duration || 0),
+                  createTime: Number(aweme.create_time || 0),
+                  statistics: {
+                    play_count: Number(statistics.play_count || 0),
+                    digg_count: Number(statistics.digg_count || 0),
+                    comment_count: Number(statistics.comment_count || 0),
+                    share_count: Number(statistics.share_count || 0),
+                  },
+                };
+              });
+              logger.info(`[Douyin API] work_list: total=${capturedData.total}, items=${capturedData.worksList?.length}`);
+            }
+          }
+
+          // 监听账号信息接口
+          if (url.includes('/account_base_info') || url.includes('/user/info')) {
+            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}`);
+            }
+          }
+        } catch {
+          // 忽略非 JSON 响应
+        }
+      });
+
+      // 访问主页获取基本信息并触发 check/user 接口
+      logger.info('[Douyin] Navigating to creator home...');
+      await page.goto(DOUYIN_API.CREATOR_HOME, {
         waitUntil: 'domcontentloaded',
         timeout: 30000,
       });
 
       await page.waitForTimeout(3000);
 
-      // 使用 JavaScript 提取信息
+      // 检查登录状态 - 如果没有从 API 获取到,通过 URL 判断
+      if (!isLoggedIn) {
+        const currentUrl = page.url();
+        isLoggedIn = !currentUrl.includes('login') && !currentUrl.includes('passport');
+      }
+
+      if (!isLoggedIn) {
+        logger.warn('[Douyin] Not logged in, returning default account info');
+        return { accountId, accountName, avatarUrl, fansCount, worksCount, worksList };
+      }
+
+      // 从页面提取基本账号信息(作为 API 数据的补充)
       const accountData = await page.evaluate(() => {
         const result: { name?: string; avatar?: string; fans?: number; douyinId?: string } = {};
 
@@ -317,117 +516,52 @@ class HeadlessBrowserService {
         return result;
       });
 
-      if (accountData.douyinId) {
+      // 优先使用 API 数据,否则使用页面数据
+      if (capturedData.userInfo?.uid) {
+        accountId = `douyin_${capturedData.userInfo.uid}`;
+      } else if (accountData.douyinId) {
         accountId = `douyin_${accountData.douyinId}`;
       }
-      if (accountData.name) {
-        accountName = accountData.name;
-      }
-      if (accountData.avatar) {
-        avatarUrl = accountData.avatar;
-      }
-      if (accountData.fans !== undefined) {
-        fansCount = accountData.fans;
-      }
-
-      // 访问内容管理页面获取作品数和作品列表
-      try {
-        await page.goto('https://creator.douyin.com/creator-micro/content/manage', {
-          waitUntil: 'domcontentloaded',
-          timeout: 30000,
-        });
-
-        await page.waitForTimeout(3000);
-
-        // 获取作品总数
-        const totalEl = await page.$('[class*="content-header-total"]');
-        if (totalEl) {
-          const totalText = await totalEl.textContent();
-          if (totalText) {
-            const match = totalText.match(/(\d+)/);
-            if (match) {
-              worksCount = parseInt(match[1], 10);
-            }
-          }
-        }
-
-        // 获取作品列表
-        worksList = await page.evaluate(() => {
-          const items: {
-            videoId?: string;
-            title: string;
-            coverUrl: string;
-            duration: string;
-            publishTime: string;
-            status: string;
-            playCount: number;
-            likeCount: number;
-            commentCount: number;
-            shareCount: number;
-          }[] = [];
-
-          const cards = document.querySelectorAll('[class*="video-card-zQ02ng"]');
-
-          cards.forEach((card: Element) => {
-            try {
-              const coverEl = card.querySelector('[class*="video-card-cover"]') as HTMLElement | null;
-              let coverUrl = '';
-              if (coverEl && coverEl.style.backgroundImage) {
-                const match = coverEl.style.backgroundImage.match(/url\("(.+?)"\)/);
-                if (match) {
-                  coverUrl = match[1];
-                }
-              }
-
-              const durationEl = card.querySelector('[class*="badge-"]');
-              const duration = durationEl?.textContent?.trim() || '';
-
-              const titleEl = card.querySelector('[class*="info-title-text"]');
-              const title = titleEl?.textContent?.trim() || '无作品描述';
-
-              const timeEl = card.querySelector('[class*="info-time"]');
-              const publishTime = timeEl?.textContent?.trim() || '';
-
-              const statusEl = card.querySelector('[class*="info-status"]');
-              const status = statusEl?.textContent?.trim() || '';
-
-              const metricItems = card.querySelectorAll('[class*="metric-item-u1CAYE"]');
-              let playCount = 0, likeCount = 0, commentCount = 0, shareCount = 0;
 
-              metricItems.forEach((metric: Element) => {
-                const labelEl = metric.querySelector('[class*="metric-label"]');
-                const valueEl = metric.querySelector('[class*="metric-value"]');
-                const label = labelEl?.textContent?.trim() || '';
-                const value = parseInt(valueEl?.textContent?.trim() || '0', 10);
-
-                switch (label) {
-                  case '播放': playCount = value; break;
-                  case '点赞': likeCount = value; break;
-                  case '评论': commentCount = value; break;
-                  case '分享': shareCount = value; break;
-                }
-              });
-
-              items.push({
-                title,
-                coverUrl,
-                duration,
-                publishTime,
-                status,
-                playCount,
-                likeCount,
-                commentCount,
-                shareCount,
-              });
-            } catch { }
-          });
-
-          return items;
-        });
-
-        logger.info(`Douyin works: total ${worksCount}, fetched ${worksList.length} items`);
-      } catch (worksError) {
-        logger.warn('Failed to fetch Douyin works list:', worksError);
+      accountName = capturedData.userInfo?.nickname || accountData.name || accountName;
+      avatarUrl = capturedData.userInfo?.avatar || accountData.avatar || avatarUrl;
+      fansCount = capturedData.userInfo?.follower_count || accountData.fans || fansCount;
+
+      // 通过 API 获取作品列表
+      logger.info('[Douyin] Fetching works via API...');
+      const apiWorks = await this.fetchWorksDirectApi(page);
+
+      if (apiWorks.length > 0) {
+        worksCount = apiWorks.length;
+        worksList = apiWorks.map(w => ({
+          videoId: w.awemeId,
+          title: w.title,
+          coverUrl: w.coverUrl,
+          duration: '00:00',
+          publishTime: w.createTime ? new Date(w.createTime * 1000).toISOString() : '',
+          status: 'published',
+          playCount: 0,
+          likeCount: 0,
+          commentCount: w.commentCount,
+          shareCount: 0,
+        }));
+        logger.info(`[Douyin] Got ${worksCount} works from API`);
+      } else if (capturedData.worksList && capturedData.worksList.length > 0) {
+        // 如果直接 API 调用失败,使用监听到的数据
+        worksCount = capturedData.total || capturedData.worksList.length;
+        worksList = capturedData.worksList.map(w => ({
+          videoId: w.awemeId,
+          title: w.title,
+          coverUrl: w.coverUrl,
+          duration: this.formatDuration(w.duration),
+          publishTime: w.createTime ? new Date(w.createTime * 1000).toISOString() : '',
+          status: 'published',
+          playCount: w.statistics.play_count,
+          likeCount: w.statistics.digg_count,
+          commentCount: w.statistics.comment_count,
+          shareCount: w.statistics.share_count,
+        }));
+        logger.info(`[Douyin] Got ${worksCount} works from intercepted API data`);
       }
 
     } catch (error) {
@@ -438,6 +572,17 @@ class HeadlessBrowserService {
   }
 
   /**
+   * 格式化视频时长
+   */
+  private formatDuration(ms: number): string {
+    if (!ms) return '00:00';
+    const seconds = Math.floor(ms / 1000);
+    const minutes = Math.floor(seconds / 60);
+    const remainingSeconds = seconds % 60;
+    return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
+  }
+
+  /**
    * 获取B站账号信息
    */
   private async fetchBilibiliAccountInfo(
@@ -593,6 +738,1467 @@ class HeadlessBrowserService {
       worksCount: 0,
     };
   }
+
+  /**
+   * 获取抖音评论 - 逐个选择作品获取评论
+   */
+  async fetchDouyinComments(cookies: CookieData[]): Promise<WorkComments[]> {
+    const browser = await chromium.launch({
+      headless: true,
+      args: ['--no-sandbox', '--disable-setuid-sandbox'],
+    });
+
+    const allWorkComments: WorkComments[] = [];
+
+    try {
+      const context = await browser.newContext({
+        viewport: { width: 1920, height: 1080 },
+        userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
+      });
+
+      // 设置 Cookie
+      const playwrightCookies = cookies.map(c => ({
+        name: c.name,
+        value: c.value,
+        domain: c.domain || '.douyin.com',
+        path: c.path || '/',
+      }));
+      await context.addCookies(playwrightCookies);
+
+      const page = await context.newPage();
+
+      // 导航到评论管理页面
+      logger.info('Navigating to Douyin comment management page...');
+      await page.goto('https://creator.douyin.com/creator-micro/interactive/comment', {
+        waitUntil: 'domcontentloaded',
+        timeout: 30000,
+      });
+
+      await page.waitForTimeout(3000);
+
+      // 点击"选择作品"按钮
+      logger.info('Looking for "选择作品" button...');
+      const selectWorkBtn = await page.$('button:has-text("选择作品"), [class*="select"]:has-text("选择作品"), div:has-text("选择作品")');
+
+      if (selectWorkBtn) {
+        await selectWorkBtn.click();
+        await page.waitForTimeout(2000);
+        logger.info('Clicked "选择作品" button');
+      } else {
+        // 如果没有选择作品按钮,可能已经有作品被选中,直接获取当前评论
+        logger.info('No "选择作品" button found, fetching current comments...');
+        const currentComments = await this.extractCommentsFromPage(page);
+        if (currentComments.length > 0) {
+          allWorkComments.push({
+            videoId: 'current',
+            videoTitle: '当前作品',
+            videoCoverUrl: '',
+            comments: currentComments,
+          });
+        }
+        await page.close();
+        await context.close();
+        await browser.close();
+        return allWorkComments;
+      }
+
+      // 获取作品列表
+      const worksList = await page.evaluate(() => {
+        const works: Array<{ videoId: string; title: string; coverUrl: string }> = [];
+
+        // 查找作品列表容器(弹窗中的作品选择列表)
+        const workItems = document.querySelectorAll('[class*="video-card"], [class*="work-item"], [class*="content-item"]');
+
+        workItems.forEach((item, index) => {
+          const titleEl = item.querySelector('[class*="title"], [class*="desc"]');
+          const coverEl = item.querySelector('img, [class*="cover"]');
+
+          const title = titleEl?.textContent?.trim() || `作品 ${index + 1}`;
+          let coverUrl = '';
+
+          if (coverEl) {
+            coverUrl = (coverEl as HTMLImageElement).src ||
+              coverEl.getAttribute('style')?.match(/url\(['"]?([^'")\s]+)['"]?\)/)?.[1] || '';
+          }
+
+          // 获取 video ID(从数据属性或其他方式)
+          const videoId = item.getAttribute('data-video-id') ||
+            item.getAttribute('data-id') ||
+            `video_${index}`;
+
+          works.push({ videoId, title, coverUrl });
+        });
+
+        return works;
+      });
+
+      logger.info(`Found ${worksList.length} works in the selector`);
+
+      // 如果有作品列表,逐个选择并获取评论
+      if (worksList.length > 0) {
+        for (let i = 0; i < worksList.length; i++) {
+          try {
+            logger.info(`Processing work ${i + 1}/${worksList.length}: ${worksList[i].title}`);
+
+            // 点击选择该作品
+            const workItem = await page.$(`[class*="video-card"]:nth-child(${i + 1}), [class*="work-item"]:nth-child(${i + 1})`);
+            if (workItem) {
+              await workItem.click();
+              await page.waitForTimeout(2000);
+
+              // 点击确认按钮(如果有)
+              const confirmBtn = await page.$('button:has-text("确定"), button:has-text("确认")');
+              if (confirmBtn) {
+                await confirmBtn.click();
+                await page.waitForTimeout(2000);
+              }
+            }
+
+            // 等待评论加载
+            await page.waitForTimeout(2000);
+
+            // 提取评论
+            const comments = await this.extractCommentsFromPage(page);
+
+            if (comments.length > 0) {
+              allWorkComments.push({
+                videoId: worksList[i].videoId,
+                videoTitle: worksList[i].title,
+                videoCoverUrl: worksList[i].coverUrl,
+                comments,
+              });
+              logger.info(`Extracted ${comments.length} comments for work: ${worksList[i].title}`);
+            }
+
+            // 重新打开选择作品弹窗(如果需要继续选择其他作品)
+            if (i < worksList.length - 1) {
+              const selectBtn = await page.$('button:has-text("选择作品"), [class*="select"]:has-text("选择作品")');
+              if (selectBtn) {
+                await selectBtn.click();
+                await page.waitForTimeout(2000);
+              }
+            }
+          } catch (workError) {
+            logger.warn(`Failed to process work ${i + 1}:`, workError);
+          }
+        }
+      } else {
+        // 没有找到作品列表,尝试直接从页面获取评论
+        const comments = await this.extractCommentsFromPage(page);
+        if (comments.length > 0) {
+          // 获取当前显示的作品信息
+          const currentWork = await page.evaluate(() => {
+            const titleEl = document.querySelector('[class*="video-title"], [class*="content-title"]');
+            const coverEl = document.querySelector('[class*="video-cover"] img, [class*="cover"] img');
+
+            return {
+              title: titleEl?.textContent?.trim() || '当前作品',
+              coverUrl: (coverEl as HTMLImageElement)?.src || '',
+            };
+          });
+
+          allWorkComments.push({
+            videoId: 'current',
+            videoTitle: currentWork.title,
+            videoCoverUrl: currentWork.coverUrl,
+            comments,
+          });
+        }
+      }
+
+      await page.close();
+      await context.close();
+      await browser.close();
+
+      logger.info(`Total: fetched comments from ${allWorkComments.length} works`);
+      return allWorkComments;
+
+    } catch (error) {
+      logger.error('Error fetching Douyin comments:', error);
+      await browser.close();
+      return allWorkComments;
+    }
+  }
+
+  /**
+   * 从页面提取评论列表
+   * 使用抖音创作者中心的精确选择器
+   * 根据实际 HTML 结构:
+   * - 评论容器: container-sXKyMs (或类似 container-xxx)
+   * - 用户名: username-aLgaNB (或类似 username-xxx)
+   * - 时间: time-NRtTXO (或类似 time-xxx)
+   * - 评论内容: comment-content-text-JvmAKq (或类似 comment-content-text-xxx)
+   * - 头像: avatar-BRKDsF (或类似 avatar-xxx)
+   */
+  private async extractCommentsFromPage(page: Page): Promise<CommentItem[]> {
+    return page.evaluate(() => {
+      const comments: Array<{
+        commentId: string;
+        authorId: string;
+        authorName: string;
+        authorAvatar: string;
+        content: string;
+        likeCount: number;
+        commentTime: string;
+      }> = [];
+
+      const seenContents = new Set<string>();
+
+      // 方法1: 直接查找所有评论容器 (container-xxx 类名)
+      // 评论容器通常包含 checkbox、avatar、content 等子元素
+      const allContainers = document.querySelectorAll('[class*="container-"]');
+      const commentContainers: Element[] = [];
+
+      allContainers.forEach(container => {
+        // 检查是否是评论容器:包含用户名和评论内容
+        const hasUsername = container.querySelector('[class*="username-"]');
+        const hasCommentContent = container.querySelector('[class*="comment-content-text-"]');
+        if (hasUsername && hasCommentContent) {
+          commentContainers.push(container);
+        }
+      });
+
+      console.log(`Found ${commentContainers.length} comment containers`);
+
+      // 如果方法1没找到,尝试方法2:通过评论内容元素向上查找
+      if (commentContainers.length === 0) {
+        const contentElements = document.querySelectorAll('[class*="comment-content-text-"]');
+        console.log(`Found ${contentElements.length} content elements, searching parents...`);
+
+        contentElements.forEach(contentEl => {
+          let parent = contentEl.parentElement;
+          // 向上查找最多 10 层
+          for (let i = 0; i < 10 && parent; i++) {
+            const className = parent.className || '';
+            // 查找包含 container- 的父元素
+            if (className.includes('container-')) {
+              if (!commentContainers.includes(parent)) {
+                commentContainers.push(parent);
+              }
+              break;
+            }
+            parent = parent.parentElement;
+          }
+        });
+      }
+
+      console.log(`Total comment containers: ${commentContainers.length}`);
+
+      commentContainers.forEach((container, index) => {
+        try {
+          // 提取用户名 - 使用 username-xxx 选择器
+          let authorName = '';
+          const usernameEl = container.querySelector('[class*="username-"]');
+          if (usernameEl && usernameEl.textContent) {
+            authorName = usernameEl.textContent.trim();
+          }
+          if (!authorName) authorName = '未知用户';
+
+          // 提取头像 - 从 avatar-xxx 容器内的 img 提取
+          let authorAvatar = '';
+          const avatarContainer = container.querySelector('[class*="avatar-"]');
+          if (avatarContainer) {
+            const avatarImg = avatarContainer.querySelector('img');
+            if (avatarImg) {
+              authorAvatar = avatarImg.src || '';
+            }
+          }
+
+          // 提取时间 - 使用 time-xxx 选择器
+          let commentTime = '';
+          const timeEl = container.querySelector('[class*="time-"]');
+          if (timeEl && timeEl.textContent) {
+            commentTime = timeEl.textContent.trim();
+          }
+
+          // 提取评论内容 - 使用 comment-content-text-xxx 选择器
+          let content = '';
+          const contentEl = container.querySelector('[class*="comment-content-text-"]');
+          if (contentEl && contentEl.textContent) {
+            content = contentEl.textContent.trim();
+          }
+
+          // 跳过空内容
+          if (!content || content.length < 1) {
+            console.log(`[${index}] Skipping empty content`);
+            return;
+          }
+
+          // 去重 (基于用户名+内容)
+          const contentKey = `${authorName}||${content}`;
+          if (seenContents.has(contentKey)) {
+            console.log(`[${index}] Skipping duplicate: ${authorName} - ${content.slice(0, 20)}`);
+            return;
+          }
+          seenContents.add(contentKey);
+
+          // 提取点赞数 - 从 operations-xxx 或 item-xxx 中提取
+          let likeCount = 0;
+          const operationsEl = container.querySelector('[class*="operations-"]');
+          if (operationsEl) {
+            // 查找第一个 item-xxx,通常是点赞数
+            const firstItem = operationsEl.querySelector('[class*="item-"]');
+            if (firstItem) {
+              const text = firstItem.textContent || '';
+              const match = text.match(/(\d+)/);
+              if (match) {
+                likeCount = parseInt(match[1], 10);
+              }
+            }
+          }
+
+          // 生成唯一 ID
+          const contentHash = content.slice(0, 30) + authorName + commentTime;
+          const commentId = `dy_${btoa(encodeURIComponent(contentHash)).slice(0, 20)}`;
+
+          comments.push({
+            commentId,
+            authorId: authorName,
+            authorName,
+            authorAvatar,
+            content,
+            likeCount,
+            commentTime,
+          });
+
+          console.log(`[${index}] Extracted: ${authorName} - ${content.slice(0, 30)} (${commentTime})`);
+        } catch (err) {
+          console.error(`[${index}] Error extracting comment:`, err);
+        }
+      });
+
+      console.log(`Successfully extracted ${comments.length} comments`);
+      return comments;
+    });
+  }
+
+  /**
+   * 获取抖音评论 - 通过监听 API 请求 (推荐方式)
+   * 使用无头浏览器,通过拦截网络请求直接获取 API 数据
+   * 更稳定、更高效
+   */
+  async fetchDouyinCommentsByApiInterception(cookies: CookieData[]): Promise<WorkComments[]> {
+    const browser = await chromium.launch({
+      headless: true, // 无头模式
+      args: ['--no-sandbox', '--disable-setuid-sandbox'],
+    });
+
+    const allWorkComments: WorkComments[] = [];
+    // 存储捕获的 API 数据
+    const capturedWorks: Array<{
+      awemeId: string;
+      title: string;
+      coverUrl: string;
+      commentCount: number;
+    }> = [];
+    const capturedComments: Map<string, CommentItem[]> = new Map();
+
+    try {
+      const context = await browser.newContext({
+        viewport: { width: 1920, height: 1080 },
+        userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
+      });
+
+      // 设置 Cookie
+      const playwrightCookies = cookies.map(c => ({
+        name: c.name,
+        value: c.value,
+        domain: c.domain || '.douyin.com',
+        path: c.path || '/',
+      }));
+      await context.addCookies(playwrightCookies);
+      logger.info(`[API Interception] Set ${playwrightCookies.length} cookies`);
+
+      const page = await context.newPage();
+
+      // 监听网络响应
+      page.on('response', async (response) => {
+        const url = response.url();
+
+        try {
+          // 监听作品列表 API - 支持新旧两种接口
+          // 新接口: /janus/douyin/creator/pc/work_list (aweme_list 字段)
+          // 旧接口: /creator/item/list (item_info_list 字段)
+          if (url.includes('/work_list') || url.includes('/creator/item/list')) {
+            const data = await response.json();
+
+            // 新接口: aweme_list
+            if (data?.aweme_list && data.aweme_list.length > 0) {
+              for (const aweme of data.aweme_list) {
+                const awemeId = String(aweme.aweme_id || '');
+                if (!awemeId) continue;
+
+                const statistics = aweme.statistics || {};
+                const commentCount = parseInt(String(statistics.comment_count || '0'), 10);
+
+                let title = aweme.item_title || '';
+                if (!title) {
+                  const desc = aweme.desc || aweme.caption || '';
+                  title = desc.split('\n')[0].slice(0, 50) || '无标题';
+                }
+
+                let coverUrl = '';
+                if (aweme.Cover?.url_list?.length > 0) {
+                  coverUrl = aweme.Cover.url_list[0];
+                } else if (aweme.video?.cover?.url_list?.length > 0) {
+                  coverUrl = aweme.video.cover.url_list[0];
+                }
+
+                capturedWorks.push({
+                  awemeId,
+                  title,
+                  coverUrl,
+                  commentCount,
+                });
+              }
+              logger.info(`[API] Captured ${data.aweme_list.length} works from work_list API`);
+            }
+
+            // 旧接口: item_info_list
+            const itemList = data?.item_info_list || data?.item_list || [];
+            if (itemList.length > 0) {
+              for (const item of itemList) {
+                capturedWorks.push({
+                  awemeId: item.item_id_plain || item.aweme_id || '',
+                  title: item.title || '无标题',
+                  coverUrl: item.cover_image_url || '',
+                  commentCount: item.comment_count || 0,
+                });
+              }
+              logger.info(`[API] Captured ${itemList.length} works from item/list API`);
+            }
+          }
+
+          // 监听评论列表 API(两种格式)
+          // 格式1: /comment/list/select/ - 初始加载,返回 { comments: [...] }
+          // 格式2: /creator/comment/list/ - 切换作品后,返回 { comment_info_list: [...] }
+          if (url.includes('/comment/list') || url.includes('/comment/read')) {
+            const data = await response.json();
+            // 从 URL 中提取 aweme_id
+            const awemeIdMatch = url.match(/aweme_id=(\d+)/);
+            const awemeId = awemeIdMatch?.[1] || '';
+
+            let comments: CommentItem[] = [];
+
+            // 格式1: 初始加载的评论 API (comment/list/select)
+            if (data?.comments && Array.isArray(data.comments) && data.comments.length > 0) {
+              comments = data.comments.map((c: Record<string, unknown>) => {
+                const user = c.user as Record<string, unknown> | undefined;
+                const avatarThumb = user?.avatar_thumb as Record<string, unknown> | undefined;
+                const avatarUrls = avatarThumb?.url_list as string[] | undefined;
+
+                return {
+                  commentId: String(c.cid || ''),
+                  authorId: String(user?.uid || ''),
+                  authorName: String(user?.nickname || '匿名'),
+                  authorAvatar: avatarUrls?.[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(`[API] Format1 (comments): Captured ${comments.length} comments`);
+            }
+
+            // 格式2: 切换作品后的评论 API (creator/comment/list)
+            if (data?.comment_info_list && Array.isArray(data.comment_info_list) && data.comment_info_list.length > 0) {
+              comments = data.comment_info_list.map((c: Record<string, unknown>) => {
+                const userInfo = c.user_info as Record<string, unknown> | undefined;
+
+                return {
+                  commentId: String(c.comment_id || ''),
+                  authorId: String(userInfo?.user_id || ''),
+                  authorName: String(userInfo?.screen_name || '匿名'),
+                  authorAvatar: String(userInfo?.avatar_url || ''),
+                  content: String(c.text || ''),
+                  likeCount: Number(c.digg_count || 0),
+                  commentTime: new Date(Number(c.create_time || 0) * 1000).toISOString(),
+                  videoId: awemeId, // 这种格式没有直接返回 aweme_id
+                };
+              });
+              logger.info(`[API] Format2 (comment_info_list): Captured ${comments.length} comments`);
+            }
+
+            if (comments.length > 0) {
+              const videoId = comments[0]?.videoId || awemeId;
+              if (videoId) {
+                const existing = capturedComments.get(videoId) || [];
+                capturedComments.set(videoId, [...existing, ...comments]);
+                logger.info(`[API] Total captured ${comments.length} comments for aweme ${videoId}`);
+              }
+            }
+          }
+        } catch {
+          // 忽略非 JSON 响应
+        }
+      });
+
+      // 导航到创作者中心页面(设置好 Cookie 后)
+      logger.info('[API Interception] Navigating to creator page...');
+      await page.goto('https://creator.douyin.com/creator-micro/home', {
+        waitUntil: 'domcontentloaded',
+        timeout: 60000,
+      });
+
+      // 等待页面加载
+      await page.waitForTimeout(3000);
+
+      // 检查是否需要登录
+      const currentUrl = page.url();
+      if (currentUrl.includes('login') || currentUrl.includes('passport')) {
+        logger.warn('[API Interception] Cookie expired');
+        return [];
+      }
+
+      // 方式1:直接调用 API 获取作品列表(优先)
+      logger.info('[API Interception] Fetching works via direct API...');
+      let works = await this.fetchWorksDirectApi(page);
+
+      // 方式2:如果直接调用失败,尝试通过页面触发 API
+      if (works.length === 0) {
+        logger.info('[API Interception] Direct API failed, trying page navigation...');
+        await page.goto('https://creator.douyin.com/creator-micro/interactive/comment', {
+          waitUntil: 'networkidle',
+          timeout: 60000,
+        });
+        await page.waitForTimeout(3000);
+
+        // 点击"选择作品"按钮触发作品列表 API
+        const selectBtn = await page.$('button:has-text("选择作品"), [class*="select"]:has-text("选择")');
+        if (selectBtn) {
+          await selectBtn.click();
+          await page.waitForTimeout(3000);
+        }
+
+        // 使用监听到的数据
+        works = capturedWorks;
+      }
+
+      logger.info(`[API Interception] Total works: ${works.length}`);
+
+      // 获取有评论的作品
+      const worksWithComments = works.filter(w => w.commentCount > 0);
+      logger.info(`[API Interception] Works with comments: ${worksWithComments.length}`);
+
+      // 如果有评论的作品,需要导航到评论管理页面并逐个切换获取
+      if (worksWithComments.length > 0) {
+        // 先尝试直接调用 API 获取评论
+        for (const work of worksWithComments) {
+          logger.info(`[API Interception] Trying direct API for: ${work.title.slice(0, 30)}... (${work.commentCount} comments)`);
+
+          let comments = capturedComments.get(work.awemeId) || [];
+
+          if (comments.length === 0) {
+            comments = await this.fetchCommentsDirectApi(page, work.awemeId);
+          }
+
+          if (comments.length > 0) {
+            allWorkComments.push({
+              videoId: work.awemeId,
+              videoTitle: work.title,
+              videoCoverUrl: work.coverUrl,
+              comments,
+            });
+            logger.info(`[API Interception] Got ${comments.length} comments for ${work.awemeId} via direct API`);
+          }
+        }
+
+        // 如果直接 API 没有获取到足够的评论,使用页面交互方式
+        const worksNeedingPageMethod = worksWithComments.filter(w => {
+          const found = allWorkComments.find(wc => wc.videoId === w.awemeId);
+          return !found || found.comments.length === 0;
+        });
+
+        if (worksNeedingPageMethod.length > 0) {
+          logger.info(`[API Interception] ${worksNeedingPageMethod.length} works need page interaction method`);
+
+          // works 是所有作品的列表(包括没有评论的),弹窗中的顺序应该和这个一致
+          // worksWithComments 是有评论的作品列表
+          logger.info(`[Page Method] All works: ${works.length}, works with comments: ${worksWithComments.length}`);
+          logger.info(`[Page Method] Works with comments IDs: ${worksWithComments.map(w => w.awemeId).join(', ')}`);
+
+          // 构建作品索引映射:在所有作品列表中,每个有评论的作品的索引是多少
+          const workIndexMap = new Map<string, number>();
+          works.forEach((w, idx) => {
+            if (w.commentCount > 0) {
+              workIndexMap.set(w.awemeId, idx);
+            }
+          });
+          logger.info(`[Page Method] Work index map: ${JSON.stringify(Object.fromEntries(workIndexMap))}`);
+
+          // 导航到评论管理页面
+          logger.info('[Page Method] Navigating to comment management page...');
+          await page.goto('https://creator.douyin.com/creator-micro/interactive/comment', {
+            waitUntil: 'domcontentloaded',
+            timeout: 60000,
+          });
+
+          // 等待页面加载
+          await page.waitForTimeout(5000);
+
+          // 用于存储最新捕获的评论和 aweme_id
+          const latestHolder: { comments: CommentItem[]; awemeId: string } = { comments: [], awemeId: '' };
+
+          // 设置监听器 - 捕获评论 API 响应
+          page.on('response', async (response) => {
+            const url = response.url();
+            if (url.includes('/comment/list') || url.includes('/comment/read')) {
+              try {
+                const jsonData = await response.json();
+                let parsedComments: CommentItem[] = [];
+                let capturedAwemeId = '';
+
+                // 从 URL 中提取 aweme_id(格式1有)
+                const awemeIdMatch = url.match(/aweme_id=(\d+)/);
+                capturedAwemeId = awemeIdMatch?.[1] || '';
+
+                // 格式1: { comments: [...] }
+                if (jsonData?.comments && Array.isArray(jsonData.comments) && jsonData.comments.length > 0) {
+                  // 从评论中提取 aweme_id
+                  const firstComment = jsonData.comments[0] as Record<string, unknown>;
+                  if (!capturedAwemeId && firstComment.aweme_id) {
+                    capturedAwemeId = String(firstComment.aweme_id);
+                  }
+
+                  parsedComments = jsonData.comments.map((c: Record<string, unknown>) => ({
+                    commentId: String((c as { cid?: string }).cid || ''),
+                    authorId: String(((c as { user?: { uid?: string } }).user)?.uid || ''),
+                    authorName: String(((c as { user?: { nickname?: string } }).user)?.nickname || '匿名'),
+                    authorAvatar: ((c as { user?: { avatar_thumb?: { url_list?: string[] } } }).user)?.avatar_thumb?.url_list?.[0] || '',
+                    content: String((c as { text?: string }).text || ''),
+                    likeCount: Number((c as { digg_count?: number }).digg_count || 0),
+                    commentTime: new Date(Number((c as { create_time?: number }).create_time || 0) * 1000).toISOString(),
+                    videoId: capturedAwemeId,
+                  }));
+                  logger.info(`[Comment API] Format1: ${parsedComments.length} comments, aweme_id: ${capturedAwemeId}`);
+                }
+
+                // 格式2: { comment_info_list: [...] }
+                if (jsonData?.comment_info_list && Array.isArray(jsonData.comment_info_list) && jsonData.comment_info_list.length > 0) {
+                  parsedComments = jsonData.comment_info_list.map((c: Record<string, unknown>) => {
+                    const userInfo = c.user_info as Record<string, unknown> | undefined;
+                    return {
+                      commentId: String(c.comment_id || ''),
+                      authorId: String(userInfo?.user_id || ''),
+                      authorName: String(userInfo?.screen_name || '匿名'),
+                      authorAvatar: String(userInfo?.avatar_url || ''),
+                      content: String(c.text || ''),
+                      likeCount: Number(c.digg_count || 0),
+                      commentTime: new Date(Number(c.create_time || 0) * 1000).toISOString(),
+                      videoId: '', // 格式2没有aweme_id,稍后填充
+                    };
+                  });
+                  logger.info(`[Comment API] Format2: ${parsedComments.length} comments (no aweme_id)`);
+                }
+
+                if (parsedComments.length > 0) {
+                  latestHolder.comments = parsedComments;
+                  latestHolder.awemeId = capturedAwemeId;
+                }
+              } catch {
+                // 忽略
+              }
+            }
+          });
+
+          // 等待第一个作品的评论加载(格式1,有 aweme_id)
+          await page.waitForTimeout(4000);
+
+          // 处理第一个作品(页面加载时自动显示,格式1有aweme_id可以直接匹配)
+          if (latestHolder.comments.length > 0 && latestHolder.awemeId) {
+            const matchedWork = works.find(w => w.awemeId === latestHolder.awemeId);
+            if (matchedWork) {
+              allWorkComments.push({
+                videoId: matchedWork.awemeId,
+                videoTitle: matchedWork.title,
+                videoCoverUrl: matchedWork.coverUrl,
+                comments: latestHolder.comments,
+              });
+              logger.info(`[Page Method] First work (by aweme_id): ${latestHolder.comments.length} comments for ${matchedWork.awemeId}`);
+            }
+          }
+
+          // 遍历其他有评论的作品
+          for (const work of worksWithComments) {
+            // 检查是否已获取
+            const existing = allWorkComments.find(wc => wc.videoId === work.awemeId);
+            if (existing) {
+              logger.info(`[Page Method] Skip ${work.awemeId}, already got ${existing.comments.length} comments`);
+              continue;
+            }
+
+            // 获取该作品在所有作品列表中的索引
+            const workIndex = workIndexMap.get(work.awemeId);
+            if (workIndex === undefined) {
+              logger.warn(`[Page Method] Work ${work.awemeId} not found in index map`);
+              continue;
+            }
+
+            logger.info(`[Page Method] Processing work: ${work.title.slice(0, 20)}... (awemeId: ${work.awemeId}, index: ${workIndex})`);
+
+            try {
+              // 清空之前的数据
+              latestHolder.comments = [];
+              latestHolder.awemeId = '';
+
+              // 点击"选择作品"按钮
+              await page.click('button:has-text("选择作品")');
+              await page.waitForTimeout(2000);
+
+              // 找到弹窗中的作品图片列表
+              const workImages = await page.$$('[role="dialog"] img[src*="douyinpic"], .douyin-creator-interactive-sidesheet-inner img[src*="douyinpic"]');
+              logger.info(`[Page Method] Found ${workImages.length} work images in dialog`);
+
+              if (workIndex < workImages.length) {
+                // 点击对应索引的作品
+                await workImages[workIndex].click();
+                logger.info(`[Page Method] Clicked work image at index ${workIndex}`);
+
+                // 等待评论 API 响应
+                await page.waitForTimeout(4000);
+
+                // 获取评论
+                if (latestHolder.comments.length > 0) {
+                  // 使用当前 work 的 awemeId
+                  const comments = latestHolder.comments.map(c => ({
+                    ...c,
+                    videoId: work.awemeId,
+                  }));
+                  allWorkComments.push({
+                    videoId: work.awemeId,
+                    videoTitle: work.title,
+                    videoCoverUrl: work.coverUrl,
+                    comments,
+                  });
+                  logger.info(`[Page Method] Got ${comments.length} comments for ${work.awemeId}`);
+                } else {
+                  // 尝试从页面提取
+                  const pageComments = await this.extractCommentsFromPage(page);
+                  if (pageComments.length > 0) {
+                    const comments = pageComments.map(c => ({ ...c, videoId: work.awemeId }));
+                    allWorkComments.push({
+                      videoId: work.awemeId,
+                      videoTitle: work.title,
+                      videoCoverUrl: work.coverUrl,
+                      comments,
+                    });
+                    logger.info(`[Page Method] Extracted ${comments.length} comments from page`);
+                  } else {
+                    logger.warn(`[Page Method] No comments for ${work.awemeId}`);
+                  }
+                }
+              } else {
+                logger.warn(`[Page Method] Index ${workIndex} out of range, only ${workImages.length} images`);
+                await page.keyboard.press('Escape');
+              }
+
+              await page.waitForTimeout(1000);
+            } catch (e) {
+              logger.warn(`[Page Method] Error for work ${work.awemeId}:`, e);
+              await page.keyboard.press('Escape').catch(() => { });
+              await page.waitForTimeout(500);
+            }
+          }
+        }
+      }
+
+      logger.info(`[API Interception] Total result: ${allWorkComments.length} works with comments`);
+      await context.close();
+    } catch (error) {
+      logger.error('[API Interception] Error:', error);
+    } finally {
+      await browser.close();
+    }
+
+    return allWorkComments;
+  }
+
+  /**
+   * 从页面获取当前作品的 videoId
+   */
+  private async getCurrentVideoIdFromPage(page: Page): Promise<string | null> {
+    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');
+        }
+
+        return null;
+      });
+
+      return videoId;
+    } catch {
+      return null;
+    }
+  }
+
+  /**
+   * 直接调用评论 API 获取数据
+   */
+  private async fetchCommentsDirectApi(page: Page, awemeId: string): Promise<CommentItem[]> {
+    const comments: CommentItem[] = [];
+
+    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',
+          },
+        });
+
+        return resp.json();
+      }, awemeId);
+
+      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(`[DirectAPI] Fetched ${comments.length} comments for ${awemeId}`);
+      }
+    } catch (e) {
+      logger.warn(`[DirectAPI] Failed to fetch comments for ${awemeId}:`, e);
+    }
+
+    return comments;
+  }
+
+  /**
+   * 直接调用抖音 API 获取作品列表
+   * 使用新的 work_list 接口,支持分页加载
+   */
+  private async fetchWorksDirectApi(page: Page): Promise<Array<{
+    awemeId: string;
+    title: string;
+    coverUrl: string;
+    commentCount: number;
+    createTime?: number;
+  }>> {
+    const works: Array<{
+      awemeId: string;
+      title: string;
+      coverUrl: string;
+      commentCount: number;
+      createTime?: number;
+    }> = [];
+
+    try {
+      let hasMore = true;
+      let maxCursor = 0;
+      let pageCount = 0;
+      const maxPages = 20; // 最多加载20页,防止无限循环
+
+      while (hasMore && pageCount < maxPages) {
+        pageCount++;
+        logger.info(`[DirectAPI] Fetching works page ${pageCount}, cursor: ${maxCursor}`);
+
+        const data = await page.evaluate(async (cursor: number) => {
+          // 使用新的 work_list API 接口
+          const url = `https://creator.douyin.com/janus/douyin/creator/pc/work_list?scene=star_atlas&device_platform=android&status=0&count=12&max_cursor=${cursor}&cookie_enabled=true&browser_language=zh-CN&browser_platform=Win32&browser_name=Mozilla&browser_online=true&timezone_name=Asia%2FShanghai&aid=1128`;
+
+          const resp = await fetch(url, {
+            credentials: 'include',
+            headers: {
+              'Accept': 'application/json',
+            },
+          });
+
+          return resp.json();
+        }, maxCursor);
+
+        // 解析 aweme_list 中的作品数据
+        const awemeList = data?.aweme_list || [];
+        logger.info(`[DirectAPI] Page ${pageCount}: got ${awemeList.length} works from aweme_list`);
+
+        for (const aweme of awemeList) {
+          const awemeId = String(aweme.aweme_id || '');
+          if (!awemeId) continue;
+
+          // 从 statistics 中获取评论数
+          const statistics = aweme.statistics || {};
+          const commentCount = parseInt(String(statistics.comment_count || '0'), 10);
+
+          // 获取标题:优先使用 item_title,其次使用 desc(描述)
+          let title = aweme.item_title || '';
+          if (!title) {
+            // 从 desc 中提取标题(取第一行或前50个字符)
+            const desc = aweme.desc || aweme.caption || '';
+            title = desc.split('\n')[0].slice(0, 50) || '无标题';
+          }
+
+          // 获取封面 URL:从 Cover.url_list 或 video.cover.url_list 中获取
+          let coverUrl = '';
+          if (aweme.Cover?.url_list?.length > 0) {
+            coverUrl = aweme.Cover.url_list[0];
+          } else if (aweme.video?.cover?.url_list?.length > 0) {
+            coverUrl = aweme.video.cover.url_list[0];
+          }
+
+          works.push({
+            awemeId,
+            title,
+            coverUrl,
+            commentCount,
+            createTime: aweme.create_time,
+          });
+        }
+
+        // 检查是否有更多数据
+        hasMore = data?.has_more === true;
+
+        // 更新游标:使用返回的 max_cursor 或基于最后一个作品的 create_time
+        if (data?.max_cursor) {
+          maxCursor = data.max_cursor;
+        } else if (awemeList.length > 0) {
+          // 如果没有 max_cursor,使用最后一个作品的 create_time 作为游标
+          const lastAweme = awemeList[awemeList.length - 1];
+          if (lastAweme.create_time) {
+            maxCursor = lastAweme.create_time * 1000; // 转换为毫秒
+          }
+        }
+
+        // 如果没有获取到数据,停止循环
+        if (awemeList.length === 0) {
+          hasMore = false;
+        }
+
+        // 稍微延迟,避免请求过快
+        if (hasMore) {
+          await new Promise(resolve => setTimeout(resolve, 500));
+        }
+      }
+
+      logger.info(`[DirectAPI] Total fetched ${works.length} works from ${pageCount} pages`);
+    } catch (e) {
+      logger.warn('[DirectAPI] Failed to fetch works:', e);
+    }
+
+    return works;
+  }
+
+  /**
+   * 获取抖音评论 (旧版 - DOM解析方式)
+   * 模拟点击"选择作品"按钮,依次点击作品获取评论
+   * 作为备用方案
+   */
+  async fetchDouyinCommentsViaApi(cookies: CookieData[]): Promise<WorkComments[]> {
+    // 优先使用 API 拦截方式
+    const result = await this.fetchDouyinCommentsByApiInterception(cookies);
+    if (result.length > 0) {
+      return result;
+    }
+
+    // 如果 API 方式失败,使用旧的 DOM 解析方式作为备用
+    logger.info('[Fallback] Using DOM parsing method...');
+
+    const browser = await chromium.launch({
+      headless: true, // 改为无头模式
+      args: ['--no-sandbox', '--disable-setuid-sandbox'],
+    });
+
+    const allWorkComments: WorkComments[] = [];
+
+    try {
+      const context = await browser.newContext({
+        viewport: { width: 1920, height: 1080 },
+        userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
+      });
+
+      // 设置 Cookie
+      const playwrightCookies = cookies.map(c => ({
+        name: c.name,
+        value: c.value,
+        domain: c.domain || '.douyin.com',
+        path: c.path || '/',
+      }));
+      await context.addCookies(playwrightCookies);
+      logger.info(`Set ${playwrightCookies.length} cookies`);
+
+      const page = await context.newPage();
+
+      // 导航到评论管理页面
+      logger.info('Navigating to Douyin comment management page...');
+      await page.goto('https://creator.douyin.com/creator-micro/interactive/comment', {
+        waitUntil: 'domcontentloaded',
+        timeout: 60000,
+      });
+
+      // 等待页面完全加载
+      logger.info('Waiting for page to fully load...');
+      await page.waitForTimeout(5000);
+
+      // 检查是否需要登录
+      const currentUrl = page.url();
+      logger.info(`Current URL: ${currentUrl}`);
+      if (currentUrl.includes('login') || currentUrl.includes('passport')) {
+        logger.warn('Cookie expired, need re-login');
+        await browser.close();
+        return allWorkComments;
+      }
+
+      logger.info('Page loaded successfully');
+
+      // 等待评论列表加载
+      logger.info('Waiting for comments to load...');
+      try {
+        await page.waitForSelector('[class*="comment-content-text-"]', { timeout: 10000 });
+        logger.info('Comments loaded');
+      } catch {
+        logger.warn('No comments found on initial load, will try to select works');
+      }
+
+      // 辅助函数:从当前页面提取评论
+      const extractCommentsFromCurrentPage = async (): Promise<CommentItem[]> => {
+        logger.info('Extracting comments from current page...');
+
+        // 滚动页面加载所有评论
+        await page.evaluate(async () => {
+          // 滚动多次加载更多评论
+          for (let i = 0; i < 10; i++) {
+            window.scrollBy(0, 500);
+            await new Promise(r => setTimeout(r, 800));
+          }
+          window.scrollTo(0, 0);
+        });
+
+        await page.waitForTimeout(2000);
+
+        // 使用精确选择器提取评论
+        const comments = await page.evaluate(() => {
+          const result: Array<{
+            commentId: string;
+            authorId: string;
+            authorName: string;
+            authorAvatar: string;
+            content: string;
+            likeCount: number;
+            commentTime: string;
+          }> = [];
+
+          const seenContents = new Set<string>();
+
+          // 查找所有评论容器:包含 username 和 comment-content-text 的 container
+          const allContainers = document.querySelectorAll('[class*="container-"]');
+          const commentContainers: Element[] = [];
+
+          allContainers.forEach(container => {
+            const hasUsername = container.querySelector('[class*="username-"]');
+            const hasCommentContent = container.querySelector('[class*="comment-content-text-"]');
+            if (hasUsername && hasCommentContent) {
+              commentContainers.push(container);
+            }
+          });
+
+          console.log(`Found ${commentContainers.length} comment containers`);
+
+          commentContainers.forEach((container, index) => {
+            try {
+              // 用户名
+              const usernameEl = container.querySelector('[class*="username-"]');
+              const authorName = usernameEl?.textContent?.trim() || '未知用户';
+
+              // 时间
+              const timeEl = container.querySelector('[class*="time-"]');
+              const commentTime = timeEl?.textContent?.trim() || '';
+
+              // 评论内容
+              const contentEl = container.querySelector('[class*="comment-content-text-"]');
+              const content = contentEl?.textContent?.trim() || '';
+
+              if (!content) return;
+
+              // 头像
+              const avatarContainer = container.querySelector('[class*="avatar-"]');
+              const avatarImg = avatarContainer?.querySelector('img');
+              const authorAvatar = avatarImg?.src || '';
+
+              // 去重
+              const key = `${authorName}||${content}`;
+              if (seenContents.has(key)) return;
+              seenContents.add(key);
+
+              // 点赞数
+              let likeCount = 0;
+              const opsEl = container.querySelector('[class*="operations-"]');
+              if (opsEl) {
+                const itemEl = opsEl.querySelector('[class*="item-"]');
+                if (itemEl) {
+                  const match = itemEl.textContent?.match(/(\d+)/);
+                  if (match) likeCount = parseInt(match[1], 10);
+                }
+              }
+
+              // 生成 ID
+              const hash = content.slice(0, 30) + authorName + commentTime;
+              const commentId = `dy_${btoa(encodeURIComponent(hash)).slice(0, 20)}`;
+
+              result.push({
+                commentId,
+                authorId: authorName,
+                authorName,
+                authorAvatar,
+                content,
+                likeCount,
+                commentTime,
+              });
+
+              console.log(`[${index}] ${authorName}: ${content.slice(0, 30)}`);
+            } catch (e) {
+              console.error(`Error at ${index}:`, e);
+            }
+          });
+
+          return result;
+        });
+
+        logger.info(`Extracted ${comments.length} comments`);
+        return comments;
+      };
+
+      // 辅助函数:获取当前显示的作品标题
+      const getCurrentWorkTitle = async (): Promise<string> => {
+        return page.evaluate(() => {
+          // 查找作品标题 - 通常在页面顶部区域
+          // 排除筛选器和按钮中的文本
+          const excludeTexts = ['全部评论', '最新发布', '全部人群', '搜索', '选择作品', '评论管理'];
+
+          // 方法1: 查找视频信息区域
+          const videoInfoSelectors = [
+            '[class*="video-info"] [class*="title"]',
+            '[class*="work-info"] [class*="title"]',
+            '[class*="content-info"] [class*="title"]',
+          ];
+
+          for (const selector of videoInfoSelectors) {
+            const el = document.querySelector(selector);
+            if (el?.textContent) {
+              const text = el.textContent.trim();
+              if (text.length > 5 && !excludeTexts.some(e => text.includes(e))) {
+                return text;
+              }
+            }
+          }
+
+          // 方法2: 查找页面上较长的标题文本
+          const allTexts = document.querySelectorAll('div, span, p');
+          for (const el of Array.from(allTexts)) {
+            const text = el.textContent?.trim() || '';
+            if (text.length > 20 &&
+              text.length < 200 &&
+              !excludeTexts.some(e => text.includes(e)) &&
+              !el.closest('button') &&
+              !el.closest('[class*="select"]') &&
+              !el.closest('[class*="filter"]')) {
+              // 检查是否可能是作品标题(通常包含特定字符或格式)
+              if (text.includes('#') || text.match(/[,。!?、]/)) {
+                return text;
+              }
+            }
+          }
+
+          return '';
+        });
+      };
+
+      // 步骤1: 先获取当前页面显示的评论(默认显示的第一个作品)
+      logger.info('Step 1: Getting comments from default view...');
+      const defaultTitle = await getCurrentWorkTitle();
+      const defaultComments = await extractCommentsFromCurrentPage();
+
+      if (defaultComments.length > 0) {
+        allWorkComments.push({
+          videoId: `video_${Date.now()}`,
+          videoTitle: defaultTitle || '默认作品',
+          videoCoverUrl: '',
+          comments: defaultComments,
+        });
+        logger.info(`Got ${defaultComments.length} comments from default view, title: "${defaultTitle.slice(0, 50)}"`);
+      }
+
+      // 步骤2: 尝试点击"选择作品"按钮获取更多作品的评论
+      logger.info('Step 2: Looking for "选择作品" button...');
+
+      // 使用 locator 查找按钮
+      const selectBtn = page.locator('text=选择作品').first();
+      const btnCount = await selectBtn.count();
+      logger.info(`Found ${btnCount} "选择作品" button(s)`);
+
+      if (btnCount > 0) {
+        logger.info('Clicking "选择作品" button...');
+        await selectBtn.click();
+
+        // 等待更长时间,确保弹窗完全加载
+        logger.info('Waiting for work list modal to appear...');
+        await page.waitForTimeout(5000);
+
+        // 打印当前页面状态,帮助调试
+        const modalInfo = await page.evaluate(() => {
+          // 查找所有可能的弹窗元素
+          const modals = document.querySelectorAll('[class*="modal"], [class*="popup"], [class*="drawer"], [class*="dialog"], [role="dialog"]');
+          const modalClasses = Array.from(modals).map(m => m.className).slice(0, 5);
+
+          // 查找所有图片(作品封面)
+          const images = document.querySelectorAll('img[src*="douyinpic"]');
+
+          // 查找所有可能的卡片元素
+          const cards = document.querySelectorAll('[class*="card"], [class*="item"]');
+          const cardClasses = Array.from(cards).map(c => c.className).slice(0, 10);
+
+          return {
+            modalCount: modals.length,
+            modalClasses,
+            imageCount: images.length,
+            cardCount: cards.length,
+            cardClasses,
+          };
+        });
+
+        logger.info(`Modal debug: ${JSON.stringify(modalInfo)}`);
+
+        // 尝试多种选择器查找作品列表
+        const workSelectors = [
+          '[class*="video-card"]',
+          '[class*="work-item"]',
+          '[class*="content-item"]',
+          '[class*="modal"] [class*="card"]',
+          '[class*="modal"] img',
+          '[class*="drawer"] [class*="card"]',
+          '[class*="drawer"] img',
+          '[role="dialog"] [class*="card"]',
+          '[role="dialog"] img',
+          '[class*="popup"] img',
+          'img[src*="douyinpic"]', // 直接找抖音图片
+        ];
+
+        let workElements: Awaited<ReturnType<typeof page.$$>> = [];
+        let usedSelector = '';
+
+        for (const selector of workSelectors) {
+          const elements = await page.$$(selector);
+          logger.info(`Selector "${selector}" found ${elements.length} elements`);
+          if (elements.length > 0 && elements.length < 50) { // 避免选中太多无关元素
+            workElements = elements;
+            usedSelector = selector;
+            break;
+          }
+        }
+
+        logger.info(`Using selector "${usedSelector}", found ${workElements.length} work items`);
+
+        if (workElements.length > 0) {
+          // 首先获取所有作品的评论数信息
+          // 根据 HTML 结构:
+          // - 作品项容器: div.container-Lkxos9 (类名可能变化,使用 [class*="container-"])
+          // - 标题: div.title-LUOP3b (类名可能变化,使用 [class*="title-"])
+          // - 评论数: div.right-os7ZB9 > div (类名可能变化,使用 [class*="right-"] > div)
+          const workInfoList = await page.evaluate(() => {
+            const works: Array<{ index: number; title: string; commentCount: number }> = [];
+
+            // 查找作品列表容器中的所有作品项
+            // 根据用户提供的 HTML,作品项的类名是 container-Lkxos9
+            const workContainers = document.querySelectorAll('[role="dialog"] [class*="container-"]');
+
+            console.log(`Found ${workContainers.length} work containers`);
+
+            workContainers.forEach((container, index) => {
+              // 检查是否包含图片(确认是作品项而不是其他容器)
+              const img = container.querySelector('img[src*="douyinpic"]');
+              if (!img) {
+                console.log(`Container ${index} has no douyinpic image, skipping`);
+                return;
+              }
+
+              // 提取标题
+              const titleEl = container.querySelector('[class*="title-"]');
+              const title = titleEl?.textContent?.trim() || `作品 ${works.length + 1}`;
+
+              // 提取评论数 - 在 right- 容器的最后一个 div 中
+              let commentCount = 0;
+              const rightContainer = container.querySelector('[class*="right-"]');
+              if (rightContainer) {
+                // 获取 right 容器下的所有直接 div 子元素
+                const divs = rightContainer.querySelectorAll(':scope > div');
+                if (divs.length > 0) {
+                  // 最后一个 div 包含评论数
+                  const lastDiv = divs[divs.length - 1];
+                  const text = lastDiv.textContent?.trim() || '0';
+                  const num = parseInt(text, 10);
+                  if (!isNaN(num)) {
+                    commentCount = num;
+                  }
+                }
+              }
+
+              console.log(`Work ${works.length}: title="${title.slice(0, 30)}...", commentCount=${commentCount}`);
+
+              works.push({
+                index: works.length,
+                title: title.slice(0, 100),
+                commentCount
+              });
+            });
+
+            // 如果上面的选择器没找到,尝试备用方法
+            if (works.length === 0) {
+              console.log('Primary selector failed, trying fallback...');
+              // 直接查找包含 douyinpic 图片的元素的父容器
+              const images = document.querySelectorAll('[role="dialog"] img[src*="douyinpic"]');
+              images.forEach((img, index) => {
+                // 向上查找到作品项容器
+                let container = img.parentElement;
+                while (container && !container.classList.toString().includes('container-')) {
+                  container = container.parentElement;
+                }
+
+                if (container) {
+                  const titleEl = container.querySelector('[class*="title-"]');
+                  const title = titleEl?.textContent?.trim() || `作品 ${index + 1}`;
+
+                  // 查找评论数
+                  let commentCount = 0;
+                  const rightEl = container.querySelector('[class*="right-"]');
+                  if (rightEl) {
+                    const text = rightEl.textContent?.trim() || '';
+                    // 提取最后出现的数字
+                    const matches = text.match(/\d+/g);
+                    if (matches && matches.length > 0) {
+                      commentCount = parseInt(matches[matches.length - 1], 10);
+                    }
+                  }
+
+                  works.push({ index, title: title.slice(0, 100), commentCount });
+                }
+              });
+            }
+
+            return works;
+          });
+
+          logger.info(`Work info list (${workInfoList.length} items): ${JSON.stringify(workInfoList)}`);
+
+          // 过滤出评论数 > 0 的作品,或者评论数未知(-1)的作品
+          const worksWithComments = workInfoList.filter(w => w.commentCount > 0 || w.commentCount === -1);
+          logger.info(`Found ${worksWithComments.length} works with comments > 0 or unknown (out of ${workInfoList.length})`);
+
+          // 如果所有作品评论数都是0,则不处理任何作品
+          const allZero = workInfoList.every(w => w.commentCount === 0);
+          if (allZero) {
+            logger.info('All works have 0 comments, skipping all');
+          }
+
+          // 如果没有找到评论数信息或有未知的,处理这些作品
+          const indicesToProcess = allZero
+            ? []
+            : (worksWithComments.length > 0
+              ? worksWithComments.map(w => w.index)
+              : Array.from({ length: Math.min(workElements.length, 10) }, (_, i) => i));
+
+          logger.info(`Will process work indices: ${indicesToProcess.join(', ')}`);
+
+          // 遍历每个有评论的作品
+          for (let idx = 0; idx < indicesToProcess.length; idx++) {
+            const i = indicesToProcess[idx];
+            try {
+              const workInfo = workInfoList.find(w => w.index === i);
+              logger.info(`Processing work ${idx + 1}/${indicesToProcess.length} (index=${i}, title="${workInfo?.title}", expectedComments=${workInfo?.commentCount})...`);
+
+              // 重新打开选择作品弹窗
+              if (idx > 0) {
+                await selectBtn.click();
+                await page.waitForTimeout(3000);
+              }
+
+              // 重新获取元素列表(因为 DOM 可能已变化)
+              const currentItems = await page.$$(usedSelector);
+              if (i < currentItems.length) {
+                // 滚动到元素可见
+                await currentItems[i].scrollIntoViewIfNeeded();
+                await page.waitForTimeout(500);
+
+                // 点击元素
+                await currentItems[i].click();
+                await page.waitForTimeout(4000);
+
+                // 获取评论
+                const title = await getCurrentWorkTitle();
+                const comments = await extractCommentsFromCurrentPage();
+
+                logger.info(`Work index=${i}: title="${title.slice(0, 50)}", comments=${comments.length}`);
+
+                // 检查是否已经获取过这个作品的评论
+                const exists = allWorkComments.some(w =>
+                  w.videoTitle === title ||
+                  (w.comments.length > 0 && comments.length > 0 &&
+                    w.comments[0].content === comments[0].content)
+                );
+
+                if (!exists && (comments.length > 0 || title)) {
+                  allWorkComments.push({
+                    videoId: `video_${Date.now()}_${i}`,
+                    videoTitle: title || workInfo?.title || `作品 ${i + 1}`,
+                    videoCoverUrl: '',
+                    comments,
+                  });
+                  logger.info(`Work index=${i}: Saved ${comments.length} comments`);
+                } else {
+                  logger.info(`Work index=${i}: Skipped (duplicate or empty)`);
+                }
+              }
+            } catch (err) {
+              logger.warn(`Error processing work index=${i}:`, err);
+            }
+          }
+        } else {
+          logger.warn('No work items found in modal');
+        }
+
+        // 按 Escape 关闭弹窗
+        try {
+          await page.keyboard.press('Escape');
+          await page.waitForTimeout(500);
+        } catch { }
+      } else {
+        logger.warn('"选择作品" button not found, only default comments will be returned');
+      }
+
+      await page.close();
+      await context.close();
+      await browser.close();
+
+      const totalComments = allWorkComments.reduce((sum, w) => sum + w.comments.length, 0);
+      logger.info(`Total: fetched ${totalComments} comments from ${allWorkComments.length} works`);
+
+      return allWorkComments;
+
+    } catch (error) {
+      logger.error('Error fetching Douyin comments:', error);
+      try {
+        await browser.close();
+      } catch { }
+      return allWorkComments;
+    }
+  }
 }
 
 export const headlessBrowserService = new HeadlessBrowserService();

+ 322 - 10
server/src/services/PublishService.ts

@@ -1,4 +1,4 @@
-import { AppDataSource, PublishTask, PublishResult } from '../models/index.js';
+import { AppDataSource, PublishTask, PublishResult, PlatformAccount } from '../models/index.js';
 import { AppError } from '../middleware/error.js';
 import { ERROR_CODES, HTTP_STATUS, WS_EVENTS } from '@media-manager/shared';
 import type {
@@ -6,8 +6,15 @@ import type {
   PublishTaskDetail,
   CreatePublishTaskRequest,
   PaginatedData,
+  PlatformType,
 } from '@media-manager/shared';
 import { wsManager } from '../websocket/index.js';
+import { DouyinAdapter } from '../automation/platforms/douyin.js';
+import { logger } from '../utils/logger.js';
+import path from 'path';
+import { config } from '../config/index.js';
+import { CookieManager } from '../automation/cookie.js';
+import { taskQueueService } from './TaskQueueService.js';
 
 interface GetTasksParams {
   page: number;
@@ -18,6 +25,26 @@ interface GetTasksParams {
 export class PublishService {
   private taskRepository = AppDataSource.getRepository(PublishTask);
   private resultRepository = AppDataSource.getRepository(PublishResult);
+  private accountRepository = AppDataSource.getRepository(PlatformAccount);
+  
+  // 平台适配器映射
+  private adapters: Map<PlatformType, DouyinAdapter> = new Map();
+  
+  constructor() {
+    // 初始化抖音适配器
+    this.adapters.set('douyin', new DouyinAdapter());
+  }
+  
+  /**
+   * 获取平台适配器
+   */
+  private getAdapter(platform: PlatformType) {
+    const adapter = this.adapters.get(platform);
+    if (!adapter) {
+      throw new AppError(`不支持的平台: ${platform}`, HTTP_STATUS.BAD_REQUEST, ERROR_CODES.VALIDATION);
+    }
+    return adapter;
+  }
 
   async getTasks(userId: number, params: GetTasksParams): Promise<PaginatedData<PublishTaskType>> {
     const { page, pageSize, status } = params;
@@ -68,7 +95,7 @@ export class PublishService {
       tags: data.tags || null,
       targetAccounts: data.targetAccounts,
       platformConfigs: data.platformConfigs || null,
-      status: data.scheduledAt ? 'pending' : 'processing',
+      status: 'pending', // 初始状态为 pending,任务队列执行时再更新为 processing
       scheduledAt: data.scheduledAt ? new Date(data.scheduledAt) : null,
     });
 
@@ -86,12 +113,277 @@ export class PublishService {
     // 通知客户端
     wsManager.sendToUser(userId, WS_EVENTS.TASK_CREATED, { task: this.formatTask(task) });
 
-    // TODO: 如果不是定时任务,立即开始发布
-    // 这里需要调用发布调度器
+    // 返回任务信息,发布任务将通过任务队列执行
+    // 调用者需要调用 taskQueueService.createTask 来创建队列任务
 
     return this.formatTask(task);
   }
-
+  
+  /**
+   * 带进度回调的发布任务执行
+   */
+  async executePublishTaskWithProgress(
+    taskId: number, 
+    userId: number,
+    onProgress?: (progress: number, message: string) => void
+  ): Promise<void> {
+    const task = await this.taskRepository.findOne({
+      where: { id: taskId },
+      relations: ['results'],
+    });
+    
+    if (!task) {
+      throw new Error(`Task ${taskId} not found`);
+    }
+    
+    // 更新任务状态为处理中
+    await this.taskRepository.update(taskId, { status: 'processing' });
+    wsManager.sendToUser(userId, WS_EVENTS.TASK_STATUS_CHANGED, {
+      taskId,
+      status: 'processing',
+    });
+    
+    const results = task.results || [];
+    let successCount = 0;
+    let failCount = 0;
+    const totalAccounts = results.length;
+    
+    // 构建视频文件的完整路径
+    let videoPath = task.videoPath || '';
+    
+    // 处理各种路径格式
+    if (videoPath) {
+      // 如果路径以 /uploads/ 开头,提取相对路径部分
+      if (videoPath.startsWith('/uploads/')) {
+        videoPath = path.join(config.upload.path, videoPath.replace('/uploads/', ''));
+      } 
+      // 如果是相对路径(不是绝对路径),拼接上传目录
+      else if (!path.isAbsolute(videoPath)) {
+        // 移除可能的重复 uploads 前缀
+        videoPath = videoPath.replace(/^uploads[\\\/]+uploads[\\\/]+/, '');
+        videoPath = videoPath.replace(/^uploads[\\\/]+/, '');
+        videoPath = path.join(config.upload.path, videoPath);
+      }
+    }
+    
+    logger.info(`Publishing video: ${videoPath}`);
+    onProgress?.(5, `准备发布到 ${totalAccounts} 个账号...`);
+    
+    // 遍历所有目标账号,逐个发布
+    for (let i = 0; i < results.length; i++) {
+      const result = results[i];
+      const accountProgress = Math.floor((i / totalAccounts) * 80) + 10;
+      
+      try {
+        // 获取账号信息
+        const account = await this.accountRepository.findOne({
+          where: { id: result.accountId },
+        });
+        
+        if (!account) {
+          logger.warn(`Account ${result.accountId} not found`);
+          await this.resultRepository.update(result.id, {
+            status: 'failed',
+            errorMessage: '账号不存在',
+          });
+          failCount++;
+          continue;
+        }
+        
+        if (!account.cookieData) {
+          logger.warn(`Account ${result.accountId} has no cookies`);
+          await this.resultRepository.update(result.id, {
+            status: 'failed',
+            errorMessage: '账号未登录',
+          });
+          failCount++;
+          continue;
+        }
+        
+        // 解密 Cookie
+        let decryptedCookies: string;
+        try {
+          decryptedCookies = CookieManager.decrypt(account.cookieData);
+        } catch {
+          // 如果解密失败,可能是未加密的 Cookie
+          decryptedCookies = account.cookieData;
+        }
+        
+        // 更新发布结果的平台信息
+        await this.resultRepository.update(result.id, {
+          platform: account.platform,
+        });
+        
+        // 获取适配器
+        const adapter = this.getAdapter(account.platform as PlatformType);
+        
+        logger.info(`Publishing to account ${account.accountName} (${account.platform})`);
+        onProgress?.(accountProgress, `正在发布到 ${account.accountName}...`);
+        
+        // 发送进度通知
+        wsManager.sendToUser(userId, WS_EVENTS.PUBLISH_PROGRESS, {
+          taskId,
+          accountId: account.id,
+          platform: account.platform,
+          status: 'uploading',
+          progress: 0,
+          message: '开始发布...',
+        });
+        
+        // 验证码处理回调(支持短信验证码和图形验证码)
+        const onCaptchaRequired = async (captchaInfo: { 
+          taskId: string; 
+          type: 'sms' | 'image';
+          phone?: string;
+          imageBase64?: string;
+        }): Promise<string> => {
+          return new Promise((resolve, reject) => {
+            const captchaTaskId = captchaInfo.taskId;
+            
+            // 发送验证码请求到前端
+            const message = captchaInfo.type === 'sms' 
+              ? '请输入短信验证码' 
+              : '请输入图片中的验证码';
+            
+            logger.info(`[Publish] Requesting ${captchaInfo.type} captcha, taskId: ${captchaTaskId}, phone: ${captchaInfo.phone}`);
+            wsManager.sendToUser(userId, WS_EVENTS.CAPTCHA_REQUIRED, {
+              taskId,
+              captchaTaskId,
+              type: captchaInfo.type,
+              phone: captchaInfo.phone || '',
+              imageBase64: captchaInfo.imageBase64 || '',
+              message,
+            });
+            
+            // 设置超时(2分钟)
+            const timeout = setTimeout(() => {
+              wsManager.removeCaptchaListener(captchaTaskId);
+              reject(new Error('验证码输入超时'));
+            }, 120000);
+            
+            // 注册验证码监听
+            wsManager.onCaptchaSubmit(captchaTaskId, (code: string) => {
+              clearTimeout(timeout);
+              wsManager.removeCaptchaListener(captchaTaskId);
+              logger.info(`[Publish] Received captcha code for ${captchaTaskId}`);
+              resolve(code);
+            });
+          });
+        };
+        
+        // 执行发布
+        const publishResult = await adapter.publishVideo(
+          decryptedCookies,
+          {
+            videoPath,
+            title: task.title || '',
+            description: task.description || undefined,
+            coverPath: task.coverPath || undefined,
+            tags: task.tags || undefined,
+          },
+          (progress, message) => {
+            // 发送进度更新
+            wsManager.sendToUser(userId, WS_EVENTS.PUBLISH_PROGRESS, {
+              taskId,
+              accountId: account.id,
+              platform: account.platform,
+              status: 'processing',
+              progress,
+              message,
+            });
+          },
+          onCaptchaRequired
+        );
+        
+        if (publishResult.success) {
+          await this.resultRepository.update(result.id, {
+            status: 'success',
+            videoUrl: publishResult.videoUrl || null,
+            platformVideoId: publishResult.platformVideoId || null,
+            publishedAt: new Date(),
+          });
+          successCount++;
+          
+          wsManager.sendToUser(userId, WS_EVENTS.PUBLISH_PROGRESS, {
+            taskId,
+            accountId: account.id,
+            platform: account.platform,
+            status: 'success',
+            progress: 100,
+            message: '发布成功',
+          });
+        } else {
+          await this.resultRepository.update(result.id, {
+            status: 'failed',
+            errorMessage: publishResult.errorMessage || '发布失败',
+          });
+          failCount++;
+          
+          wsManager.sendToUser(userId, WS_EVENTS.PUBLISH_PROGRESS, {
+            taskId,
+            accountId: account.id,
+            platform: account.platform,
+            status: 'failed',
+            progress: 0,
+            message: publishResult.errorMessage || '发布失败',
+          });
+        }
+        
+        // 每个账号发布后等待一段时间,避免过于频繁
+        await new Promise(resolve => setTimeout(resolve, 5000));
+        
+      } catch (error) {
+        logger.error(`Failed to publish to account ${result.accountId}:`, error);
+        await this.resultRepository.update(result.id, {
+          status: 'failed',
+          errorMessage: error instanceof Error ? error.message : '发布失败',
+        });
+        failCount++;
+      }
+    }
+    
+    // 更新任务状态
+    const finalStatus = failCount === 0 ? 'completed' : (successCount === 0 ? 'failed' : 'completed');
+    await this.taskRepository.update(taskId, {
+      status: finalStatus,
+      publishedAt: new Date(),
+    });
+    
+    wsManager.sendToUser(userId, WS_EVENTS.TASK_STATUS_CHANGED, {
+      taskId,
+      status: finalStatus,
+      successCount,
+      failCount,
+    });
+    
+    onProgress?.(100, `发布完成: ${successCount} 成功, ${failCount} 失败`);
+    logger.info(`Task ${taskId} completed: ${successCount} success, ${failCount} failed`);
+    
+    // 发布成功后,自动创建同步作品任务
+    if (successCount > 0) {
+      // 收集成功发布的账号ID
+      const successAccountIds = new Set<number>();
+      for (const result of results) {
+        if (result.status === 'success') {
+          successAccountIds.add(result.accountId);
+        }
+      }
+      
+      // 为每个成功的账号创建同步任务
+      for (const accountId of successAccountIds) {
+        const account = await this.accountRepository.findOne({ where: { id: accountId } });
+        if (account) {
+          taskQueueService.createTask(userId, {
+            type: 'sync_works',
+            title: `同步作品 - ${account.accountName || '账号'}`,
+            accountId: account.id,
+          });
+          logger.info(`Created sync_works task for account ${accountId} after publish`);
+        }
+      }
+    }
+  }
+  
   async cancelTask(userId: number, taskId: number): Promise<void> {
     const task = await this.taskRepository.findOne({
       where: { id: taskId, userId },
@@ -118,11 +410,12 @@ export class PublishService {
     if (!task) {
       throw new AppError('任务不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
     }
-    if (task.status !== 'failed') {
-      throw new AppError('只能重试失败的任务', HTTP_STATUS.BAD_REQUEST, ERROR_CODES.VALIDATION);
+    // 允许重试失败或卡住(processing)的任务
+    if (!['failed', 'processing'].includes(task.status)) {
+      throw new AppError('只能重试失败或卡住的任务', HTTP_STATUS.BAD_REQUEST, ERROR_CODES.VALIDATION);
     }
 
-    await this.taskRepository.update(taskId, { status: 'processing' });
+    await this.taskRepository.update(taskId, { status: 'pending' });
 
     // 重置失败的发布结果
     await this.resultRepository.update(
@@ -137,11 +430,30 @@ export class PublishService {
       status: 'processing',
     });
 
-    // TODO: 重新开始发布
-
+    // 返回任务信息,调用者需要通过任务队列重新执行
     return this.formatTask(updated!);
   }
 
+  async deleteTask(userId: number, taskId: number): Promise<void> {
+    const task = await this.taskRepository.findOne({
+      where: { id: taskId, userId },
+    });
+    if (!task) {
+      throw new AppError('任务不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
+    }
+    
+    // 不能删除正在执行的任务
+    if (task.status === 'processing') {
+      throw new AppError('不能删除正在执行的任务', HTTP_STATUS.BAD_REQUEST, ERROR_CODES.VALIDATION);
+    }
+
+    // 先删除关联的发布结果
+    await this.resultRepository.delete({ taskId });
+    
+    // 再删除任务
+    await this.taskRepository.delete(taskId);
+  }
+
   private formatTask(task: PublishTask): PublishTaskType {
     return {
       id: task.id,

+ 265 - 0
server/src/services/TaskQueueService.ts

@@ -0,0 +1,265 @@
+import { v4 as uuidv4 } from 'uuid';
+import { 
+  Task, 
+  TaskType, 
+  TaskStatus, 
+  TaskPriority, 
+  TaskResult,
+  TaskProgressUpdate,
+  CreateTaskRequest,
+  TASK_WS_EVENTS,
+} from '@media-manager/shared';
+import { wsManager } from '../websocket/index.js';
+import { logger } from '../utils/logger.js';
+
+// 任务执行器类型
+type TaskExecutor = (
+  task: Task, 
+  updateProgress: (update: Partial<TaskProgressUpdate>) => void
+) => Promise<TaskResult>;
+
+/**
+ * 全局异步任务队列服务
+ * 管理所有后台任务的创建、执行、进度追踪
+ */
+class TaskQueueService {
+  // 用户任务列表 Map<userId, Task[]>
+  private userTasks: Map<number, Task[]> = new Map();
+  
+  // 任务执行器 Map<TaskType, TaskExecutor>
+  private executors: Map<TaskType, TaskExecutor> = new Map();
+  
+  // 正在执行的任务数量限制(每用户)
+  private maxConcurrentTasks = 3;
+
+  /**
+   * 注册任务执行器
+   */
+  registerExecutor(type: TaskType, executor: TaskExecutor): void {
+    this.executors.set(type, executor);
+    logger.info(`Task executor registered: ${type}`);
+  }
+
+  /**
+   * 创建新任务
+   */
+  createTask(userId: number, request: CreateTaskRequest): Task & { userId: number } {
+    const task: Task & { userId: number; [key: string]: unknown } = {
+      id: uuidv4(),
+      type: request.type,
+      title: request.title || this.getDefaultTitle(request.type),
+      description: request.description,
+      status: 'pending',
+      progress: 0,
+      priority: request.priority || 'normal',
+      createdAt: new Date().toISOString(),
+      accountId: request.accountId,
+      userId, // 存储 userId 用于任务执行
+      // 合并额外数据
+      ...(request.data || {}),
+    };
+
+    // 添加到用户任务列表
+    if (!this.userTasks.has(userId)) {
+      this.userTasks.set(userId, []);
+    }
+    this.userTasks.get(userId)!.push(task);
+
+    // 通知前端任务已创建
+    this.notifyUser(userId, TASK_WS_EVENTS.TASK_CREATED, { task });
+
+    logger.info(`Task created: ${task.id} (${task.type}) for user ${userId}`);
+
+    // 尝试执行任务
+    this.tryExecuteNext(userId);
+
+    return task;
+  }
+
+  /**
+   * 获取用户的所有任务
+   */
+  getUserTasks(userId: number): Task[] {
+    return this.userTasks.get(userId) || [];
+  }
+
+  /**
+   * 获取用户的活跃任务(pending + running)
+   */
+  getActiveTasks(userId: number): Task[] {
+    const tasks = this.userTasks.get(userId) || [];
+    return tasks.filter(t => t.status === 'pending' || t.status === 'running');
+  }
+
+  /**
+   * 取消任务
+   */
+  cancelTask(userId: number, taskId: string): boolean {
+    const tasks = this.userTasks.get(userId);
+    if (!tasks) return false;
+
+    const task = tasks.find(t => t.id === taskId);
+    if (!task) return false;
+
+    if (task.status === 'pending') {
+      task.status = 'cancelled';
+      task.completedAt = new Date().toISOString();
+      this.notifyUser(userId, TASK_WS_EVENTS.TASK_CANCELLED, { task });
+      logger.info(`Task cancelled: ${taskId}`);
+      return true;
+    }
+
+    // 正在运行的任务暂不支持取消
+    return false;
+  }
+
+  /**
+   * 清理已完成的任务(保留最近N个)
+   */
+  cleanupCompletedTasks(userId: number, keepCount = 10): void {
+    const tasks = this.userTasks.get(userId);
+    if (!tasks) return;
+
+    const completedTasks = tasks.filter(t => 
+      t.status === 'completed' || t.status === 'failed' || t.status === 'cancelled'
+    );
+
+    if (completedTasks.length > keepCount) {
+      // 按完成时间排序,保留最新的
+      completedTasks.sort((a, b) => 
+        new Date(b.completedAt || 0).getTime() - new Date(a.completedAt || 0).getTime()
+      );
+      
+      const toRemove = completedTasks.slice(keepCount);
+      const toRemoveIds = new Set(toRemove.map(t => t.id));
+      
+      this.userTasks.set(userId, tasks.filter(t => !toRemoveIds.has(t.id)));
+    }
+  }
+
+  /**
+   * 尝试执行下一个任务
+   */
+  private async tryExecuteNext(userId: number): Promise<void> {
+    const tasks = this.userTasks.get(userId);
+    if (!tasks) return;
+
+    // 检查当前运行中的任务数量
+    const runningCount = tasks.filter(t => t.status === 'running').length;
+    if (runningCount >= this.maxConcurrentTasks) {
+      return;
+    }
+
+    // 找到下一个待执行的任务(按优先级排序)
+    const pendingTasks = tasks.filter(t => t.status === 'pending');
+    if (pendingTasks.length === 0) return;
+
+    // 按优先级排序
+    pendingTasks.sort((a, b) => {
+      const priorityOrder = { high: 0, normal: 1, low: 2 };
+      return priorityOrder[a.priority] - priorityOrder[b.priority];
+    });
+
+    const nextTask = pendingTasks[0];
+    await this.executeTask(userId, nextTask);
+  }
+
+  /**
+   * 执行任务
+   */
+  private async executeTask(userId: number, task: Task): Promise<void> {
+    const executor = this.executors.get(task.type);
+    if (!executor) {
+      logger.error(`No executor registered for task type: ${task.type}`);
+      task.status = 'failed';
+      task.error = `不支持的任务类型: ${task.type}`;
+      task.completedAt = new Date().toISOString();
+      this.notifyUser(userId, TASK_WS_EVENTS.TASK_FAILED, { task });
+      return;
+    }
+
+    // 更新任务状态为运行中
+    task.status = 'running';
+    task.startedAt = new Date().toISOString();
+    task.progress = 0;
+    this.notifyUser(userId, TASK_WS_EVENTS.TASK_STARTED, { task });
+
+    logger.info(`Task started: ${task.id} (${task.type})`);
+
+    // 进度更新回调
+    const updateProgress = (update: Partial<TaskProgressUpdate>) => {
+      if (update.progress !== undefined) task.progress = update.progress;
+      if (update.currentStep !== undefined) task.currentStep = update.currentStep;
+      if (update.currentStepIndex !== undefined) task.currentStepIndex = update.currentStepIndex;
+      
+      this.notifyUser(userId, TASK_WS_EVENTS.TASK_PROGRESS, {
+        taskId: task.id,
+        progress: task.progress,
+        currentStep: task.currentStep,
+        currentStepIndex: task.currentStepIndex,
+        message: update.message,
+      });
+    };
+
+    try {
+      const result = await executor(task, updateProgress);
+      
+      task.status = 'completed';
+      task.progress = 100;
+      task.result = result;
+      task.completedAt = new Date().toISOString();
+      
+      this.notifyUser(userId, TASK_WS_EVENTS.TASK_COMPLETED, { task });
+      logger.info(`Task completed: ${task.id}, result: ${result.message}`);
+    } catch (error) {
+      task.status = 'failed';
+      task.error = error instanceof Error ? error.message : '任务执行失败';
+      task.completedAt = new Date().toISOString();
+      
+      this.notifyUser(userId, TASK_WS_EVENTS.TASK_FAILED, { task });
+      logger.error(`Task failed: ${task.id}`, error);
+    }
+
+    // 清理旧任务并尝试执行下一个
+    this.cleanupCompletedTasks(userId);
+    this.tryExecuteNext(userId);
+  }
+
+  /**
+   * 通知用户
+   */
+  private notifyUser(userId: number, event: string, data: Record<string, unknown>): void {
+    wsManager.sendToUser(userId, event, {
+      event: event.split(':')[1], // 提取事件名
+      ...data,
+    });
+  }
+
+  /**
+   * 获取默认任务标题
+   */
+  private getDefaultTitle(type: TaskType): string {
+    const titles: Record<TaskType, string> = {
+      sync_comments: '同步评论',
+      sync_works: '同步作品',
+      sync_account: '同步账号信息',
+      publish_video: '发布视频',
+      batch_reply: '批量回复评论',
+    };
+    return titles[type] || '未知任务';
+  }
+
+  /**
+   * 发送任务列表给用户
+   */
+  sendTaskList(userId: number): void {
+    const tasks = this.getUserTasks(userId);
+    wsManager.sendToUser(userId, TASK_WS_EVENTS.TASK_LIST, {
+      event: 'list',
+      tasks,
+    });
+  }
+}
+
+// 导出单例
+export const taskQueueService = new TaskQueueService();

+ 117 - 8
server/src/services/WorkService.ts

@@ -1,4 +1,4 @@
-import { AppDataSource, Work, PlatformAccount } from '../models/index.js';
+import { AppDataSource, Work, PlatformAccount, Comment } from '../models/index.js';
 import { AppError } from '../middleware/error.js';
 import { ERROR_CODES, HTTP_STATUS } from '@media-manager/shared';
 import type { PlatformType, Work as WorkType, WorkStats, WorksQueryParams } from '@media-manager/shared';
@@ -87,7 +87,7 @@ export class WorkService {
   /**
    * 同步账号的作品
    */
-  async syncWorks(userId: number, accountId?: number): Promise<void> {
+  async syncWorks(userId: number, accountId?: number): Promise<{ synced: number; accounts: number }> {
     const queryBuilder = this.accountRepository
       .createQueryBuilder('account')
       .where('account.userId = :userId', { userId })
@@ -98,23 +98,29 @@ export class WorkService {
     }
 
     const accounts = await queryBuilder.getMany();
+    let totalSynced = 0;
+    let accountCount = 0;
 
     for (const account of accounts) {
       try {
-        await this.syncAccountWorks(userId, account);
+        const synced = await this.syncAccountWorks(userId, account);
+        totalSynced += synced;
+        accountCount++;
       } catch (error) {
         logger.error(`Failed to sync works for account ${account.id}:`, error);
       }
     }
+
+    return { synced: totalSynced, accounts: accountCount };
   }
 
   /**
    * 同步单个账号的作品
    */
-  private async syncAccountWorks(userId: number, account: PlatformAccount): Promise<void> {
+  private async syncAccountWorks(userId: number, account: PlatformAccount): Promise<number> {
     if (!account.cookieData) {
       logger.warn(`Account ${account.id} has no cookie data`);
-      return;
+      return 0;
     }
 
     // 解密 Cookie
@@ -131,20 +137,26 @@ export class WorkService {
       cookieList = JSON.parse(decryptedCookies);
     } catch {
       logger.error(`Invalid cookie format for account ${account.id}`);
-      return;
+      return 0;
     }
 
     // 获取作品列表
     const platform = account.platform as PlatformType;
     const accountInfo = await headlessBrowserService.fetchAccountInfo(platform, cookieList);
 
+    let syncedCount = 0;
+    
+    // 收集远程作品的 platformVideoId
+    const remotePlatformVideoIds = new Set<string>();
+
     if (accountInfo.worksList && accountInfo.worksList.length > 0) {
       for (const workItem of accountInfo.worksList) {
         // 生成一个唯一的视频ID
         const platformVideoId = workItem.videoId || `${platform}_${workItem.title}_${workItem.publishTime}`.substring(0, 100);
+        remotePlatformVideoIds.add(platformVideoId);
 
         // 查找是否已存在
-        let existingWork = await this.workRepository.findOne({
+        const existingWork = await this.workRepository.findOne({
           where: { accountId: account.id, platformVideoId },
         });
 
@@ -180,10 +192,34 @@ export class WorkService {
 
           await this.workRepository.save(work);
         }
+        syncedCount++;
       }
 
-      logger.info(`Synced ${accountInfo.worksList.length} works for account ${account.id}`);
+      logger.info(`Synced ${syncedCount} works for account ${account.id}`);
     }
+
+    // 删除本地存在但远程已删除的作品
+    const localWorks = await this.workRepository.find({
+      where: { accountId: account.id },
+    });
+    
+    let deletedCount = 0;
+    for (const localWork of localWorks) {
+      if (!remotePlatformVideoIds.has(localWork.platformVideoId)) {
+        // 先删除关联的评论
+        await AppDataSource.getRepository(Comment).delete({ workId: localWork.id });
+        // 再删除作品
+        await this.workRepository.delete(localWork.id);
+        deletedCount++;
+        logger.info(`Deleted work ${localWork.id} (${localWork.title}) - no longer exists on platform`);
+      }
+    }
+    
+    if (deletedCount > 0) {
+      logger.info(`Deleted ${deletedCount} works that no longer exist on platform for account ${account.id}`);
+    }
+
+    return syncedCount;
   }
 
   /**
@@ -227,6 +263,79 @@ export class WorkService {
   }
 
   /**
+   * 删除本地作品记录
+   */
+  async deleteWork(userId: number, workId: number): Promise<void> {
+    const work = await this.workRepository.findOne({
+      where: { id: workId, userId },
+    });
+    
+    if (!work) {
+      throw new AppError('作品不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
+    }
+    
+    // 先删除关联的评论
+    await AppDataSource.getRepository(Comment).delete({ workId });
+    
+    // 删除作品
+    await this.workRepository.delete(workId);
+    
+    logger.info(`Deleted work ${workId} for user ${userId}`);
+  }
+
+  /**
+   * 删除平台上的作品
+   */
+  async deletePlatformWork(
+    userId: number, 
+    workId: number,
+    onCaptchaRequired?: (captchaInfo: { taskId: string }) => Promise<string>
+  ): Promise<{ success: boolean; errorMessage?: string }> {
+    const work = await this.workRepository.findOne({
+      where: { id: workId, userId },
+      relations: ['account'],
+    });
+    
+    if (!work) {
+      throw new AppError('作品不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
+    }
+    
+    const account = await this.accountRepository.findOne({
+      where: { id: work.accountId },
+    });
+    
+    if (!account || !account.cookieData) {
+      throw new AppError('账号不存在或未登录', HTTP_STATUS.BAD_REQUEST, ERROR_CODES.ACCOUNT_NOT_FOUND);
+    }
+    
+    // 解密 Cookie
+    let decryptedCookies: string;
+    try {
+      decryptedCookies = CookieManager.decrypt(account.cookieData);
+    } catch {
+      decryptedCookies = account.cookieData;
+    }
+    
+    // 根据平台调用对应的删除方法
+    if (account.platform === 'douyin') {
+      const { DouyinAdapter } = await import('../automation/platforms/douyin.js');
+      const adapter = new DouyinAdapter();
+      
+      const result = await adapter.deleteWork(decryptedCookies, work.platformVideoId, onCaptchaRequired);
+      
+      if (result.success) {
+        // 更新作品状态为已删除
+        await this.workRepository.update(workId, { status: 'deleted' });
+        logger.info(`Platform work ${workId} deleted successfully`);
+      }
+      
+      return result;
+    }
+    
+    return { success: false, errorMessage: '暂不支持该平台删除功能' };
+  }
+
+  /**
    * 格式化作品
    */
   private formatWork(work: Work): WorkType {

+ 193 - 0
server/src/services/taskExecutors.ts

@@ -0,0 +1,193 @@
+/**
+ * 任务执行器注册
+ * 在应用启动时注册所有任务类型的执行器
+ */
+import { Task, TaskResult, TaskProgressUpdate } from '@media-manager/shared';
+import { taskQueueService } from './TaskQueueService.js';
+import { CommentService } from './CommentService.js';
+import { WorkService } from './WorkService.js';
+import { AccountService } from './AccountService.js';
+import { PublishService } from './PublishService.js';
+import { logger } from '../utils/logger.js';
+
+// 创建服务实例
+const commentService = new CommentService();
+const workService = new WorkService();
+const accountService = new AccountService();
+const publishService = new PublishService();
+
+type ProgressUpdater = (update: Partial<TaskProgressUpdate>) => void;
+
+/**
+ * 同步评论任务执行器
+ */
+async function syncCommentsExecutor(task: Task, updateProgress: ProgressUpdater): Promise<TaskResult> {
+  updateProgress({ progress: 5, currentStep: '连接平台...' });
+
+  const onProgress = (current: number, total: number, workTitle: string) => {
+    const progress = total > 0 ? Math.min(90, Math.round((current / total) * 90) + 5) : 50;
+    updateProgress({
+      progress,
+      currentStep: `正在同步: ${workTitle || `作品 ${current}/${total}`}`,
+      currentStepIndex: current,
+    });
+  };
+
+  // 从任务中获取 userId(需要在创建任务时传入)
+  const userId = (task as Task & { userId?: number }).userId;
+  if (!userId) {
+    throw new Error('缺少用户ID');
+  }
+
+  const result = await commentService.syncComments(userId, task.accountId, onProgress);
+
+  updateProgress({ progress: 100, currentStep: '同步完成' });
+
+  return {
+    success: true,
+    message: `同步完成,共同步 ${result.synced} 条评论`,
+    data: {
+      syncedCount: result.synced,
+      accountCount: result.accounts,
+    },
+  };
+}
+
+/**
+ * 同步作品任务执行器
+ */
+async function syncWorksExecutor(task: Task, updateProgress: ProgressUpdater): Promise<TaskResult> {
+  updateProgress({ progress: 5, currentStep: '获取作品列表...' });
+
+  const userId = (task as Task & { userId?: number }).userId;
+  if (!userId) {
+    throw new Error('缺少用户ID');
+  }
+
+  const result = await workService.syncWorks(userId, task.accountId);
+
+  updateProgress({ progress: 100, currentStep: '同步完成' });
+
+  return {
+    success: true,
+    message: `同步完成,共同步 ${result.synced} 个作品`,
+    data: {
+      syncedCount: result.synced,
+      accountCount: result.accounts,
+    },
+  };
+}
+
+/**
+ * 同步账号信息任务执行器
+ */
+async function syncAccountExecutor(task: Task, updateProgress: ProgressUpdater): Promise<TaskResult> {
+  updateProgress({ progress: 10, currentStep: '获取账号信息...' });
+
+  const userId = (task as Task & { userId?: number }).userId;
+  if (!userId) {
+    throw new Error('缺少用户ID');
+  }
+
+  if (!task.accountId) {
+    throw new Error('缺少账号ID');
+  }
+
+  await accountService.refreshAccount(userId, task.accountId);
+
+  updateProgress({ progress: 100, currentStep: '同步完成' });
+
+  return {
+    success: true,
+    message: '账号信息已更新',
+  };
+}
+
+/**
+ * 发布视频任务执行器
+ */
+async function publishVideoExecutor(task: Task, updateProgress: ProgressUpdater): Promise<TaskResult> {
+  updateProgress({ progress: 5, currentStep: '准备发布...' });
+
+  const userId = (task as Task & { userId?: number }).userId;
+  if (!userId) {
+    throw new Error('缺少用户ID');
+  }
+
+  // 从任务数据中获取发布任务ID
+  const taskData = task as Task & { publishTaskId?: number };
+  if (!taskData.publishTaskId) {
+    throw new Error('缺少发布任务ID');
+  }
+
+  // 执行发布任务
+  await publishService.executePublishTaskWithProgress(
+    taskData.publishTaskId, 
+    userId,
+    (progress, message) => {
+      updateProgress({ progress, currentStep: message });
+    }
+  );
+
+  updateProgress({ progress: 100, currentStep: '发布完成' });
+
+  return {
+    success: true,
+    message: '视频发布任务已完成',
+  };
+}
+
+/**
+ * 删除平台作品任务执行器
+ */
+async function deleteWorkExecutor(task: Task, updateProgress: ProgressUpdater): Promise<TaskResult> {
+  updateProgress({ progress: 10, currentStep: '准备删除...' });
+
+  const userId = (task as Task & { userId?: number }).userId;
+  if (!userId) {
+    throw new Error('缺少用户ID');
+  }
+
+  const taskData = task as Task & { workId?: number };
+  if (!taskData.workId) {
+    throw new Error('缺少作品ID');
+  }
+
+  updateProgress({ progress: 30, currentStep: '连接平台删除作品...' });
+
+  // 执行平台删除
+  const result = await workService.deletePlatformWork(userId, taskData.workId);
+
+  if (result.success) {
+    updateProgress({ progress: 80, currentStep: '删除本地记录...' });
+    
+    // 平台删除成功后,删除本地记录
+    try {
+      await workService.deleteWork(userId, taskData.workId);
+      logger.info(`Local work ${taskData.workId} deleted after platform deletion`);
+    } catch (error) {
+      logger.warn(`Failed to delete local work ${taskData.workId}:`, error);
+      // 本地删除失败不影响整体结果
+    }
+  }
+
+  updateProgress({ progress: 100, currentStep: result.success ? '删除完成' : '删除失败' });
+
+  return {
+    success: result.success,
+    message: result.success ? '作品已从平台删除,本地记录已清理' : (result.errorMessage || '删除失败'),
+  };
+}
+
+/**
+ * 注册所有任务执行器
+ */
+export function registerTaskExecutors(): void {
+  taskQueueService.registerExecutor('sync_comments', syncCommentsExecutor);
+  taskQueueService.registerExecutor('sync_works', syncWorksExecutor);
+  taskQueueService.registerExecutor('sync_account', syncAccountExecutor);
+  taskQueueService.registerExecutor('publish_video', publishVideoExecutor);
+  taskQueueService.registerExecutor('delete_work', deleteWorkExecutor);
+  
+  logger.info('All task executors registered');
+}

+ 44 - 0
server/src/websocket/index.ts

@@ -14,6 +14,8 @@ interface AuthenticatedWebSocket extends WebSocket {
 class WebSocketManager {
   private wss: WebSocketServer | null = null;
   private clients: Map<number, Set<AuthenticatedWebSocket>> = new Map();
+  // 验证码回调监听器
+  private captchaListeners: Map<string, (code: string) => void> = new Map();
 
   setup(server: HttpServer): void {
     this.wss = new WebSocketServer({ server, path: '/ws' });
@@ -68,6 +70,9 @@ class WebSocketManager {
         case WS_EVENTS.PING:
           this.send(ws, { type: WS_EVENTS.PONG, timestamp: Date.now() });
           break;
+        case WS_EVENTS.CAPTCHA_SUBMIT:
+          this.handleCaptchaSubmit(message.payload);
+          break;
         default:
           logger.warn('Unknown WebSocket message type:', message.type);
       }
@@ -75,6 +80,25 @@ class WebSocketManager {
       logger.error('Failed to parse WebSocket message:', error);
     }
   }
+  
+  /**
+   * 处理验证码提交
+   */
+  private handleCaptchaSubmit(payload: { captchaTaskId: string; code: string }): void {
+    const { captchaTaskId, code } = payload || {};
+    if (!captchaTaskId || !code) {
+      logger.warn('[WS] Invalid captcha submit payload');
+      return;
+    }
+    
+    const listener = this.captchaListeners.get(captchaTaskId);
+    if (listener) {
+      logger.info(`[WS] Captcha submitted for task ${captchaTaskId}`);
+      listener(code);
+    } else {
+      logger.warn(`[WS] No listener for captcha task ${captchaTaskId}`);
+    }
+  }
 
   private handleAuth(ws: AuthenticatedWebSocket, token?: string): void {
     if (!token) {
@@ -128,11 +152,15 @@ class WebSocketManager {
     const userClients = this.clients.get(userId);
     if (userClients) {
       const message = JSON.stringify({ type, payload, timestamp: Date.now() });
+      logger.info(`[WS] Sending to user ${userId}: type=${type}, payload=${JSON.stringify(payload)}`);
+      logger.info(`[WS] Full message: ${message}`);
       userClients.forEach((ws) => {
         if (ws.readyState === WebSocket.OPEN) {
           ws.send(message);
         }
       });
+    } else {
+      logger.warn(`[WS] No clients for user ${userId}, message not sent: type=${type}`);
     }
   }
 
@@ -164,6 +192,22 @@ class WebSocketManager {
     const userClients = this.clients.get(userId);
     return userClients ? userClients.size > 0 : false;
   }
+  
+  /**
+   * 注册验证码提交监听
+   */
+  onCaptchaSubmit(captchaTaskId: string, callback: (code: string) => void): void {
+    this.captchaListeners.set(captchaTaskId, callback);
+    logger.info(`[WS] Registered captcha listener for ${captchaTaskId}`);
+  }
+  
+  /**
+   * 移除验证码监听
+   */
+  removeCaptchaListener(captchaTaskId: string): void {
+    this.captchaListeners.delete(captchaTaskId);
+    logger.info(`[WS] Removed captcha listener for ${captchaTaskId}`);
+  }
 }
 
 export const wsManager = new WebSocketManager();

+ 16 - 0
shared/src/constants/api.ts

@@ -43,6 +43,12 @@ export const API_PATHS = {
     DETAIL: (id: number) => `/api/publish/${id}`,
     CANCEL: (id: number) => `/api/publish/${id}/cancel`,
     RETRY: (id: number) => `/api/publish/${id}/retry`,
+    DELETE: (id: number) => `/api/publish/${id}`,
+  },
+  // 作品管理
+  WORKS: {
+    LIST: '/api/works',
+    DELETE_PLATFORM: (id: number) => `/api/works/${id}/delete-platform`,
   },
   // 评论管理
   COMMENTS: {
@@ -100,9 +106,19 @@ export const WS_EVENTS = {
   TASK_CREATED: 'task:created',
   TASK_STATUS_CHANGED: 'task:status_changed',
   TASK_PROGRESS: 'task:progress',
+  // 发布
+  PUBLISH_PROGRESS: 'publish:progress',
+  // 验证码
+  CAPTCHA_REQUIRED: 'captcha:required',
+  CAPTCHA_SUBMIT: 'captcha:submit',
+  CAPTCHA_RESULT: 'captcha:result',
   // 评论
   COMMENT_NEW: 'comment:new',
   COMMENT_REPLIED: 'comment:replied',
+  COMMENT_SYNCED: 'comment:synced',
+  COMMENT_SYNC_STARTED: 'comment:sync_started',
+  COMMENT_SYNC_PROGRESS: 'comment:sync_progress',
+  COMMENT_SYNC_FAILED: 'comment:sync_failed',
   // 数据
   ANALYTICS_UPDATED: 'analytics:updated',
   // 心跳

+ 1 - 0
shared/src/types/index.ts

@@ -6,3 +6,4 @@ export * from './comment.js';
 export * from './analytics.js';
 export * from './api.js';
 export * from './work.js';
+export * from './task.js';

+ 123 - 0
shared/src/types/task.ts

@@ -0,0 +1,123 @@
+/**
+ * 异步任务类型定义
+ */
+
+// 任务类型
+export type TaskType = 
+  | 'sync_comments'      // 同步评论
+  | 'sync_works'         // 同步作品
+  | 'sync_account'       // 同步账号信息
+  | 'publish_video'      // 发布视频
+  | 'batch_reply'        // 批量回复评论
+  | 'delete_work';       // 删除平台作品
+
+// 任务状态
+export type TaskStatus = 
+  | 'pending'      // 等待中
+  | 'running'      // 执行中
+  | 'completed'    // 已完成
+  | 'failed'       // 失败
+  | 'cancelled';   // 已取消
+
+// 任务优先级
+export type TaskPriority = 'low' | 'normal' | 'high';
+
+// 任务信息
+export interface Task {
+  id: string;
+  type: TaskType;
+  title: string;
+  description?: string;
+  status: TaskStatus;
+  progress: number;        // 0-100
+  currentStep?: string;    // 当前步骤描述
+  totalSteps?: number;     // 总步骤数
+  currentStepIndex?: number; // 当前步骤索引
+  priority: TaskPriority;
+  createdAt: string;
+  startedAt?: string;
+  completedAt?: string;
+  error?: string;
+  result?: TaskResult;
+  // 关联数据
+  accountId?: number;
+  accountName?: string;
+  platform?: string;
+}
+
+// 任务结果
+export interface TaskResult {
+  success: boolean;
+  message: string;
+  data?: Record<string, unknown>;
+}
+
+// 任务类型配置
+export const TASK_TYPE_CONFIG: Record<TaskType, { 
+  name: string; 
+  icon: string;
+  color: string;
+}> = {
+  sync_comments: {
+    name: '同步评论',
+    icon: 'ChatDotRound',
+    color: '#409eff',
+  },
+  sync_works: {
+    name: '同步作品',
+    icon: 'VideoCamera',
+    color: '#67c23a',
+  },
+  sync_account: {
+    name: '同步账号',
+    icon: 'User',
+    color: '#e6a23c',
+  },
+  publish_video: {
+    name: '发布视频',
+    icon: 'Upload',
+    color: '#f56c6c',
+  },
+  batch_reply: {
+    name: '批量回复',
+    icon: 'ChatLineSquare',
+    color: '#909399',
+  },
+  delete_work: {
+    name: '删除作品',
+    icon: 'Delete',
+    color: '#f56c6c',
+  },
+};
+
+// WebSocket 任务事件类型
+export const TASK_WS_EVENTS = {
+  // 任务生命周期
+  TASK_CREATED: 'task:created',
+  TASK_STARTED: 'task:started',
+  TASK_PROGRESS: 'task:progress',
+  TASK_COMPLETED: 'task:completed',
+  TASK_FAILED: 'task:failed',
+  TASK_CANCELLED: 'task:cancelled',
+  // 任务列表
+  TASK_LIST: 'task:list',
+} as const;
+
+// 任务进度更新
+export interface TaskProgressUpdate {
+  taskId: string;
+  progress: number;
+  currentStep?: string;
+  currentStepIndex?: number;
+  message?: string;
+}
+
+// 创建任务请求
+export interface CreateTaskRequest {
+  type: TaskType;
+  title?: string;
+  description?: string;
+  priority?: TaskPriority;
+  accountId?: number;
+  data?: Record<string, unknown>;
+}

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor