Просмотр исходного кода

fix: 修复发布任务相关Bug (#6066,#6067,#6068,#6069)

#6067 - 抖音Python服务不可用时标记为CAPTCHA_REQUIRED,允许使用有头浏览器重试,
        扩展canRetryWithHeadful支持douyin/weixin_video平台
#6068 - 任务详情基于实际PublishResult记录计算账号数和状态,不依赖缓存targetAccounts
#6069 - 新建任务表单根据选中平台动态显示/隐藏字段并标记必填,添加平台要求提示
#6066 - 发布列表和任务队列中增加平台/渠道标签展示
ethanfly 3 дней назад
Родитель
Сommit
6fe524fe0d

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

@@ -13,6 +13,7 @@ import {
   ChatLineSquare,
 } from '@element-plus/icons-vue';
 import { useTaskQueueStore } from '@/stores/taskQueue';
+import { PLATFORMS } from '@media-manager/shared';
 import type { Task, TaskType, TaskStatus } from '@media-manager/shared';
 import dayjs from 'dayjs';
 
@@ -66,6 +67,11 @@ function getStatusConfig(status: TaskStatus) {
   return statusConfig[status] || statusConfig.pending;
 }
 
+function getPlatformName(platform?: string): string {
+  if (!platform) return '';
+  return PLATFORMS[platform as keyof typeof PLATFORMS]?.name || platform;
+}
+
 function formatTime(time?: string) {
   if (!time) return '-';
   return dayjs(time).format('HH:mm:ss');
@@ -117,6 +123,9 @@ function handleClearCompleted() {
               <div class="task-info">
                 <el-icon class="task-icon"><component :is="getIcon(task.type)" /></el-icon>
                 <span class="task-title">{{ task.title }}</span>
+                <el-tag v-if="task.platform" size="small" type="info" class="platform-tag">
+                  {{ getPlatformName(task.platform) }}
+                </el-tag>
               </div>
               <ElTag size="small" :type="getStatusConfig(task.status).type">
                 {{ getStatusConfig(task.status).text }}
@@ -149,6 +158,9 @@ function handleClearCompleted() {
               <div class="task-info">
                 <el-icon class="task-icon"><component :is="getIcon(task.type)" /></el-icon>
                 <span class="task-title">{{ task.title }}</span>
+                <el-tag v-if="task.platform" size="small" type="info" class="platform-tag">
+                  {{ getPlatformName(task.platform) }}
+                </el-tag>
               </div>
               <div class="task-actions">
                 <ElTag size="small" type="info">等待中</ElTag>
@@ -191,6 +203,9 @@ function handleClearCompleted() {
               <div class="task-info">
                 <el-icon class="task-icon"><component :is="getIcon(task.type)" /></el-icon>
                 <span class="task-title">{{ task.title }}</span>
+                <el-tag v-if="task.platform" size="small" type="info" class="platform-tag">
+                  {{ getPlatformName(task.platform) }}
+                </el-tag>
               </div>
               <ElTag size="small" :type="getStatusConfig(task.status).type">
                 {{ getStatusConfig(task.status).text }}
@@ -330,6 +345,10 @@ function handleClearCompleted() {
   color: $text-primary;
 }
 
+.platform-tag {
+  flex-shrink: 0;
+}
+
 .task-actions {
   display: flex;
   align-items: center;

+ 141 - 14
client/src/views/Publish/index.vue

@@ -37,9 +37,17 @@
           </template>
         </el-table-column>
         
-        <el-table-column label="目标账号" width="120">
+        <el-table-column label="目标账号" min-width="180">
           <template #default="{ row }">
-            {{ row.targetAccounts?.length || 0 }} 个
+            <span>{{ row.targetAccounts?.length || 0 }} 个</span>
+            <el-tag
+              v-for="platform in getTaskPlatforms(row)"
+              :key="platform"
+              size="small"
+              style="margin-left: 4px"
+            >
+              {{ getPlatformName(platform) }}
+            </el-tag>
           </template>
         </el-table-column>
         
@@ -113,7 +121,17 @@
     <!-- 创建发布对话框 -->
     <el-dialog v-model="showCreateDialog" title="新建发布" width="600px">
       <el-form :model="createForm" label-width="100px">
-        <el-form-item label="视频文件">
+        <!-- 平台提示:根据选择的目标平台动态显示必填要求 -->
+        <el-alert
+          v-if="createSelectedPlatforms.length > 0"
+          :title="createPlatformHint"
+          type="info"
+          :closable="false"
+          show-icon
+          style="margin-bottom: 16px"
+        />
+        
+        <el-form-item label="视频文件" :required="createRequireVideo">
           <el-upload
             action=""
             :auto-upload="false"
@@ -125,17 +143,26 @@
           <span v-if="createForm.videoFile" style="margin-left: 12px">
             {{ createForm.videoFile.name }}
           </span>
+          <span v-if="createRequireImage && !createForm.videoFile" class="form-tip">
+            也可上传图片发布
+          </span>
         </el-form-item>
         
-        <el-form-item label="标题">
-          <el-input v-model="createForm.title" placeholder="视频标题" />
+        <el-form-item v-if="createRequireTitle" label="标题" required>
+          <el-input v-model="createForm.title" placeholder="视频标题" show-word-limit :maxlength="createTitleMaxLength" />
+        </el-form-item>
+        <el-form-item v-else label="标题">
+          <el-input v-model="createForm.title" placeholder="视频标题(可选)" />
         </el-form-item>
         
-        <el-form-item label="描述">
-          <el-input v-model="createForm.description" type="textarea" :rows="3" placeholder="视频描述" />
+        <el-form-item v-if="createRequireDescription" label="描述" required>
+          <el-input v-model="createForm.description" type="textarea" :rows="3" placeholder="视频描述" show-word-limit :maxlength="createDescMaxLength" />
+        </el-form-item>
+        <el-form-item v-else label="描述">
+          <el-input v-model="createForm.description" type="textarea" :rows="3" placeholder="视频描述(可选)" />
         </el-form-item>
         
-        <el-form-item label="标签">
+        <el-form-item v-if="createShowTags" label="标签">
           <el-select v-model="createForm.tags" multiple filterable allow-create placeholder="添加标签" style="width: 100%">
           </el-select>
         </el-form-item>
@@ -217,7 +244,7 @@
             </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="目标账号">{{ taskDetail?.results?.length || currentTask?.targetAccounts?.length || 0 }} 个</el-descriptions-item>
           <el-descriptions-item label="定时发布">
             {{ currentTask.scheduledAt ? formatDate(currentTask.scheduledAt) : '立即发布' }}
           </el-descriptions-item>
@@ -451,6 +478,74 @@ const filteredTasks = computed(() => {
   );
 });
 
+// ===== Bug #6069: 创建表单平台感知字段 =====
+
+// 当前选中的目标平台列表
+const createSelectedPlatforms = computed<PlatformType[]>(() => {
+  const ids = new Set(createForm.targetAccounts);
+  const platforms = new Set<PlatformType>();
+  for (const account of accounts.value) {
+    if (ids.has(Number(account.id))) {
+      platforms.add(account.platform);
+    }
+  }
+  return Array.from(platforms);
+});
+
+// 各平台发布要求
+const PLATFORM_PUBLISH_REQUIREMENTS: Record<string, {
+  requireTitle: boolean;
+  requireDescription: boolean;
+  requireVideo: boolean;
+  requireImage: boolean;
+  showTags: boolean;
+}> = {
+  douyin: { requireTitle: true, requireDescription: false, requireVideo: true, requireImage: false, showTags: true },
+  xiaohongshu: { requireTitle: true, requireDescription: true, requireVideo: false, requireImage: true, showTags: true },
+  weixin_video: { requireTitle: true, requireDescription: false, requireVideo: true, requireImage: false, showTags: true },
+  baijiahao: { requireTitle: true, requireDescription: true, requireVideo: false, requireImage: false, showTags: true },
+  kuaishou: { requireTitle: true, requireDescription: false, requireVideo: true, requireImage: false, showTags: true },
+  bilibili: { requireTitle: true, requireDescription: false, requireVideo: true, requireImage: false, showTags: true },
+};
+
+// 只要任一选中平台要求某字段,就显示必填
+const createRequireTitle = computed(() => createSelectedPlatforms.value.some(p => PLATFORM_PUBLISH_REQUIREMENTS[p]?.requireTitle));
+const createRequireDescription = computed(() => createSelectedPlatforms.value.some(p => PLATFORM_PUBLISH_REQUIREMENTS[p]?.requireDescription));
+const createRequireVideo = computed(() => createSelectedPlatforms.value.some(p => PLATFORM_PUBLISH_REQUIREMENTS[p]?.requireVideo));
+const createRequireImage = computed(() => createSelectedPlatforms.value.some(p => PLATFORM_PUBLISH_REQUIREMENTS[p]?.requireImage));
+const createShowTags = computed(() => createSelectedPlatforms.value.some(p => PLATFORM_PUBLISH_REQUIREMENTS[p]?.showTags));
+
+// 取最严格的标题/描述长度限制
+const createTitleMaxLength = computed(() => {
+  let max = 100;
+  for (const p of createSelectedPlatforms.value) {
+    const info = PLATFORMS[p];
+    if (info?.maxTitleLength && info.maxTitleLength < max) max = info.maxTitleLength;
+  }
+  return max;
+});
+const createDescMaxLength = computed(() => {
+  let max = 2000;
+  for (const p of createSelectedPlatforms.value) {
+    const info = PLATFORMS[p];
+    if (info?.maxDescriptionLength && info.maxDescriptionLength < max) max = info.maxDescriptionLength;
+  }
+  return max;
+});
+
+// 平台提示文案
+const createPlatformHint = computed(() => {
+  const platforms = createSelectedPlatforms.value;
+  if (!platforms.length) return '';
+  const names = platforms.map(p => PLATFORMS[p]?.name || p).join('、');
+  const tips: string[] = [];
+  if (createRequireTitle.value) tips.push('标题必填');
+  if (createRequireDescription.value) tips.push('正文必填');
+  if (createRequireVideo.value) tips.push('视频必填');
+  if (createRequireImage.value) tips.push('图片或视频必填');
+  return `已选平台:${names}。要求:${tips.join('、')}`;
+});
+
 const pagination = reactive({
   page: 1,
   pageSize: 20,
@@ -617,6 +712,18 @@ function getPlatformName(platform: PlatformType) {
   return PLATFORMS[platform]?.name || platform;
 }
 
+// 根据 targetAccounts 获取关联的平台列表(Bug #6066: 显示渠道)
+function getTaskPlatforms(task: PublishTask): PlatformType[] {
+  const ids = new Set(task.targetAccounts || []);
+  const platforms = new Set<PlatformType>();
+  for (const account of accounts.value) {
+    if (ids.has(Number(account.id))) {
+      platforms.add(account.platform);
+    }
+  }
+  return Array.from(platforms);
+}
+
 function getStatusType(status: string) {
   const types: Record<string, string> = {
     pending: 'info',
@@ -653,13 +760,13 @@ function isScreenshotPendingError(errorMessage: string | null | undefined): bool
   return !!errorMessage && errorMessage.includes('请查看截图');
 }
 
-// 是否可以使用有头浏览器重新发布(目前主要用于小红书发布失败场景)
+// 是否可以使用有头浏览器重新发布
 function canRetryWithHeadful(row: { platform: string; status: string | null | undefined }): boolean {
   if (row.status !== 'failed') return false;
-  return row.platform === 'xiaohongshu' || row.platform === 'baijiahao';
+  return row.platform === 'xiaohongshu' || row.platform === 'baijiahao' || row.platform === 'douyin' || row.platform === 'weixin_video';
 }
 
-// 打开查看截图(小红书等平台暂打开创作者中心,用户可自行查看发布状态)
+// 打开查看截图(平台暂打开创作者中心,用户可自行查看发布状态)
 function openScreenshotView(row: { platform: string; accountId: number }) {
   if (row.platform === 'xiaohongshu') {
     window.open('https://creator.xiaohongshu.com/publish/publish', '_blank', 'noopener,noreferrer');
@@ -669,6 +776,14 @@ function openScreenshotView(row: { platform: string; accountId: number }) {
     window.open('https://baijiahao.baidu.com/builder/rc/content', '_blank', 'noopener,noreferrer');
     return;
   }
+  if (row.platform === 'douyin') {
+    window.open('https://creator.douyin.com/creator-micro/content/upload', '_blank', 'noopener,noreferrer');
+    return;
+  }
+  if (row.platform === 'weixin_video') {
+    window.open('https://channels.weixin.qq.com/platform', '_blank', 'noopener,noreferrer');
+    return;
+  }
   ElMessage.info('请前往对应平台查看发布状态');
 }
 
@@ -786,8 +901,20 @@ function handleFileChange(file: UploadFile) {
 }
 
 async function handleCreate() {
-  if (!createForm.videoFile || !createForm.title || createForm.targetAccounts.length === 0) {
-    ElMessage.warning('请填写完整信息');
+  if (createForm.targetAccounts.length === 0) {
+    ElMessage.warning('请至少选择一个目标账号');
+    return;
+  }
+  if (createRequireTitle.value && !createForm.title) {
+    ElMessage.warning('所选平台要求标题必填');
+    return;
+  }
+  if (createRequireDescription.value && !createForm.description) {
+    ElMessage.warning('所选平台要求正文必填');
+    return;
+  }
+  if (createRequireVideo.value && !createForm.videoFile) {
+    ElMessage.warning('所选平台要求视频必填');
     return;
   }
 

+ 2 - 2
server/src/automation/platforms/douyin.ts

@@ -1079,13 +1079,13 @@ export class DouyinAdapter extends BasePlatformAdapter {
     onCaptchaRequired?: (captchaInfo: { taskId: string; phone?: string }) => Promise<string>,
     options?: { headless?: boolean }
   ): Promise<PublishResult> {
-    // 只使用 Python 服务发布
+    // 只使用 Python 服务发布,不可用时提示用户使用有头浏览器重试
     const pythonAvailable = await this.checkPythonServiceAvailable();
     if (!pythonAvailable) {
       logger.error('[Douyin] Python service not available');
       return {
         success: false,
-        errorMessage: 'Python 发布服务不可用,请确保 Python 服务已启动',
+        errorMessage: 'CAPTCHA_REQUIRED:Python 发布服务不可用,请确保 Python 服务已启动后重试,或使用有头浏览器重新发布',
       };
     }
 

+ 34 - 3
server/src/services/PublishService.ts

@@ -154,7 +154,26 @@ export class PublishService {
     if (!task) {
       throw new AppError('任务不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
     }
-    return this.formatTaskDetail(task);
+    // 基于 PublishResult 记录重新计算任务状态(修复 #6068:删除账号后状态不一致)
+    const results = task.results || [];
+    const actualTargetAccounts = results.map(r => r.accountId);
+    const completedResults = results.filter(r => r.status === 'success');
+    const failedResults = results.filter(r => r.status === 'failed');
+
+    let computedStatus = task.status;
+    if (results.length > 0 && task.status !== 'pending' && task.status !== 'processing' && task.status !== 'cancelled') {
+      if (completedResults.length === results.length) {
+        computedStatus = 'completed';
+      } else if (failedResults.length === results.length) {
+        computedStatus = 'failed';
+      } else if (completedResults.length + failedResults.length === results.length) {
+        computedStatus = 'failed'; // 部分失败也标记为 failed(保持向后兼容)
+      } else {
+        computedStatus = task.status;
+      }
+    }
+
+    return this.formatTaskDetail(task, actualTargetAccounts, computedStatus);
   }
 
   async createTask(userId: number, data: CreatePublishTaskRequest): Promise<PublishTaskType> {
@@ -878,9 +897,21 @@ export class PublishService {
     }
   }
 
-  private formatTaskDetail(task: PublishTask): PublishTaskDetail {
+  private formatTaskDetail(
+    task: PublishTask,
+    overrideTargetAccounts?: number[],
+    overrideStatus?: string,
+  ): PublishTaskDetail {
+    const base = this.formatTask(task);
+    // 修复 #6068:使用实际存在的 PublishResult 账号列表覆盖缓存的 targetAccounts
+    if (overrideTargetAccounts) {
+      base.targetAccounts = overrideTargetAccounts;
+    }
+    if (overrideStatus) {
+      base.status = overrideStatus as PublishTaskType['status'];
+    }
     return {
-      ...this.formatTask(task),
+      ...base,
       results: task.results?.map(r => ({
         id: r.id,
         taskId: r.taskId,