Bladeren bron

feat: enhance server configuration and error handling in publishing

- Added optional `pythonServiceUrl` to the server configuration interface.
- Updated `setSingleServer` function to store the Python service URL.
- Improved error handling in the publishing view, including new buttons for confirming pending results and retrying with a headful browser.
- Implemented backend support for manually confirming publish results, allowing users to mark results as successful.
- Enhanced the loading and saving of Python service configurations based on authentication and local server status.
Ethanfly 17 uur geleden
bovenliggende
commit
db43c83a4d

+ 5 - 3
client/src/stores/server.ts

@@ -6,6 +6,7 @@ interface ServerConfig {
   id: string;
   name: string;
   url: string;
+  pythonServiceUrl?: string;
   isDefault?: boolean;
 }
 
@@ -89,11 +90,12 @@ export const useServerStore = defineStore('server', () => {
     }
   }
 
-  // 单服务器模式:覆盖保存一个服务器配置并设为当前
-  function setSingleServer(config: { url: string; name?: string }) {
+  // 单服务器模式:覆盖保存一个服务器配置并设为当前,Python 服务地址也存本地
+  function setSingleServer(config: { url: string; name?: string; pythonServiceUrl?: string }) {
     const url = config.url.replace(/\/$/, '');
     const name = (config.name || '').trim() || '服务器';
-    servers.value = [{ id: SINGLE_SERVER_ID, name, url, isDefault: true }];
+    const pythonServiceUrl = config.pythonServiceUrl?.replace(/\/$/, '') || undefined;
+    servers.value = [{ id: SINGLE_SERVER_ID, name, url, pythonServiceUrl, isDefault: true }];
     currentServerId.value = SINGLE_SERVER_ID;
     saveConfig();
   }

+ 96 - 5
client/src/views/Publish/index.vue

@@ -247,7 +247,7 @@
                 </el-tag>
               </template>
             </el-table-column>
-            <el-table-column label="错误信息" min-width="200">
+            <el-table-column label="错误信息" min-width="260">
               <template #default="{ row }">
                 <div v-if="isCaptchaError(row.errorMessage)" class="captcha-error">
                   <el-text type="warning">检测到验证码,需要手动验证</el-text>
@@ -260,7 +260,38 @@
                     打开浏览器验证
                   </el-button>
                 </div>
-                <span v-else>{{ row.errorMessage || '-' }}</span>
+                <div v-else-if="isScreenshotPendingError(row.errorMessage)" class="screenshot-pending">
+                  <span>{{ row.errorMessage?.replace('请查看截图', '').trim() || '发布结果待确认' }}</span>
+                  <el-button
+                    type="primary"
+                    size="small"
+                    link
+                    @click="openScreenshotView(row)"
+                  >
+                    查看截图
+                  </el-button>
+                  <el-button
+                    v-if="canRetryWithHeadful(row)"
+                    type="primary"
+                    size="small"
+                    link
+                    @click="openBrowserForCaptcha(row.accountId, row.platform)"
+                  >
+                    使用有头浏览器重新发布
+                  </el-button>
+                </div>
+                <div v-else class="normal-error">
+                  <span>{{ row.errorMessage || '-' }}</span>
+                  <el-button
+                    v-if="canRetryWithHeadful(row)"
+                    type="primary"
+                    size="small"
+                    link
+                    @click="openBrowserForCaptcha(row.accountId, row.platform)"
+                  >
+                    使用有头浏览器重新发布
+                  </el-button>
+                </div>
               </template>
             </el-table-column>
             <el-table-column label="发布时间" width="160">
@@ -274,6 +305,13 @@
       
       <template #footer>
         <el-button @click="showDetailDialog = false">关闭</el-button>
+        <el-button
+          v-if="hasPendingConfirmResults"
+          type="success"
+          @click="confirmAllPendingResults"
+        >
+          确认
+        </el-button>
         <el-button type="primary" @click="openEditDialog">
           <el-icon><Edit /></el-icon>
           修改并重新发布
@@ -593,6 +631,48 @@ function isCaptchaError(errorMessage: string | null | undefined): boolean {
   return !!errorMessage && errorMessage.includes('CAPTCHA_REQUIRED');
 }
 
+// 检查是否是"发布结果待确认,请查看截图"类错误
+function isScreenshotPendingError(errorMessage: string | null | undefined): boolean {
+  return !!errorMessage && errorMessage.includes('请查看截图');
+}
+
+// 是否可以使用有头浏览器重新发布(目前主要用于小红书发布失败场景)
+function canRetryWithHeadful(row: { platform: string; status: string | null | undefined }): boolean {
+  return row.platform === 'xiaohongshu' && row.status === 'failed';
+}
+
+// 打开查看截图(小红书等平台暂打开创作者中心,用户可自行查看发布状态)
+function openScreenshotView(row: { platform: string; accountId: number }) {
+  if (row.platform === 'xiaohongshu') {
+    window.open('https://creator.xiaohongshu.com/publish/publish', '_blank', 'noopener,noreferrer');
+  } else {
+    ElMessage.info('请前往对应平台查看发布状态');
+  }
+}
+
+// 是否存在待确认的发布结果
+const hasPendingConfirmResults = computed(() => {
+  const results = taskDetail.value?.results || [];
+  return results.some(r => isScreenshotPendingError(r.errorMessage));
+});
+
+// 确认所有待确认的发布结果
+async function confirmAllPendingResults() {
+  if (!currentTask.value || !taskDetail.value?.results?.length) return;
+  const pending = taskDetail.value.results.filter(r => isScreenshotPendingError(r.errorMessage));
+  if (!pending.length) return;
+  try {
+    for (const r of pending) {
+      await request.post(`/api/publish/${currentTask.value.id}/results/${r.id}/confirm`);
+    }
+    ElMessage.success('已确认发布成功');
+    const detail = await request.get(`/api/publish/${currentTask.value.id}`);
+    taskDetail.value = detail;
+  } catch {
+    ElMessage.error('确认失败');
+  }
+}
+
 // 使用有头浏览器重新执行发布流程(用于验证码场景)
 async function openBrowserForCaptcha(accountId: number, platform: string) {
   if (!currentTask.value) {
@@ -962,10 +1042,21 @@ onMounted(() => {
   }
 }
 
-.captcha-error {
+.captcha-error,
+.screenshot-pending {
   display: flex;
-  flex-direction: column;
-  gap: 4px;
+  flex-direction: row;
+  align-items: center;
+  gap: 8px;
+  flex-wrap: wrap;
+}
+
+.normal-error {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  gap: 8px;
+  flex-wrap: wrap;
 }
 
 .account-option {

+ 18 - 9
client/src/views/ServerConfig/index.vue

@@ -203,16 +203,21 @@ function isLocalServerUrl(url?: string): boolean {
   }
 }
 
-// 仅当已登录或本地服务器时加载 Python 配置,避免未登录访问远程接口触发 401
+// 加载 Python 配置:优先从接口获取,未登录或远程时用本地缓存
 async function loadPythonService() {
   try {
     if (!pythonFormEnabled.value) return;
-    const canLoad = authStore.isAuthenticated || isLocalServerUrl(apiBaseUrl.value);
-    if (!canLoad) return;
-    const config = await request.get('/api/system/python-service', { baseURL: apiBaseUrl.value });
-    pythonService.url = String(config.url || '');
+    const localUrl = serverStore.currentServer?.pythonServiceUrl;
+    const canLoadFromApi = authStore.isAuthenticated || isLocalServerUrl(apiBaseUrl.value);
+    if (canLoadFromApi) {
+      const config = await request.get('/api/system/python-service', { baseURL: apiBaseUrl.value });
+      pythonService.url = String(config.url || localUrl || '');
+    } else if (localUrl) {
+      pythonService.url = localUrl;
+    }
   } catch {
-    // 错误已处理
+    const localUrl = serverStore.currentServer?.pythonServiceUrl;
+    if (localUrl) pythonService.url = localUrl;
   }
 }
 
@@ -230,16 +235,19 @@ async function saveAll() {
 
   savingAll.value = true;
   try {
+    const pythonUrl = pythonService.url ? normalizeBaseUrl(pythonService.url) : undefined;
     serverStore.setSingleServer({
       url: normalizedServerUrl,
+      pythonServiceUrl: pythonUrl,
     });
 
     const canSavePython = authStore.isAuthenticated || isLocalServerUrl(normalizedServerUrl);
-    if (pythonService.url && pythonFormEnabled.value && canSavePython) {
-      await request.put('/api/system/python-service', { url: normalizeBaseUrl(pythonService.url) }, { baseURL: normalizedServerUrl });
+    if (pythonUrl && canSavePython) {
+      await request.put('/api/system/python-service', { url: pythonUrl }, { baseURL: normalizedServerUrl });
     }
 
     ElMessage.success('配置已保存');
+    router.push('/login');
   } catch {
     // 错误已处理
   } finally {
@@ -292,6 +300,7 @@ async function checkPythonService() {
 
 onMounted(() => {
   serverForm.url = serverStore.currentServer?.url || '';
+  pythonService.url = serverStore.currentServer?.pythonServiceUrl || '';
   if (pythonFormEnabled.value) loadPythonService();
 });
 
@@ -300,8 +309,8 @@ watch(
   () => {
     connectionResult.value = null;
     pythonCheckResult.value = null;
-    pythonService.url = '';
     serverForm.url = serverStore.currentServer?.url || serverForm.url;
+    pythonService.url = serverStore.currentServer?.pythonServiceUrl || '';
     if (pythonFormEnabled.value) loadPythonService();
   }
 );

+ 18 - 0
server/src/routes/publish.ts

@@ -127,6 +127,24 @@ router.delete(
   })
 );
 
+// 手动确认发布结果(如小红书"发布结果待确认"时用户确认已发布成功)
+router.post(
+  '/:taskId/results/:resultId/confirm',
+  [
+    param('taskId').isInt().withMessage('任务ID无效'),
+    param('resultId').isInt().withMessage('结果ID无效'),
+    validateRequest,
+  ],
+  asyncHandler(async (req, res) => {
+    await publishService.confirmPublishResult(
+      req.user!.userId,
+      Number(req.params.taskId),
+      Number(req.params.resultId)
+    );
+    res.json({ success: true, message: '已确认发布成功' });
+  })
+);
+
 // 单账号有头浏览器重试发布(用于验证码场景)
 router.post(
   '/:taskId/retry-headful/:accountId',

+ 32 - 2
server/src/services/PublishService.ts

@@ -418,8 +418,10 @@ export class PublishService {
       }
     }
 
-    // 更新任务状态
-    const finalStatus = failCount === 0 ? 'completed' : (successCount === 0 ? 'failed' : 'completed');
+    // 更新任务状态:
+    // - 只要有一条失败,任务状态就是 failed
+    // - 只有全部成功(failCount===0)才标记为 completed
+    const finalStatus = failCount > 0 ? 'failed' : 'completed';
     await this.taskRepository.update(taskId, {
       status: finalStatus,
       publishedAt: new Date(),
@@ -757,6 +759,34 @@ export class PublishService {
     };
   }
 
+  /**
+   * 用户手动确认发布结果(如小红书"发布结果待确认"时用户确认已发布成功)
+   */
+  async confirmPublishResult(userId: number, taskId: number, resultId: number): Promise<void> {
+    const task = await this.taskRepository.findOne({
+      where: { id: taskId, userId },
+      relations: ['results'],
+    });
+    if (!task) {
+      throw new AppError('任务不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
+    }
+    const result = task.results?.find(r => r.id === resultId);
+    if (!result) {
+      throw new AppError('发布结果不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
+    }
+    await this.resultRepository.update(resultId, {
+      status: 'success',
+      errorMessage: null,
+      publishedAt: new Date(),
+    });
+    // 检查是否所有结果都成功,如是则更新任务状态
+    const updatedResults = await this.resultRepository.find({ where: { taskId } });
+    const allSuccess = updatedResults.every(r => r.status === 'success');
+    if (allSuccess) {
+      await this.taskRepository.update(taskId, { status: 'completed', publishedAt: new Date() });
+    }
+  }
+
   private formatTaskDetail(task: PublishTask): PublishTaskDetail {
     return {
       ...this.formatTask(task),