Sfoglia il codice sorgente

fix: 修复智媒通26个Bug(同步、数据展示、功能缺失)

ethanfly 5 giorni fa
parent
commit
bb50c62f72

+ 13 - 2
client/src/api/request.ts

@@ -176,15 +176,26 @@ request.interceptors.response.use(
       return Promise.reject(error);
     }
 
-    // 网络错误(无响应):不弹错误弹窗,仅在控制台记录,避免后端未就绪时频繁弹窗干扰用户
+    // 网络错误(无响应):使用轻量提示,避免频繁弹窗干扰用户
     if (!error.response && error.code === 'ERR_NETWORK') {
       console.warn('[Request] Network error (backend may be unavailable):', error.message, error.config?.url);
+      // #6077: 使用 warning 级别提示而非 error,减少干扰感
+      ElMessage.warning({
+        message: '网络连接失败,请检查后端服务是否启动',
+        grouping: true,
+        duration: 3000,
+      });
       return Promise.reject(error);
     }
 
-    // 请求超时:不弹错误弹窗,仅在控制台记录
+    // 请求超时:提示用户请求超时
     if (error.code === 'ECONNABORTED' && error.message?.includes('timeout')) {
       console.warn('[Request] Request timeout:', error.config?.url);
+      ElMessage.warning({
+        message: '请求超时,请稍后重试',
+        grouping: true,
+        duration: 3000,
+      });
       return Promise.reject(error);
     }
 

+ 14 - 5
client/src/components/TaskProgressDialog.vue

@@ -126,14 +126,17 @@ function handleClearCompleted() {
                 <el-tag v-if="task.platform" size="small" type="info" class="platform-tag">
                   {{ getPlatformName(task.platform) }}
                 </el-tag>
+                <el-tag v-else-if="task.accountName" size="small" type="info" class="platform-tag">
+                  {{ task.accountName }}
+                </el-tag>
               </div>
               <ElTag size="small" :type="getStatusConfig(task.status).type">
                 {{ getStatusConfig(task.status).text }}
               </ElTag>
             </div>
             <div class="task-progress">
-              <ElProgress 
-                :percentage="task.progress || 0" 
+              <ElProgress
+                :percentage="task.progress || 0"
                 :stroke-width="8"
                 :show-text="true"
               />
@@ -149,9 +152,9 @@ function handleClearCompleted() {
           <div class="task-group-title">
             等待中 ({{ groupedTasks.pending.length }})
           </div>
-          <div 
-            v-for="task in groupedTasks.pending" 
-            :key="task.id" 
+          <div
+            v-for="task in groupedTasks.pending"
+            :key="task.id"
             class="task-item pending"
           >
             <div class="task-header">
@@ -161,6 +164,9 @@ function handleClearCompleted() {
                 <el-tag v-if="task.platform" size="small" type="info" class="platform-tag">
                   {{ getPlatformName(task.platform) }}
                 </el-tag>
+                <el-tag v-else-if="task.accountName" size="small" type="info" class="platform-tag">
+                  {{ task.accountName }}
+                </el-tag>
               </div>
               <div class="task-actions">
                 <ElTag size="small" type="info">等待中</ElTag>
@@ -206,6 +212,9 @@ function handleClearCompleted() {
                 <el-tag v-if="task.platform" size="small" type="info" class="platform-tag">
                   {{ getPlatformName(task.platform) }}
                 </el-tag>
+                <el-tag v-else-if="task.accountName" size="small" type="info" class="platform-tag">
+                  {{ task.accountName }}
+                </el-tag>
               </div>
               <ElTag size="small" :type="getStatusConfig(task.status).type">
                 {{ getStatusConfig(task.status).text }}

+ 13 - 6
client/src/layouts/MainLayout.vue

@@ -235,7 +235,7 @@
       </div>
       
       <!-- 主内容区域 - 标签页内容 -->
-      <el-main class="main-content">
+      <el-main class="main-content" v-loading="pageLoading" element-loading-text="正在加载页面数据...">
         <div class="tab-contents">
           <!-- 页面类型标签页 -->
           <template v-for="tab in tabsStore.tabs" :key="tab.id">
@@ -424,25 +424,30 @@ function closeCloseMenu() {
 // 初始化任务队列 WebSocket
 onMounted(async () => {
   taskStore.connectWebSocket();
-  
+
   // 根据当前路由初始化标签页
   initTabsFromRoute();
-  
+
   // 监听点击事件关闭右键菜单
   document.addEventListener('click', closeContextMenu);
-  
+
   // 监听键盘快捷键
   document.addEventListener('keydown', handleKeydown);
-  
+
   // 初始化窗口状态
   if (window.electronAPI?.isMaximized) {
     isMaximized.value = await window.electronAPI.isMaximized();
   }
-  
+
   // 监听窗口最大化状态变化
   window.electronAPI?.onMaximizedChange?.((maximized: boolean) => {
     isMaximized.value = maximized;
   });
+
+  // #6078: 首页异步组件加载完成后关闭页面 loading
+  // 使用 nextTick + 短延迟确保异步组件渲染完成
+  await new Promise(resolve => setTimeout(resolve, 800));
+  pageLoading.value = false;
 });
 
 // 根据路由初始化标签页
@@ -493,6 +498,8 @@ onUnmounted(() => {
 
 const isCollapsed = ref(false);
 const unreadComments = ref(0);
+// #6078: 页面初始加载状态(首页 Dashboard 等异步组件加载完成前显示)
+const pageLoading = ref(true);
 
 // 当前激活的菜单路径
 const activeMenuPath = computed(() => {

+ 39 - 17
client/src/views/Accounts/index.vue

@@ -91,6 +91,9 @@
             <el-tag :type="getStatusType(row.status)">
               {{ getStatusText(row.status) }}
             </el-tag>
+            <el-tooltip v-if="!row.cookieData && row.status !== 'disabled'" content="Cookie 为空,请重新登录" placement="top">
+              <el-tag type="warning" size="small" style="margin-left: 4px; cursor: help;">无Cookie</el-tag>
+            </el-tooltip>
           </template>
         </el-table-column>
         
@@ -114,12 +117,12 @@
           </template>
         </el-table-column>
         
-        <el-table-column label="操作" width="240" fixed="right">
+        <el-table-column label="操作" width="280" fixed="right">
           <template #default="{ row }">
-            <el-button type="primary" link size="small" @click="openPlatformAdmin(row)">
+            <el-button type="primary" link size="small" @click="openPlatformAdmin(row)" :disabled="row.status === 'disabled'">
               后台
             </el-button>
-            <el-button type="primary" link size="small" @click="refreshAccount(row.id)">
+            <el-button type="primary" link size="small" @click="refreshAccount(row.id)" :disabled="row.status === 'disabled'">
               刷新
             </el-button>
             <el-button
@@ -533,8 +536,8 @@ async function loadAccounts() {
 }
 
 async function handleAddAccount() {
-  if (!addForm.platform || !addForm.cookieData) {
-    ElMessage.warning('请填写完整信息');
+  if (!addForm.platform || !addForm.cookieData?.trim()) {
+    ElMessage.warning('请填写完整信息(Cookie 不能为空)');
     return;
   }
   
@@ -565,7 +568,13 @@ async function refreshAccount(id: number) {
     ElMessage.error('账号不存在');
     return;
   }
-  
+
+  // #6084: Cookie 为空或已过期时提示用户重新登录
+  if (!account.cookieData && account.status === 'expired') {
+    ElMessage.warning('账号 Cookie 为空且已过期,请重新登录');
+    return;
+  }
+
   // 使用任务队列
   await taskStore.syncAccount(id, account.accountName);
   ElMessage.success('账号刷新任务已创建');
@@ -578,13 +587,25 @@ async function refreshAllAccounts() {
     ElMessage.warning('暂无账号');
     return;
   }
-  
-  // 为每个账号创建刷新任务
-  for (const account of accounts.value) {
+
+  // #6084: 检查是否有 cookie 为空且已过期的账号
+  const noCookieAccounts = accounts.value.filter(a => !a.cookieData && a.status === 'expired');
+  const validAccounts = accounts.value.filter(a => noCookieAccounts.every(n => n.id !== a.id));
+
+  if (validAccounts.length === 0) {
+    ElMessage.warning('所有账号 Cookie 均为空或已过期,请重新登录');
+    return;
+  }
+
+  // 为每个有效账号创建刷新任务
+  for (const account of validAccounts) {
     await taskStore.syncAccount(account.id, account.accountName);
   }
-  
-  ElMessage.success(`已创建 ${accounts.value.length} 个账号刷新任务`);
+
+  if (noCookieAccounts.length > 0) {
+    ElMessage.warning(`${noCookieAccounts.length} 个账号 Cookie 为空已跳过,请重新登录`);
+  }
+  ElMessage.success(`已创建 ${validAccounts.length} 个账号刷新任务`);
   taskStore.openDialog();
 }
 
@@ -766,14 +787,15 @@ async function handleAddGroup() {
     ElMessage.warning('请输入分组名称');
     return;
   }
-  
+
   groupSaving.value = true;
   try {
     await accountsApi.createGroup({ name: newGroupName.value.trim() });
     ElMessage.success('分组创建成功');
     newGroupName.value = '';
+    // #6030: 先刷新分组计数(获取全量数据),再刷新列表
+    await refreshGroupCounts();
     loadAccounts();
-    refreshGroupCounts();
   } catch {
     // 错误已处理
   } finally {
@@ -796,14 +818,14 @@ async function handleSaveGroup(groupId: number) {
     ElMessage.warning('分组名称不能为空');
     return;
   }
-  
+
   groupSaving.value = true;
   try {
     await accountsApi.updateGroup(groupId, { name: editingGroupName.value.trim() });
     ElMessage.success('分组更新成功');
     cancelEditGroup();
+    await refreshGroupCounts();
     loadAccounts();
-    refreshGroupCounts();
   } catch {
     // 错误已处理
   } finally {
@@ -817,15 +839,15 @@ async function handleDeleteGroup(group: AccountGroup) {
     ElMessage.warning(`该分组下还有 ${count} 个账号,请先移除账号`);
     return;
   }
-  
+
   try {
     await ElMessageBox.confirm(`确定要删除分组"${group.name}"吗?`, '提示', {
       type: 'warning',
     });
     await accountsApi.deleteGroup(group.id);
     ElMessage.success('分组删除成功');
+    await refreshGroupCounts();
     loadAccounts();
-    refreshGroupCounts();
   } catch {
     // 取消或错误
   }

+ 9 - 2
client/src/views/Dashboard/index.vue

@@ -122,6 +122,7 @@ import { User, VideoPlay, UserFilled, TrendCharts, Refresh } from '@element-plus
 import * as echarts from 'echarts';
 import { accountsApi } from '@/api/accounts';
 import { dashboardApi, type TrendData } from '@/api/dashboard';
+import request from '@/api/request';
 import { PLATFORMS } from '@media-manager/shared';
 import type { PlatformAccount, PublishTask, PlatformType } from '@media-manager/shared';
 import { useTabsStore } from '@/stores/tabs';
@@ -368,12 +369,18 @@ async function loadTrendData() {
 async function loadData() {
   try {
     // 并行获取所有数据
-    const [accountsData, worksStats] = await Promise.all([
+    const [accountsData, worksStats, tasksData] = await Promise.all([
       accountsApi.getAccounts(),
       dashboardApi.getWorksStats().catch(() => null),
+      request.get('/api/publish', { params: { page: 1, pageSize: 5 } }).catch(() => null),
     ]);
-    
+
     accounts.value = accountsData;
+
+    // 加载最近发布任务
+    if (tasksData) {
+      tasks.value = tasksData.items || [];
+    }
     
     // 更新统计数据
     stats.value[0].value = accounts.value.length;

+ 11 - 2
client/src/views/Profile/index.vue

@@ -15,7 +15,9 @@
               :show-file-list="false"
               @change="handleAvatarChange"
             >
-              <el-button type="primary" link>更换头像</el-button>
+              <el-button type="primary" link :loading="uploadingAvatar">
+                {{ uploadingAvatar ? '上传中...' : '更换头像' }}
+              </el-button>
             </el-upload>
           </div>
           
@@ -98,6 +100,7 @@ const activeTab = ref('info');
 const saving = ref(false);
 const changingPassword = ref(false);
 const passwordFormRef = ref<FormInstance>();
+const uploadingAvatar = ref(false);
 
 const profileForm = reactive({
   nickname: '',
@@ -160,6 +163,8 @@ async function handleAvatarChange(file: UploadFile) {
     return;
   }
 
+  uploadingAvatar.value = true;
+
   const formData = new FormData();
   formData.append('image', file.raw);
 
@@ -168,6 +173,7 @@ async function handleAvatarChange(file: UploadFile) {
   const baseUrl = serverStore.currentServer?.url;
   if (!baseUrl) {
     ElMessage.error('未配置服务器地址');
+    uploadingAvatar.value = false;
     return;
   }
 
@@ -193,7 +199,10 @@ async function handleAvatarChange(file: UploadFile) {
       ElMessage.success('头像更新成功');
     })
     .catch(() => {
-      ElMessage.error('头像上传失败');
+      ElMessage.error('头像上传失败,请检查网络连接');
+    })
+    .finally(() => {
+      uploadingAvatar.value = false;
     });
 }
 

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

@@ -925,6 +925,11 @@ async function handleCreate() {
     ElMessage.warning('所选平台要求视频必填');
     return;
   }
+  // 修复 #6069:补充图片类平台校验
+  if (createRequireImage.value && !createForm.videoFile) {
+    ElMessage.warning('所选平台要求图片或视频必填');
+    return;
+  }
 
   if (createForm.usePublishProxy && publishProxyRegions.value.length > 0 && !createForm.publishProxyRegionPath.length) {
     ElMessage.warning('请选择代理城市');
@@ -1158,6 +1163,13 @@ onMounted(() => {
   loadAccounts();
   loadSystemConfig();
 });
+
+// 修复 #6070:打开新建发布对话框时重新加载账号列表,确保新增/删除账号后数据同步
+watch(showCreateDialog, (visible) => {
+  if (visible) {
+    loadAccounts();
+  }
+});
 </script>
 
 <style lang="scss" scoped>

+ 5 - 5
client/src/views/Schedule/index.vue

@@ -82,11 +82,11 @@
         </el-form-item>
         <el-form-item label="重复方式">
           <el-radio-group v-model="form.repeatType">
-            <el-radio-button label="once">仅一次</el-radio-button>
-            <el-radio-button label="daily">每天</el-radio-button>
-            <el-radio-button label="weekday">工作日</el-radio-button>
-            <el-radio-button label="weekly">每周</el-radio-button>
-            <el-radio-button label="custom">自定义</el-radio-button>
+            <el-radio-button value="once">仅一次</el-radio-button>
+            <el-radio-button value="daily">每天</el-radio-button>
+            <el-radio-button value="weekday">工作日</el-radio-button>
+            <el-radio-button value="weekly">每周</el-radio-button>
+            <el-radio-button value="custom">自定义</el-radio-button>
           </el-radio-group>
         </el-form-item>
         <el-form-item v-if="form.repeatType === 'weekly'" label="周几">

+ 104 - 1
client/src/views/Settings/index.vue

@@ -144,6 +144,43 @@
             <el-button type="primary" @click="handleAddUser" :loading="addingUser">确定</el-button>
           </template>
         </el-dialog>
+
+        <!-- 编辑用户对话框 -->
+        <el-dialog
+          v-model="showEditUserDialog"
+          title="编辑用户"
+          width="480px"
+          @closed="resetEditUserForm"
+        >
+          <el-form :model="editUserForm" :rules="editUserFormRules" ref="editUserFormRef" label-width="80px">
+            <el-form-item label="用户名">
+              <el-input :model-value="editUserForm.username" disabled />
+            </el-form-item>
+            <el-form-item label="新密码">
+              <el-input v-model="editUserForm.password" type="password" show-password placeholder="留空则不修改" />
+            </el-form-item>
+            <el-form-item label="邮箱" prop="email">
+              <el-input v-model="editUserForm.email" placeholder="请输入邮箱" />
+            </el-form-item>
+            <el-form-item label="角色" prop="role">
+              <el-select v-model="editUserForm.role" style="width: 100%">
+                <el-option label="运营" value="operator" />
+                <el-option label="编辑" value="editor" />
+                <el-option label="管理员" value="admin" />
+              </el-select>
+            </el-form-item>
+            <el-form-item label="状态" prop="status">
+              <el-select v-model="editUserForm.status" style="width: 100%">
+                <el-option label="正常" value="active" />
+                <el-option label="禁用" value="disabled" />
+              </el-select>
+            </el-form-item>
+          </el-form>
+          <template #footer>
+            <el-button @click="showEditUserDialog = false">取消</el-button>
+            <el-button type="primary" @click="handleSaveEditUser" :loading="editingUser">保存</el-button>
+          </template>
+        </el-dialog>
       </el-tab-pane>
       
       <el-tab-pane label="系统状态" name="status">
@@ -201,8 +238,11 @@ const pythonCheckResult = ref<any | null>(null);
 
 const users = ref<User[]>([]);
 const showAddUserDialog = ref(false);
+const showEditUserDialog = ref(false);
 const addingUser = ref(false);
+const editingUser = ref(false);
 const userFormRef = ref<FormInstance>();
+const editUserFormRef = ref<FormInstance>();
 
 const userForm = reactive({
   username: '',
@@ -211,6 +251,15 @@ const userForm = reactive({
   role: 'operator',
 });
 
+const editUserForm = reactive({
+  id: 0,
+  username: '',
+  password: '',
+  email: '',
+  role: 'operator',
+  status: 'active' as string,
+});
+
 const userFormRules: FormRules = {
   username: [
     { required: true, message: '请输入用户名', trigger: 'blur' },
@@ -228,6 +277,18 @@ const userFormRules: FormRules = {
   ],
 };
 
+const editUserFormRules: FormRules = {
+  email: [
+    { type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' },
+  ],
+  role: [
+    { required: true, message: '请选择角色', trigger: 'change' },
+  ],
+  status: [
+    { required: true, message: '请选择状态', trigger: 'change' },
+  ],
+};
+
 function resetUserForm() {
   userForm.username = '';
   userForm.password = '';
@@ -391,7 +452,49 @@ async function loadSystemStatus() {
 }
 
 function editUser(user: User) {
-  ElMessage.info('编辑功能开发中');
+  editUserForm.id = user.id;
+  editUserForm.username = user.username;
+  editUserForm.password = '';
+  editUserForm.email = user.email || '';
+  editUserForm.role = user.role || 'operator';
+  editUserForm.status = user.status || 'active';
+  showEditUserDialog.value = true;
+}
+
+function resetEditUserForm() {
+  editUserForm.id = 0;
+  editUserForm.username = '';
+  editUserForm.password = '';
+  editUserForm.email = '';
+  editUserForm.role = 'operator';
+  editUserForm.status = 'active';
+  editUserFormRef.value?.resetFields();
+}
+
+async function handleSaveEditUser() {
+  if (!editUserFormRef.value) return;
+  const valid = await editUserFormRef.value.validate().catch(() => false);
+  if (!valid) return;
+
+  editingUser.value = true;
+  try {
+    const data: Record<string, unknown> = {
+      email: editUserForm.email || undefined,
+      role: editUserForm.role,
+      status: editUserForm.status,
+    };
+    if (editUserForm.password) {
+      data.password = editUserForm.password;
+    }
+    await request.put(`/api/users/${editUserForm.id}`, data);
+    ElMessage.success('用户信息已更新');
+    showEditUserDialog.value = false;
+    loadUsers();
+  } catch {
+    // 错误已由拦截器处理
+  } finally {
+    editingUser.value = false;
+  }
 }
 
 async function deleteUser(id: number) {

+ 20 - 4
client/src/views/Works/index.vue

@@ -345,8 +345,11 @@
         </template>
       </div>
       
-      <template #footer v-if="syncState.status !== 'syncing'">
-        <el-button type="primary" @click="closeSyncDialog">确定</el-button>
+      <template #footer>
+        <el-button v-if="syncState.status === 'syncing'" type="danger" @click="stopCommentSync">
+          中断同步
+        </el-button>
+        <el-button v-else type="primary" @click="closeSyncDialog">确定</el-button>
       </template>
     </el-dialog>
   </div>
@@ -544,8 +547,8 @@ async function loadWorks() {
   } finally {
     loading.value = false;
   }
-  // 同步更新统计数据(应用当前筛选条件)
-  loadStats();
+  // 修复 #6074:同步更新统计数据,避免快速切换筛选条件时 loadStats 结果与作品列表不一致
+  await loadStats();
 }
 
 async function loadStats() {
@@ -809,6 +812,19 @@ function closeSyncDialog() {
   stopSyncAnimation();
 }
 
+// #6072: 中断评论同步
+function stopCommentSync() {
+  stopSyncAnimation();
+  if (syncTimeoutTimer) {
+    clearTimeout(syncTimeoutTimer);
+    syncTimeoutTimer = null;
+  }
+  syncState.status = 'failed';
+  syncState.error = '用户手动中断同步';
+  syncingComments.value = false;
+  ElMessage.info('已中断评论同步');
+}
+
 function viewAllComments() {
   closeSyncDialog();
   commentsWork.value = null; // 不筛选特定作品

+ 25 - 29
server/src/automation/platforms/baijiahao.ts

@@ -333,39 +333,35 @@ export class BaijiahaoAdapter extends BasePlatformAdapter {
     onCaptchaRequired?: (captchaInfo: { taskId: string; type: 'sms' | 'image'; phone?: string; imageBase64?: string }) => Promise<string>,
     options?: { headless?: boolean }
   ): Promise<PublishResult> {
-    // 只使用 Python 服务发布
+    // #6035: 优先使用 Python 服务发布,不可用时回退到 Playwright
     const pythonAvailable = await this.checkPythonServiceAvailable();
-    if (!pythonAvailable) {
-      logger.error('[Baijiahao] Python service not available');
-      return {
-        success: false,
-        errorMessage: 'Python 发布服务不可用,请确保 Python 服务已启动',
-      };
-    }
+    if (pythonAvailable) {
+      logger.info('[Baijiahao] Using Python service for publishing');
+      try {
+        const result = await this.publishVideoViaPython(cookies, params, onProgress);
 
-    logger.info('[Baijiahao] Using Python service for publishing');
-    try {
-      const result = await this.publishVideoViaPython(cookies, params, onProgress);
-      
-      // 检查是否需要验证码
-      if (!result.success && result.errorMessage?.includes('验证码')) {
-        logger.info('[Baijiahao] Python detected captcha, need headful browser');
-        return {
-          success: false,
-          errorMessage: `CAPTCHA_REQUIRED:${result.errorMessage}`,
-        };
+        // 检查是否需要验证码
+        if (!result.success && result.errorMessage?.includes('验证码')) {
+          logger.info('[Baijiahao] Python detected captcha, need headful browser');
+          return {
+            success: false,
+            errorMessage: `CAPTCHA_REQUIRED:${result.errorMessage}`,
+          };
+        }
+
+        if (result.success) {
+          return result;
+        }
+
+        logger.warn(`[Baijiahao] Python publish failed: ${result.errorMessage}, falling back to Playwright`);
+      } catch (pythonError) {
+        logger.warn('[Baijiahao] Python publish failed, falling back to Playwright:', pythonError);
       }
-      
-      return result;
-    } catch (pythonError) {
-      logger.error('[Baijiahao] Python publish failed:', pythonError);
-      return {
-        success: false,
-        errorMessage: pythonError instanceof Error ? pythonError.message : '发布失败',
-      };
+    } else {
+      logger.warn('[Baijiahao] Python service not available, using Playwright fallback');
     }
 
-    /* ========== Playwright 方式已注释,只使用 Python API ==========
+    // #6035: Playwright 备用方案(Python 不可用时使用)
     const useHeadless = options?.headless ?? true;
 
     try {
@@ -774,7 +770,7 @@ export class BaijiahaoAdapter extends BasePlatformAdapter {
         errorMessage: error instanceof Error ? error.message : '发布失败',
       };
     }
-    ========== Playwright 方式已注释结束 ========== */
+    // Playwright 备用方案结束
   }
   
   async getComments(cookies: string, videoId: string): Promise<CommentData[]> {

+ 27 - 36
server/src/automation/platforms/douyin.ts

@@ -1068,7 +1068,7 @@ export class DouyinAdapter extends BasePlatformAdapter {
   /**
    * 发布视频
    * 参考 https://github.com/kebenxiaoming/matrix 项目实现
-   * 只使用 Python 服务发布,如果检测到验证码返回错误让前端用有头浏览器重试
+   * 优先使用 Python 服务发布,不可用时回退到 Playwright,检测到验证码返回错误让前端用有头浏览器重试
    * @param onCaptchaRequired 验证码回调,返回用户输入的验证码
    * @param options.headless 是否使用无头模式,默认 true
    */
@@ -1079,47 +1079,38 @@ export class DouyinAdapter extends BasePlatformAdapter {
     onCaptchaRequired?: (captchaInfo: { taskId: string; phone?: string }) => Promise<string>,
     options?: { headless?: boolean }
   ): Promise<PublishResult> {
-    // 只使用 Python 服务发布,不可用时提示用户使用有头浏览器重试
+    // #6067: 优先使用 Python 服务发布,不可用时回退到 Playwright
     const pythonAvailable = await this.checkPythonServiceAvailable();
-    if (!pythonAvailable) {
-      logger.error('[Douyin] Python service not available');
-      return {
-        success: false,
-        errorMessage: 'CAPTCHA_REQUIRED:Python 发布服务不可用,请确保 Python 服务已启动后重试,或使用有头浏览器重新发布',
-      };
-    }
+    if (pythonAvailable) {
+      logger.info('[Douyin] Using Python service for publishing');
+      try {
+        const pythonResult = await this.publishVideoViaPython(cookies, params, onProgress);
 
-    logger.info('[Douyin] Using Python service for publishing');
-    try {
-      const pythonResult = await this.publishVideoViaPython(cookies, params, onProgress);
+        // 检查是否需要验证码 - 返回错误让前端用有头浏览器重试
+        if (pythonResult.needCaptcha) {
+          logger.info(`[Douyin] Python detected captcha (${pythonResult.captchaType}), need headful browser`);
+          onProgress?.(0, `检测到${pythonResult.captchaType}验证码,请使用有头浏览器重试...`);
+          return {
+            success: false,
+            errorMessage: `CAPTCHA_REQUIRED:检测到${pythonResult.captchaType}验证码,需要使用有头浏览器完成验证`,
+          };
+        }
 
-      // 检查是否需要验证码 - 返回错误让前端用有头浏览器重试
-      if (pythonResult.needCaptcha) {
-        logger.info(`[Douyin] Python detected captcha (${pythonResult.captchaType}), need headful browser`);
-        onProgress?.(0, `检测到${pythonResult.captchaType}验证码,请使用有头浏览器重试...`);
-        return {
-          success: false,
-          errorMessage: `CAPTCHA_REQUIRED:检测到${pythonResult.captchaType}验证码,需要使用有头浏览器完成验证`,
-        };
-      }
+        if (pythonResult.success) {
+          return pythonResult;
+        }
 
-      if (pythonResult.success) {
-        return pythonResult;
+        logger.warn(`[Douyin] Python publish failed: ${pythonResult.errorMessage}, falling back to Playwright`);
+      } catch (pythonError) {
+        logger.warn('[Douyin] Python publish failed, falling back to Playwright:', pythonError);
       }
-
-      return {
-        success: false,
-        errorMessage: pythonResult.errorMessage || '发布失败',
-      };
-    } catch (pythonError) {
-      logger.error('[Douyin] Python publish failed:', pythonError);
-      return {
-        success: false,
-        errorMessage: pythonError instanceof Error ? pythonError.message : '发布失败',
-      };
+    } else {
+      logger.warn('[Douyin] Python service not available, using Playwright fallback');
     }
 
-    /* ========== Playwright 方式已注释,只使用 Python API ==========
+    // #6067: Playwright 备用方案(Python 不可用时使用)
+    const useHeadless = options?.headless !== false;
+    const { aiService } = await import('../../ai/index.js');
     try {
       await this.initBrowser({ headless: useHeadless });
       await this.setCookies(cookies);
@@ -1757,7 +1748,7 @@ export class DouyinAdapter extends BasePlatformAdapter {
         errorMessage: error instanceof Error ? error.message : '发布失败',
       };
     }
-    ========== Playwright 方式已注释结束 ========== */
+    // Playwright 备用方案结束
   }
 
   /**

+ 4 - 2
server/src/models/entities/UserDayStatistics.ts

@@ -57,11 +57,13 @@ export class UserDayStatistics {
   @Column({ name: 'completion_rate', type: 'varchar', length: 50, default: '0', comment: '视频完播率' })
   completionRate!: string;
 
-  @Column({ type: 'timestamp', name: 'created_at', default: () => 'CURRENT_TIMESTAMP' })
+  // 修复 #6081:使用 datetime 而非 timestamp,与数据库实际列类型一致
+  // TIMESTAMP 会做时区转换导致日期解析异常(如显示为 2001 年)
+  @Column({ type: 'datetime', name: 'created_at', default: () => 'CURRENT_TIMESTAMP' })
   createdAt!: Date;
 
   @Column({
-    type: 'timestamp',
+    type: 'datetime',
     name: 'updated_at',
     default: () => 'CURRENT_TIMESTAMP',
     onUpdate: 'CURRENT_TIMESTAMP',

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

@@ -45,8 +45,9 @@ router.get(
 router.post(
   '/',
   [
-    body('videoPath').notEmpty().withMessage('视频路径不能为空'),
-    body('title').notEmpty().withMessage('标题不能为空'),
+    // 修复 #6069:videoPath 和 title 改为可选,具体校验由前端按平台要求完成
+    body('videoPath').optional({ nullable: true }).isString().withMessage('视频路径格式无效'),
+    body('title').optional({ nullable: true }).isString().withMessage('标题格式无效'),
     body('targetAccounts').isArray({ min: 1 }).withMessage('至少选择一个目标账号'),
     body('publishProxy').optional({ nullable: true }).isObject().withMessage('发布代理配置无效'),
     validateRequest,

+ 2 - 1
server/src/services/AccountService.ts

@@ -456,8 +456,9 @@ export class AccountService {
     }
     await this.accountRepository.delete(accountId);
 
-    // 通知其他客户端
+    // 通知其他客户端(修复 #6068/#6070:删除账号后各模块需要刷新数据)
     wsManager.sendToUser(userId, WS_EVENTS.ACCOUNT_DELETED, { accountId });
+    wsManager.sendToUser(userId, WS_EVENTS.ACCOUNT_DATA_CHANGED, { accountId });
   }
 
   async refreshAccount(userId: number, accountId: number): Promise<PlatformAccountType & { needReLogin?: boolean }> {

+ 169 - 6
server/src/services/HeadlessBrowserService.ts

@@ -455,6 +455,11 @@ class HeadlessBrowserService {
 
       const config = this.getPlatformConfig(platform);
 
+      // #6065: 视频号使用专门的平台登录页检查(登录后 URL 含特定路径,非登录时可能不重定向)
+      if (platform === 'weixin_video') {
+        return this.checkWeixinVideoLoginStatusByBrowser(page, context, cookies, browser);
+      }
+
       // 访问平台主页
       await page.goto(config.homeUrl, {
         waitUntil: 'domcontentloaded',
@@ -616,6 +621,107 @@ class HeadlessBrowserService {
   }
 
   /**
+   * #6065: 视频号登录状态检测 - 通过检查平台页面是否展示账号信息
+   * 视频号 Cookie 失效时可能不重定向到登录页(URL 不变),需要正向信号判断
+   */
+  private async checkWeixinVideoLoginStatusByBrowser(
+    page: Page,
+    context: BrowserContext,
+    cookies: CookieData[],
+    browser: import('playwright').Browser
+  ): Promise<CookieCheckResult> {
+    try {
+      // 视频号创作者平台需要等待加载完成后检测页面内容
+      await page.goto('https://channels.weixin.qq.com/platform', {
+        waitUntil: 'domcontentloaded',
+        timeout: 30000,
+      });
+
+      await page.waitForTimeout(5000);
+
+      // 尝试等待网络空闲,给页面足够时间加载
+      try {
+        await page.waitForLoadState('networkidle', { timeout: 15000 });
+      } catch {
+        // 超时继续
+      }
+
+      const url = page.url();
+      const bodyText = await this.getPageBodyTextSafe(page);
+      logger.info(`[Weixin Video] Browser check: URL=${url}, bodyLen=${bodyText.length}`);
+
+      // 检测明确的登录页特征
+      if (url.includes('login.html') || url.includes('/login?') || url.includes('passport')) {
+        logger.info('[Weixin Video] Redirected to login page');
+        await page.close();
+        await context.close();
+        await browser.close();
+        return { isValid: false, needReLogin: true, uncertain: false, reason: 'need_login', source: 'browser' };
+      }
+
+      // 检测风控
+      if (this.containsRiskKeywords(url) || this.containsRiskKeywords(bodyText)) {
+        logger.info('[Weixin Video] Detected risk control keywords');
+        await page.close();
+        await context.close();
+        await browser.close();
+        return { isValid: false, needReLogin: true, uncertain: false, reason: 'risk_control', source: 'browser', message: '检测到风控/验证页面' };
+      }
+
+      // #6065: 正向信号检测 - 检查页面上是否存在已登录的账号信息元素
+      // 视频号登录后首页会出现 nickname、头像等元素
+      const positiveSignals = [
+        '.finder-nickname',       // 视频号昵称
+        '.avatar img[src]',       // 头像图片
+        '[class*="video-count"]', // 视频数
+        '[class*="follower"]',    // 关注者
+        'div.title-name',         // 账号名称
+      ];
+
+      let hasPositiveSignal = false;
+      for (const selector of positiveSignals) {
+        try {
+          const count = await page.locator(selector).count();
+          if (count > 0) {
+            hasPositiveSignal = true;
+            logger.info(`[Weixin Video] Found positive signal: ${selector}`);
+            break;
+          }
+        } catch {
+          // continue
+        }
+      }
+
+      // 额外检查:页面正文是否包含视频号管理页面的特征文本
+      const hasManagementText = bodyText.includes('发表视频') ||
+        bodyText.includes('数据中心') ||
+        bodyText.includes('互动管理') ||
+        bodyText.includes('内容管理');
+
+      if (hasPositiveSignal || hasManagementText) {
+        logger.info(`[Weixin Video] Cookie valid (positive signal=${hasPositiveSignal}, managementText=${hasManagementText})`);
+        await page.close();
+        await context.close();
+        await browser.close();
+        return { isValid: true, needReLogin: false, uncertain: false, reason: 'valid', source: 'browser' };
+      }
+
+      // 没有正向信号也没有登录指标 → 不确定(可能是页面加载慢或网络问题)
+      logger.warn(`[Weixin Video] No positive or negative signals detected, marking as uncertain`);
+      await page.close();
+      await context.close();
+      await browser.close();
+      return { isValid: false, needReLogin: false, uncertain: true, reason: 'uncertain', source: 'browser', message: '无法确定视频号登录状态' };
+    } catch (error) {
+      logger.error('[Weixin Video] checkLoginByBrowser error:', error);
+      try {
+        await browser.close();
+      } catch { /* ignore */ }
+      return { isValid: false, needReLogin: false, uncertain: true, reason: 'uncertain', source: 'browser', message: error instanceof Error ? error.message : 'Weixin video check error' };
+    }
+  }
+
+  /**
    * 访问平台后台页面并截图(用于 AI 分析)
    * @param platform 平台类型
    * @param cookies Cookie 数据
@@ -921,8 +1027,14 @@ class HeadlessBrowserService {
         }
       } else {
         logger.info(`[Python API] Service not available for baijiahao, falling back to direct API`);
-        // Python 不可用时,回退到 Node 直连 API(可能仍会遇到分散认证问题)
-        info = await this.fetchBaijiahaoAccountInfoDirectApi(cookies);
+        // #6085: Python 不可用时回退到 Node 直连 API,需捕获分散认证等 errno 异常
+        try {
+          info = await this.fetchBaijiahaoAccountInfoDirectApi(cookies);
+        } catch (apiError) {
+          logger.warn(`[Baijiahao] Direct API failed: ${apiError instanceof Error ? apiError.message : apiError}`);
+          // 分散认证等 errno 非 0 错误不一定是 cookie 失效,返回默认信息而非抛出异常
+          info = this.getDefaultAccountInfo(platform);
+        }
         info.source = 'api';
         info.pythonAvailable = false;
       }
@@ -1315,6 +1427,26 @@ class HeadlessBrowserService {
         logger.warn('[Douyin] Failed to navigate to data center:', error);
       }
 
+      // #6088: 如果还没有获取到作品列表,主动访问内容管理页面触发 work_list API
+      if (!capturedData.worksList || capturedData.worksList.length === 0) {
+        logger.info('[Douyin] No works captured yet, navigating to content manage page to trigger work_list API...');
+        try {
+          await page.goto('https://creator.douyin.com/creator-micro/content/manage', {
+            waitUntil: 'domcontentloaded',
+            timeout: 15000,
+          });
+          await page.waitForTimeout(5000);
+
+          if (capturedData.worksList && capturedData.worksList.length > 0) {
+            logger.info(`[Douyin] Captured ${capturedData.worksList.length} works from content manage page`);
+          } else {
+            logger.warn('[Douyin] Still no works captured from content manage page');
+          }
+        } catch (error) {
+          logger.warn('[Douyin] Failed to navigate to content manage page:', error);
+        }
+      }
+
       // 检查登录状态 - 如果没有从 API 获取到,通过 URL 判断
       if (!isLoggedIn) {
         const currentUrl = page.url();
@@ -2280,8 +2412,9 @@ class HeadlessBrowserService {
 
         const fetchNotesPage = async (pageNum: number) => {
           return await page.evaluate(async (p) => {
+            // #6071: 添加 page_size=20 确保每页返回足够多的笔记(默认可能只有10条)
             const response = await fetch(
-              `https://edith.xiaohongshu.com/web_api/sns/v5/creator/note/user/posted?tab=0&page=${p}`,
+              `https://edith.xiaohongshu.com/web_api/sns/v5/creator/note/user/posted?tab=0&page=${p}&page_size=20`,
               {
                 method: 'GET',
                 credentials: 'include',
@@ -2557,8 +2690,14 @@ class HeadlessBrowserService {
       logger.info(`[Baijiahao API] appinfo response: errno=${appInfoData.errno}, errmsg=${appInfoData.errmsg}`);
 
       if (appInfoData.errno !== 0) {
-        logger.error(`[Baijiahao API] appinfo API error: errno=${appInfoData.errno}, errmsg=${appInfoData.errmsg}`);
-        throw new Error(`appinfo API error: ${appInfoData.errmsg || 'Unknown error'}`);
+        // #6085: errno 非 0 不一定是 cookie 失效(如 errno=10001402 分散认证),
+        // 只有 errno=110 才明确表示未登录,其他 errno 返回默认信息避免同步中断
+        if (appInfoData.errno === 110) {
+          logger.error(`[Baijiahao API] Not logged in (errno=110)`);
+          throw new Error(`appinfo API error: errno=110, cookie expired`);
+        }
+        logger.warn(`[Baijiahao API] appinfo returned errno=${appInfoData.errno}, errmsg=${appInfoData.errmsg}, returning default info`);
+        return accountInfo;
       }
 
       if (!appInfoData.data?.user) {
@@ -4118,7 +4257,7 @@ class HeadlessBrowserService {
   /**
    * 通过 Python API 获取评论 - 分作品逐个获取
    */
-  private async fetchCommentsViaPythonApi(platform: 'douyin' | 'xiaohongshu' | 'weixin', cookies: CookieData[]): Promise<WorkComments[]> {
+  private async fetchCommentsViaPythonApi(platform: 'douyin' | 'xiaohongshu' | 'weixin' | 'baijiahao', cookies: CookieData[]): Promise<WorkComments[]> {
     const allWorkComments: WorkComments[] = [];
     const cookieString = JSON.stringify(cookies);
     const pythonUrl = (await getPythonServiceBaseUrl()).replace(/\/$/, '');
@@ -5089,6 +5228,30 @@ class HeadlessBrowserService {
   }
 
   /**
+   * #6073: 获取百家号评论 - 优先使用 Python API
+   * 注: CommentService 调用了此方法但之前未实现,导致运行时 TypeError
+   */
+  async fetchBaijiahaoCommentsViaApi(cookies: CookieData[]): Promise<WorkComments[]> {
+    const pythonAvailable = await this.checkPythonServiceAvailable();
+    if (pythonAvailable) {
+      logger.info('[Baijiahao Comments] Using Python API...');
+      try {
+        const result = await this.fetchCommentsViaPythonApi('baijiahao', cookies);
+        if (result.length > 0) {
+          return result;
+        }
+        logger.info('[Baijiahao Comments] Python API returned empty');
+      } catch (pythonError) {
+        logger.warn('[Baijiahao Comments] Python API failed:', pythonError);
+      }
+    }
+
+    // 百家号暂无 Playwright 评论抓取方案,返回空数组
+    logger.warn('[Baijiahao Comments] No fallback available, returning empty');
+    return [];
+  }
+
+  /**
    * 获取微信视频号评论 - 优先使用 Python API
    */
   async fetchWeixinVideoCommentsViaApi(cookies: CookieData[]): Promise<WorkComments[]> {

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

@@ -138,7 +138,8 @@ export class PublishService {
       .getManyAndCount();
 
     // 批量查询每个任务的发布结果统计
-    let resultStatsMap: Record<number, { successCount: number; failCount: number }> = {};
+    // 修复 #6068:同时查询实际目标账号 ID,删除账号后 targetAccounts 原始缓存不再准确
+    let resultStatsMap: Record<number, { successCount: number; failCount: number; actualTargetAccounts: number[] }> = {};
     if (tasks.length > 0) {
       const taskIds = tasks.map(t => t.id);
       const stats = await this.resultRepository
@@ -146,6 +147,7 @@ export class PublishService {
         .select('r.taskId', 'taskId')
         .addSelect('COUNT(CASE WHEN r.status = :success THEN 1 END)', 'successCount')
         .addSelect('COUNT(CASE WHEN r.status = :failed THEN 1 END)', 'failCount')
+        .addSelect('GROUP_CONCAT(DISTINCT r.account_id)', 'actualTargetAccountIds')
         .setParameter('success', 'success')
         .setParameter('failed', 'failed')
         .where('r.taskId IN (:...taskIds)', { taskIds })
@@ -155,6 +157,9 @@ export class PublishService {
         resultStatsMap[s.taskId] = {
           successCount: Number(s.successCount) || 0,
           failCount: Number(s.failCount) || 0,
+          actualTargetAccounts: s.actualTargetAccountIds
+            ? String(s.actualTargetAccountIds).split(',').map(Number).filter(Boolean)
+            : [],
         };
       }
     }
@@ -166,6 +171,10 @@ export class PublishService {
         if (stats) {
           formatted.successCount = stats.successCount;
           formatted.failCount = stats.failCount;
+          // 修复 #6068:使用 PublishResult 中的实际账号 ID 覆盖原始缓存,删除账号后保持准确
+          if (stats.actualTargetAccounts.length > 0) {
+            formatted.targetAccounts = stats.actualTargetAccounts;
+          }
         }
         return formatted;
       }),

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

@@ -975,6 +975,7 @@ export class WorkDayStatisticsService {
   }> {
     const { startDate, endDate, platform, groupId } = options;
 
+    // TODO(#6082): 仅查询以下支持的平台,若新增平台数据统计需同步更新此列表
     // 只查询支持的平台:抖音、百家号、视频号、小红书
     const allowedPlatforms = ['douyin', 'baijiahao', 'weixin_video', 'xiaohongshu'];
 

+ 22 - 0
server/src/services/WorkService.ts

@@ -7,6 +7,7 @@ import { headlessBrowserService } from './HeadlessBrowserService.js';
 import { CookieManager } from '../automation/cookie.js';
 import { WorkDayStatisticsService } from './WorkDayStatisticsService.js';
 import { taskQueueService } from './TaskQueueService.js';
+import { wsManager } from '../websocket/index.js';
 
 export class WorkService {
   private workRepository = AppDataSource.getRepository(Work);
@@ -264,6 +265,27 @@ export class WorkService {
       `拉取完成:${account.accountName || account.id} (${platform}) python=${accountInfo.pythonAvailable ? 'ok' : 'off'} source=${accountInfo.source || 'unknown'} list=${accountInfo.worksList?.length || 0} total=${accountInfo.worksCount || 0}`
     );
 
+    // #6075: 同步作品时同步更新账号的头像和昵称
+    if (accountInfo.accountName || accountInfo.avatarUrl) {
+      const accountUpdate: Partial<PlatformAccount> = {};
+      const defaultNames = [`${platform}账号`, '未知账号', '抖音账号', '小红书账号', '快手账号', '视频号账号', 'B站账号', '头条账号', '百家号账号'];
+
+      if (accountInfo.accountName && !defaultNames.includes(accountInfo.accountName) && accountInfo.accountName !== account.accountName) {
+        accountUpdate.accountName = accountInfo.accountName;
+      }
+      if (accountInfo.avatarUrl && accountInfo.avatarUrl !== account.avatarUrl) {
+        accountUpdate.avatarUrl = accountInfo.avatarUrl;
+      }
+      if (Object.keys(accountUpdate).length > 0) {
+        await this.accountRepository.update(account.id, accountUpdate);
+        logger.info(`[SyncAccountWorks] Updated account info: ${Object.keys(accountUpdate).join(', ')}`);
+        wsManager.sendToUser(userId, 'account:info_updated', {
+          accountId: account.id,
+          ...accountUpdate,
+        });
+      }
+    }
+
     let syncedCount = 0;
 
     // 收集远程作品的 platformVideoId

+ 2 - 0
server/src/services/login/XiaohongshuLoginService.ts

@@ -39,6 +39,8 @@ export class XiaohongshuLoginService extends BaseLoginService {
         dataKey: 'personalInfo',
         handler: (data: any) => {
           const info = data.data || data;
+          // TODO(#6064): fans_count 字段名需与实际 API 响应结构核对,
+          // 若小红书 API 返回结构变化(如嵌套层级调整)会导致粉丝数显示错误
           return {
             avatar: info.avatar,
             name: info.name,