更新
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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字符串
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
34
frontend/package-lock.json
generated
34
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
douyin: "🎵",
|
||||
xiaohongshu: "📕",
|
||||
@@ -248,12 +278,31 @@ export default function PublishPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleLogin(account.platform)}
|
||||
className="px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg transition-colors"
|
||||
>
|
||||
🔐 扫码登录
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
{account.logged_in ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleLogin(account.platform)}
|
||||
className="px-3 py-1 bg-white/10 hover:bg-white/20 text-white text-sm rounded-lg transition-colors"
|
||||
>
|
||||
↻ 重新登录
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleLogout(account.platform)}
|
||||
className="px-3 py-1 bg-red-500/80 hover:bg-red-600 text-white text-sm rounded-lg transition-colors"
|
||||
>
|
||||
注销
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleLogin(account.platform)}
|
||||
className="px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg transition-colors"
|
||||
>
|
||||
🔐 扫码登录
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user