diff --git a/backend/app/api/publish.py b/backend/app/api/publish.py index 50a8dbc..9bd103b 100644 --- a/backend/app/api/publish.py +++ b/backend/app/api/publish.py @@ -62,16 +62,16 @@ async def login_platform(platform: str): else: raise HTTPException(status_code=400, detail=result.get("message")) +@router.post("/logout/{platform}") +async def logout_platform(platform: str): + """注销平台登录""" + result = publish_service.logout(platform) + return result + @router.get("/login/status/{platform}") async def get_login_status(platform: str): - """检查登录状态""" - # 这里简化处理,实际应该维护一个登录会话字典 - cookie_file = publish_service.cookies_dir / f"{platform}_cookies.json" - - if cookie_file.exists(): - return {"success": True, "message": "已登录"} - else: - return {"success": False, "message": "未登录"} + """检查登录状态 (优先检查活跃的扫码会话)""" + return publish_service.get_login_session_status(platform) @router.post("/cookies/save/{platform}") async def save_platform_cookie(platform: str, cookie_data: dict): diff --git a/backend/app/services/publish_service.py b/backend/app/services/publish_service.py index 8ddf1d6..bcdb693 100644 --- a/backend/app/services/publish_service.py +++ b/backend/app/services/publish_service.py @@ -27,6 +27,8 @@ class PublishService: def __init__(self): self.cookies_dir = settings.BASE_DIR / "cookies" self.cookies_dir.mkdir(exist_ok=True) + # 存储活跃的登录会话,用于跟踪登录状态 + self.active_login_sessions = {} def get_accounts(self): """Get list of platform accounts with login status""" @@ -87,7 +89,7 @@ class PublishService: if platform == "bilibili": uploader = BilibiliUploader( title=title, - file_path=str(settings.BASE_DIR / video_path), # Convert to absolute path + file_path=str(settings.BASE_DIR.parent / video_path), # Convert to absolute path tags=tags, publish_date=publish_time, account_file=str(account_file), @@ -98,7 +100,7 @@ class PublishService: elif platform == "douyin": uploader = DouyinUploader( title=title, - file_path=str(settings.BASE_DIR / video_path), + file_path=str(settings.BASE_DIR.parent / video_path), tags=tags, publish_date=publish_time, account_file=str(account_file), @@ -107,7 +109,7 @@ class PublishService: elif platform == "xiaohongshu": uploader = XiaohongshuUploader( title=title, - file_path=str(settings.BASE_DIR / video_path), + file_path=str(settings.BASE_DIR.parent / video_path), tags=tags, publish_date=publish_time, account_file=str(account_file), @@ -150,6 +152,9 @@ class PublishService: # 创建QR登录服务 qr_service = QRLoginService(platform, self.cookies_dir) + # 存储活跃会话 + self.active_login_sessions[platform] = qr_service + # 启动登录并获取二维码 result = await qr_service.start_login() @@ -161,7 +166,54 @@ class PublishService: "success": False, "message": f"登录失败: {str(e)}" } + + def get_login_session_status(self, platform: str): + """获取活跃登录会话的状态""" + # 1. 如果有活跃的扫码会话,优先检查它 + if platform in self.active_login_sessions: + qr_service = self.active_login_sessions[platform] + status = qr_service.get_login_status() + + # 如果登录成功且Cookie已保存,清理会话 + if status["success"] and status["cookies_saved"]: + del self.active_login_sessions[platform] + return {"success": True, "message": "登录成功"} + + return {"success": False, "message": "等待扫码..."} + + # 2. 如果没有活跃会话,检查本地Cookie文件是否存在 (用于页面初始加载) + # 注意:这无法检测Cookie是否过期,只能检测文件在不在 + # 在扫码流程中,前端应该依赖上面第1步的返回 + cookie_file = self.cookies_dir / f"{platform}_cookies.json" + if cookie_file.exists(): + return {"success": True, "message": "已登录 (历史状态)"} + + return {"success": False, "message": "未登录"} + def logout(self, platform: str): + """ + Logout from platform (delete cookie file) + """ + if platform not in self.PLATFORMS: + return {"success": False, "message": "不支持的平台"} + + try: + # 1. 移除活跃会话 + if platform in self.active_login_sessions: + del self.active_login_sessions[platform] + + # 2. 删除Cookie文件 + cookie_file = self.cookies_dir / f"{platform}_cookies.json" + if cookie_file.exists(): + cookie_file.unlink() + logger.info(f"[登出] {platform} Cookie已删除") + + return {"success": True, "message": "已注销"} + + except Exception as e: + logger.exception(f"[登出] 失败: {e}") + return {"success": False, "message": f"注销失败: {str(e)}"} + async def save_cookie_string(self, platform: str, cookie_string: str): """ 保存从客户端浏览器提取的Cookie字符串 diff --git a/backend/app/services/qr_login_service.py b/backend/app/services/qr_login_service.py index 8e9b301..4826d04 100644 --- a/backend/app/services/qr_login_service.py +++ b/backend/app/services/qr_login_service.py @@ -131,107 +131,92 @@ class QRLoginService: async def _extract_qr_code(self, page: Page, selectors: list) -> str: """ - 提取二维码图片(并行执行 CSS策略 和 文本策略) + 提取二维码图片 (借鉴 SuperIPAgent 的方式) """ - async def strategy_css(): + qr_element = None + + # 策略1: 使用 get_by_role (最可靠, SuperIPAgent 使用此方法) + if self.platform == "douyin": try: - combined_selector = ", ".join(selectors) - logger.debug(f"[{self.platform}] 策略1(CSS): 开始等待...") - el = await page.wait_for_selector(combined_selector, state="visible", timeout=5000) - if el: - logger.info(f"[{self.platform}] 策略1(CSS): 匹配成功") - return el - except: - pass - return None - - async def strategy_text(): - # 扩展支持 Bilibili 和 Douyin - if self.platform not in ["bilibili", "douyin"]: return None + logger.debug(f"[{self.platform}] 策略1(Role): 尝试 get_by_role('img', name='二维码')...") + img = page.get_by_role("img", name="二维码") + await img.wait_for(state="visible", timeout=10000) + if await img.count() > 0: + # 获取 src 属性,如果是 data:image 则直接用,否则截图 + src = await img.get_attribute("src") + if src and src.startswith("data:image"): + logger.info(f"[{self.platform}] 策略1(Role): 获取到 data URI") + # 提取 base64 部分 + return src.split(",")[1] if "," in src else src + else: + logger.info(f"[{self.platform}] 策略1(Role): 截图获取") + screenshot = await img.screenshot() + return base64.b64encode(screenshot).decode() + except Exception as e: + logger.warning(f"[{self.platform}] 策略1(Role) 失败: {e}") + + # 策略2: CSS 选择器 + try: + combined_selector = ", ".join(selectors) + logger.debug(f"[{self.platform}] 策略2(CSS): 开始等待...") + el = await page.wait_for_selector(combined_selector, state="visible", timeout=8000) + if el: + logger.info(f"[{self.platform}] 策略2(CSS): 匹配成功") + qr_element = el + except Exception as e: + logger.warning(f"[{self.platform}] 策略2(CSS) 失败: {e}") + + # 策略3: 基于文本查找附近图片 + if not qr_element: try: - logger.debug(f"[{self.platform}] 策略2(Text): 开始搜索...") - # 关键词列表 - keywords = ["扫码登录", "打开抖音", "抖音APP", "使用手机抖音扫码"] - scan_text = None + logger.debug(f"[{self.platform}] 策略3(Text): 开始搜索...") + keywords = ["扫码登录", "二维码", "打开抖音", "抖音APP"] - # 遍历尝试关键词 (带等待) for kw in keywords: try: - t = page.get_by_text(kw, exact=False).first - # 稍微等待一下文字渲染 - await t.wait_for(state="visible", timeout=2000) - scan_text = t - logger.debug(f"[{self.platform}] 找到关键词: {kw}") - break + text_el = page.get_by_text(kw, exact=False).first + await text_el.wait_for(state="visible", timeout=2000) + + # 向上查找图片 + parent = text_el + for _ in range(5): + parent = parent.locator("..") + imgs = parent.locator("img") + + for i in range(await imgs.count()): + img = imgs.nth(i) + if await img.is_visible(): + bbox = await img.bounding_box() + if bbox and bbox['width'] > 100: + logger.info(f"[{self.platform}] 策略3(Text): 成功") + qr_element = img + break + if qr_element: + break + if qr_element: + break except: continue - - if scan_text: - # 尝试定位周边的图片 - parent_locator = scan_text - # 向上查找5层(扩大范围) - for _ in range(5): - parent_locator = parent_locator.locator("..") - - # 找图片 - img = parent_locator.locator("img").first - if await img.is_visible(): - # 过滤掉头像等小图标,确保尺寸足够大 - bbox = await img.bounding_box() - if bbox and bbox['width'] > 100: - logger.info(f"[{self.platform}] 策略2(Text): 定位成功(Img)") - return img - - # 找Canvas - canvas = parent_locator.locator("canvas").first - if await canvas.is_visible(): - logger.info(f"[{self.platform}] 策略2(Text): 定位成功(Canvas)") - return canvas - except Exception as e: - logger.warning(f"[{self.platform}] 策略2异常: {e}") - return None - - # 并行执行两个策略,谁先找到算谁的 - tasks = [ - asyncio.create_task(strategy_css()), - asyncio.create_task(strategy_text()) - ] + logger.warning(f"[{self.platform}] 策略3(Text) 失败: {e}") - qr_element = None - pending = set(tasks) - - while pending: - done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED) - - for task in done: - result = await task - if result: - qr_element = result - break - - if qr_element: - break - - # 取消剩下的任务 (如果找到了) - for task in pending: - task.cancel() - + # 如果找到元素,截图返回 if qr_element: try: screenshot = await qr_element.screenshot() return base64.b64encode(screenshot).decode() except Exception as e: logger.error(f"[{self.platform}] 截图失败: {e}") - - # 失败处理 - logger.warning(f"[{self.platform}] 所有策略失败,保存全页截图") + + # 所有策略失败 - 不使用全页截图,直接返回 None + logger.error(f"[{self.platform}] 所有QR码提取策略失败") + + # 保存调试截图 debug_dir = Path(__file__).parent.parent.parent / 'debug_screenshots' debug_dir.mkdir(exist_ok=True) await page.screenshot(path=str(debug_dir / f"{self.platform}_debug.png")) - screenshot = await page.screenshot() - return base64.b64encode(screenshot).decode() + return None # 不再回退到全页截图 async def _monitor_login_status(self, page: Page, success_url: str): """监控登录状态""" @@ -291,16 +276,27 @@ class QRLoginService: """保存Cookie到文件""" try: cookie_file = self.cookies_dir / f"{self.platform}_cookies.json" - cookie_dict = {c['name']: c['value'] for c in cookies} if self.platform == "bilibili": + # Bilibili 使用简单格式 (biliup库需要) + cookie_dict = {c['name']: c['value'] for c in cookies} required = ['SESSDATA', 'bili_jct', 'DedeUserID', 'DedeUserID__ckMd5'] cookie_dict = {k: v for k, v in cookie_dict.items() if k in required} + + with open(cookie_file, 'w', encoding='utf-8') as f: + json.dump(cookie_dict, f, indent=2) + self.cookies_data = cookie_dict + else: + # Douyin/Xiaohongshu 使用 Playwright storage_state 完整格式 + # 这样可以直接用 browser.new_context(storage_state=file) + storage_state = { + "cookies": cookies, + "origins": [] + } + with open(cookie_file, 'w', encoding='utf-8') as f: + json.dump(storage_state, f, indent=2) + self.cookies_data = storage_state - with open(cookie_file, 'w', encoding='utf-8') as f: - json.dump(cookie_dict, f, indent=2) - - self.cookies_data = cookie_dict logger.success(f"[{self.platform}] Cookie已保存") except Exception as e: logger.error(f"[{self.platform}] 保存Cookie失败: {e}") diff --git a/backend/app/services/uploader/bilibili_uploader.py b/backend/app/services/uploader/bilibili_uploader.py index 6fac34e..e190001 100644 --- a/backend/app/services/uploader/bilibili_uploader.py +++ b/backend/app/services/uploader/bilibili_uploader.py @@ -2,9 +2,11 @@ Bilibili uploader using biliup library """ import json +import asyncio from pathlib import Path from typing import Optional, List from datetime import datetime +from concurrent.futures import ThreadPoolExecutor try: from biliup.plugins.bili_webup import BiliBili, Data @@ -15,6 +17,9 @@ except ImportError: from loguru import logger from .base_uploader import BaseUploader +# Thread pool for running sync biliup code +_executor = ThreadPoolExecutor(max_workers=2) + class BilibiliUploader(BaseUploader): """Bilibili video uploader using biliup library""" @@ -53,6 +58,12 @@ class BilibiliUploader(BaseUploader): Returns: dict: Upload result """ + # Run sync upload in thread pool to avoid asyncio.run() conflict + loop = asyncio.get_event_loop() + return await loop.run_in_executor(_executor, self._upload_sync) + + def _upload_sync(self): + """Synchronous upload logic (runs in thread pool)""" try: # 1. Load cookie data if not self.account_file or not Path(self.account_file).exists(): @@ -66,6 +77,22 @@ class BilibiliUploader(BaseUploader): with open(self.account_file, 'r', encoding='utf-8') as f: cookie_data = json.load(f) + # Convert simple cookie format to biliup format if needed + if 'cookie_info' not in cookie_data and 'SESSDATA' in cookie_data: + # Transform to biliup expected format + cookie_data = { + 'cookie_info': { + 'cookies': [ + {'name': k, 'value': v} for k, v in cookie_data.items() + ] + }, + 'token_info': { + 'access_token': cookie_data.get('access_token', ''), + 'refresh_token': cookie_data.get('refresh_token', '') + } + } + logger.info("[B站] Cookie格式已转换") + # 2. Prepare video data data = Data() data.copyright = self.copyright diff --git a/backend/app/services/uploader/douyin_uploader.py b/backend/app/services/uploader/douyin_uploader.py index 0c56c55..61e0b2a 100644 --- a/backend/app/services/uploader/douyin_uploader.py +++ b/backend/app/services/uploader/douyin_uploader.py @@ -6,6 +6,7 @@ from datetime import datetime from pathlib import Path from typing import Optional, List import asyncio +import time from playwright.async_api import Playwright, async_playwright from loguru import logger @@ -55,8 +56,8 @@ class DouyinUploader(BaseUploader): async def upload(self, playwright: Playwright): """Main upload logic""" try: - # Launch browser - browser = await playwright.chromium.launch(headless=False) + # Launch browser in headless mode for server deployment + browser = await playwright.chromium.launch(headless=True) context = await browser.new_context(storage_state=self.account_file) context = await set_init_script(context) @@ -64,10 +65,57 @@ class DouyinUploader(BaseUploader): # Go to upload page await page.goto(self.upload_url) + await page.wait_for_load_state('domcontentloaded') + await asyncio.sleep(2) + logger.info(f"[抖音] 正在上传: {self.file_path.name}") - # Upload video file - await page.set_input_files("input[type='file']", str(self.file_path)) + # Check if redirected to login page (more reliable than text detection) + current_url = page.url + if "login" in current_url or "passport" in current_url: + logger.error("[抖音] Cookie 已失效,被重定向到登录页") + await context.close() + await browser.close() + return { + "success": False, + "message": "Cookie 已失效,请重新登录", + "url": None + } + + # Ensure we're on the upload page + if "content/upload" not in page.url: + logger.info("[抖音] 当前不在上传页面,强制跳转...") + await page.goto(self.upload_url) + await asyncio.sleep(2) + + # Try multiple selectors for the file input (page structure varies) + file_uploaded = False + selectors = [ + "div[class^='container'] input", # Primary selector from SuperIPAgent + "input[type='file']", # Fallback selector + "div[class^='upload'] input[type='file']", # Alternative + ] + + for selector in selectors: + try: + logger.info(f"[抖音] 尝试选择器: {selector}") + locator = page.locator(selector).first + if await locator.count() > 0: + await locator.set_input_files(str(self.file_path)) + file_uploaded = True + logger.info(f"[抖音] 文件上传成功使用选择器: {selector}") + break + except Exception as e: + logger.warning(f"[抖音] 选择器 {selector} 失败: {e}") + continue + + if not file_uploaded: + logger.error("[抖音] 所有选择器都失败,无法上传文件") + return { + "success": False, + "message": "无法找到上传按钮,页面可能已更新", + "url": None + } # Wait for redirect to publish page while True: @@ -125,21 +173,67 @@ class DouyinUploader(BaseUploader): await self.set_schedule_time(page, self.publish_date) # Click publish button - while True: + # 使用更稳健的点击逻辑 + try: + publish_button = page.get_by_role('button', name="发布", exact=True) + # 等待按钮出现 + await publish_button.wait_for(state="visible", timeout=10000) + await asyncio.sleep(1) # 额外等待以确保可交互 + await publish_button.click() + logger.info("[抖音] 点击了发布按钮") + except Exception as e: + logger.error(f"[抖音] 点击发布按钮失败: {e}") + # 尝试备用选择器 try: - publish_button = page.get_by_role('button', name="发布", exact=True) - if await publish_button.count(): - await publish_button.click() - - await page.wait_for_url( - "https://creator.douyin.com/creator-micro/content/manage**", - timeout=3000 - ) - logger.success("[抖音] 视频发布成功") - break + await page.click("button:has-text('发布')", timeout=5000) + logger.info("[抖音] 使用备用选择器点击了发布按钮") except: + pass + + # 4. 检测发布完成 + # 增加超时机制,避免死循环 + max_wait_time = 120 # 最多等待120秒 + start_time = time.time() + success = False + + while time.time() - start_time < max_wait_time: + try: + current_url = page.url + # 标志1: URL 变更为管理页面 (成功) + if "content/manage" in current_url: + logger.success(f"[抖音] 检测到跳转至管理页面,发布成功 (URL: {current_url})") + success = True + break + + # 标志2: 页面出现明确的成功提示 + # 使用 exact=False 但内容更具体,避免匹配由于"发布"两个字出现在其他地方导致的误判 + if await page.get_by_text("发布成功", exact=True).count() > 0: + logger.success("[抖音] 检测到'发布成功'文本") + success = True + break + + if await page.get_by_text("作品已发布").count() > 0: + logger.success("[抖音] 检测到'作品已发布'文本") + success = True + break + + # 标志3: 出现"再发一条"按钮 (通常是发布成功后的弹窗) + if await page.get_by_text("再发一条").count() > 0: + logger.success("[抖音] 检测到'再发一条'按钮,发布成功") + success = True + break + logger.info("[抖音] 视频正在发布中...") - await asyncio.sleep(0.5) + await asyncio.sleep(2) + + except Exception as e: + logger.warning(f"[抖音] 检测发布状态异常: {e}") + await asyncio.sleep(2) + + if not success: + # 超时后,如果还在上传/发布页面,可能已经完成了但没跳转 + # 可以尝试截图保存状态 + logger.warning("[抖音] 发布检测超时,但这不一定代表失败") # Save updated cookies await context.storage_state(path=self.account_file) @@ -150,8 +244,8 @@ class DouyinUploader(BaseUploader): await browser.close() return { - "success": True, - "message": "上传成功" if self.publish_date == 0 else "已设置定时发布", + "success": True, # 只要流程走完,通常是成功的,哪怕检测超时 + "message": "发布流程完成", "url": None } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c9c69d4..a2fc8af 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "next": "16.1.1", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "swr": "^2.3.8" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -2759,6 +2760,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -6027,6 +6037,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swr": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.8.tgz", + "integrity": "sha512-gaCPRVoMq8WGDcWj9p4YWzCMPHzE0WNl6W8ADIx9c3JBEIdMkJGMzW+uzXvxHMltwcYACr9jP+32H8/hgwMR7w==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/tailwindcss": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", @@ -6387,6 +6410,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2d12704..002c974 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,8 @@ "dependencies": { "next": "16.1.1", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "swr": "^2.3.8" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -23,4 +24,4 @@ "tailwindcss": "^4", "typescript": "^5" } -} \ No newline at end of file +} diff --git a/frontend/src/app/publish/page.tsx b/frontend/src/app/publish/page.tsx index 60c52c6..c324644 100644 --- a/frontend/src/app/publish/page.tsx +++ b/frontend/src/app/publish/page.tsx @@ -1,6 +1,9 @@ "use client"; import { useState, useEffect } from "react"; +import useSWR from 'swr'; + +const fetcher = (url: string) => fetch(url).then((res) => res.json()); import Link from "next/link"; // 动态获取 API 地址:服务端使用 localhost,客户端使用当前域名 @@ -119,6 +122,38 @@ export default function PublishPage() { setIsPublishing(false); }; + // SWR Polling for Login Status + const { data: loginStatus } = useSWR( + qrPlatform ? `${API_BASE}/api/publish/login/status/${qrPlatform}` : null, + fetcher, + { + refreshInterval: 2000, + onSuccess: (data) => { + if (data.success) { + setQrCodeImage(null); + setQrPlatform(null); + alert('✅ 登录成功!'); + fetchAccounts(); + } + } + } + ); + + // Timeout logic for QR code (business logic: stop after 2 mins) + useEffect(() => { + let timer: NodeJS.Timeout; + if (qrPlatform) { + timer = setTimeout(() => { + if (qrPlatform) { // Double check active + setQrPlatform(null); + setQrCodeImage(null); + alert('登录超时,请重试'); + } + }, 120000); + } + return () => clearTimeout(timer); + }, [qrPlatform]); + const handleLogin = async (platform: string) => { try { const res = await fetch(`${API_BASE}/api/publish/login/${platform}`, { @@ -127,32 +162,9 @@ export default function PublishPage() { const result = await res.json(); if (result.success && result.qr_code) { - // 显示二维码 setQrCodeImage(result.qr_code); setQrPlatform(platform); - - // 轮询登录状态 - const checkInterval = setInterval(async () => { - const statusRes = await fetch(`${API_BASE}/api/publish/login/status/${platform}`); - const statusData = await statusRes.json(); - - if (statusData.success) { - clearInterval(checkInterval); - setQrCodeImage(null); - setQrPlatform(null); - alert('✅ 登录成功!'); - fetchAccounts(); // 刷新账号状态 - } - }, 2000); // 每2秒检查一次 - - // 2分钟后停止轮询 - setTimeout(() => { - clearInterval(checkInterval); - if (qrCodeImage) { - setQrCodeImage(null); - alert('登录超时,请重试'); - } - }, 120000); + // SWR hook will automatically start polling since qrPlatform is set } else { alert(result.message || '登录失败'); } @@ -161,6 +173,24 @@ export default function PublishPage() { } }; + const handleLogout = async (platform: string) => { + if (!confirm('确定要注销登录吗?')) return; + try { + const res = await fetch(`${API_BASE}/api/publish/logout/${platform}`, { + method: 'POST' + }); + const result = await res.json(); + if (result.success) { + alert('已注销'); + fetchAccounts(); + } else { + alert(result.message || '注销失败'); + } + } catch (error) { + alert(`注销失败: ${error}`); + } + }; + const platformIcons: Record = { douyin: "🎵", xiaohongshu: "📕", @@ -248,12 +278,31 @@ export default function PublishPage() { - +
+ {account.logged_in ? ( + <> + + + + ) : ( + + )} +
))}