This commit is contained in:
Kevin Wong
2026-01-22 17:15:42 +08:00
parent ad7ff7a385
commit 3a76f9d0cf
8 changed files with 399 additions and 148 deletions

View File

@@ -62,16 +62,16 @@ async def login_platform(platform: str):
else: else:
raise HTTPException(status_code=400, detail=result.get("message")) 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}") @router.get("/login/status/{platform}")
async def get_login_status(platform: str): async def get_login_status(platform: str):
"""检查登录状态""" """检查登录状态 (优先检查活跃的扫码会话)"""
# 这里简化处理,实际应该维护一个登录会话字典 return publish_service.get_login_session_status(platform)
cookie_file = publish_service.cookies_dir / f"{platform}_cookies.json"
if cookie_file.exists():
return {"success": True, "message": "已登录"}
else:
return {"success": False, "message": "未登录"}
@router.post("/cookies/save/{platform}") @router.post("/cookies/save/{platform}")
async def save_platform_cookie(platform: str, cookie_data: dict): async def save_platform_cookie(platform: str, cookie_data: dict):

View File

@@ -27,6 +27,8 @@ class PublishService:
def __init__(self): def __init__(self):
self.cookies_dir = settings.BASE_DIR / "cookies" self.cookies_dir = settings.BASE_DIR / "cookies"
self.cookies_dir.mkdir(exist_ok=True) self.cookies_dir.mkdir(exist_ok=True)
# 存储活跃的登录会话,用于跟踪登录状态
self.active_login_sessions = {}
def get_accounts(self): def get_accounts(self):
"""Get list of platform accounts with login status""" """Get list of platform accounts with login status"""
@@ -87,7 +89,7 @@ class PublishService:
if platform == "bilibili": if platform == "bilibili":
uploader = BilibiliUploader( uploader = BilibiliUploader(
title=title, 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, tags=tags,
publish_date=publish_time, publish_date=publish_time,
account_file=str(account_file), account_file=str(account_file),
@@ -98,7 +100,7 @@ class PublishService:
elif platform == "douyin": elif platform == "douyin":
uploader = DouyinUploader( uploader = DouyinUploader(
title=title, title=title,
file_path=str(settings.BASE_DIR / video_path), file_path=str(settings.BASE_DIR.parent / video_path),
tags=tags, tags=tags,
publish_date=publish_time, publish_date=publish_time,
account_file=str(account_file), account_file=str(account_file),
@@ -107,7 +109,7 @@ class PublishService:
elif platform == "xiaohongshu": elif platform == "xiaohongshu":
uploader = XiaohongshuUploader( uploader = XiaohongshuUploader(
title=title, title=title,
file_path=str(settings.BASE_DIR / video_path), file_path=str(settings.BASE_DIR.parent / video_path),
tags=tags, tags=tags,
publish_date=publish_time, publish_date=publish_time,
account_file=str(account_file), account_file=str(account_file),
@@ -150,6 +152,9 @@ class PublishService:
# 创建QR登录服务 # 创建QR登录服务
qr_service = QRLoginService(platform, self.cookies_dir) qr_service = QRLoginService(platform, self.cookies_dir)
# 存储活跃会话
self.active_login_sessions[platform] = qr_service
# 启动登录并获取二维码 # 启动登录并获取二维码
result = await qr_service.start_login() result = await qr_service.start_login()
@@ -162,6 +167,53 @@ class PublishService:
"message": f"登录失败: {str(e)}" "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): async def save_cookie_string(self, platform: str, cookie_string: str):
""" """
保存从客户端浏览器提取的Cookie字符串 保存从客户端浏览器提取的Cookie字符串

View File

@@ -131,92 +131,76 @@ class QRLoginService:
async def _extract_qr_code(self, page: Page, selectors: list) -> str: 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:
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: try:
combined_selector = ", ".join(selectors) combined_selector = ", ".join(selectors)
logger.debug(f"[{self.platform}] 策略1(CSS): 开始等待...") logger.debug(f"[{self.platform}] 策略2(CSS): 开始等待...")
el = await page.wait_for_selector(combined_selector, state="visible", timeout=5000) el = await page.wait_for_selector(combined_selector, state="visible", timeout=8000)
if el: if el:
logger.info(f"[{self.platform}] 策略1(CSS): 匹配成功") logger.info(f"[{self.platform}] 策略2(CSS): 匹配成功")
return el qr_element = el
except: except Exception as e:
pass logger.warning(f"[{self.platform}] 策略2(CSS) 失败: {e}")
return None
async def strategy_text(): # 策略3: 基于文本查找附近图片
# 扩展支持 Bilibili 和 Douyin if not qr_element:
if self.platform not in ["bilibili", "douyin"]: return None
try: try:
logger.debug(f"[{self.platform}] 策略2(Text): 开始搜索...") logger.debug(f"[{self.platform}] 策略3(Text): 开始搜索...")
# 关键词列表 keywords = ["扫码登录", "二维码", "打开抖音", "抖音APP"]
keywords = ["扫码登录", "打开抖音", "抖音APP", "使用手机抖音扫码"]
scan_text = None
# 遍历尝试关键词 (带等待)
for kw in keywords: for kw in keywords:
try: try:
t = page.get_by_text(kw, exact=False).first text_el = page.get_by_text(kw, exact=False).first
# 稍微等待一下文字渲染 await text_el.wait_for(state="visible", timeout=2000)
await t.wait_for(state="visible", timeout=2000)
scan_text = t # 向上查找图片
logger.debug(f"[{self.platform}] 找到关键词: {kw}") 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 break
except: except:
continue 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: except Exception as e:
logger.warning(f"[{self.platform}] 策略2异常: {e}") logger.warning(f"[{self.platform}] 策略3(Text) 失败: {e}")
return None
# 并行执行两个策略,谁先找到算谁的
tasks = [
asyncio.create_task(strategy_css()),
asyncio.create_task(strategy_text())
]
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: if qr_element:
try: try:
screenshot = await qr_element.screenshot() screenshot = await qr_element.screenshot()
@@ -224,14 +208,15 @@ class QRLoginService:
except Exception as e: except Exception as e:
logger.error(f"[{self.platform}] 截图失败: {e}") logger.error(f"[{self.platform}] 截图失败: {e}")
# 失败处理 # 所有策略失败 - 不使用全页截图,直接返回 None
logger.warning(f"[{self.platform}] 所有策略失败,保存全页截图") logger.error(f"[{self.platform}] 所有QR码提取策略失败")
# 保存调试截图
debug_dir = Path(__file__).parent.parent.parent / 'debug_screenshots' debug_dir = Path(__file__).parent.parent.parent / 'debug_screenshots'
debug_dir.mkdir(exist_ok=True) debug_dir.mkdir(exist_ok=True)
await page.screenshot(path=str(debug_dir / f"{self.platform}_debug.png")) await page.screenshot(path=str(debug_dir / f"{self.platform}_debug.png"))
screenshot = await page.screenshot() return None # 不再回退到全页截图
return base64.b64encode(screenshot).decode()
async def _monitor_login_status(self, page: Page, success_url: str): async def _monitor_login_status(self, page: Page, success_url: str):
"""监控登录状态""" """监控登录状态"""
@@ -291,16 +276,27 @@ class QRLoginService:
"""保存Cookie到文件""" """保存Cookie到文件"""
try: try:
cookie_file = self.cookies_dir / f"{self.platform}_cookies.json" cookie_file = self.cookies_dir / f"{self.platform}_cookies.json"
cookie_dict = {c['name']: c['value'] for c in cookies}
if self.platform == "bilibili": if self.platform == "bilibili":
# Bilibili 使用简单格式 (biliup库需要)
cookie_dict = {c['name']: c['value'] for c in cookies}
required = ['SESSDATA', 'bili_jct', 'DedeUserID', 'DedeUserID__ckMd5'] required = ['SESSDATA', 'bili_jct', 'DedeUserID', 'DedeUserID__ckMd5']
cookie_dict = {k: v for k, v in cookie_dict.items() if k in required} 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: with open(cookie_file, 'w', encoding='utf-8') as f:
json.dump(cookie_dict, f, indent=2) json.dump(cookie_dict, f, indent=2)
self.cookies_data = cookie_dict 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
logger.success(f"[{self.platform}] Cookie已保存") logger.success(f"[{self.platform}] Cookie已保存")
except Exception as e: except Exception as e:
logger.error(f"[{self.platform}] 保存Cookie失败: {e}") logger.error(f"[{self.platform}] 保存Cookie失败: {e}")

View File

@@ -2,9 +2,11 @@
Bilibili uploader using biliup library Bilibili uploader using biliup library
""" """
import json import json
import asyncio
from pathlib import Path from pathlib import Path
from typing import Optional, List from typing import Optional, List
from datetime import datetime from datetime import datetime
from concurrent.futures import ThreadPoolExecutor
try: try:
from biliup.plugins.bili_webup import BiliBili, Data from biliup.plugins.bili_webup import BiliBili, Data
@@ -15,6 +17,9 @@ except ImportError:
from loguru import logger from loguru import logger
from .base_uploader import BaseUploader from .base_uploader import BaseUploader
# Thread pool for running sync biliup code
_executor = ThreadPoolExecutor(max_workers=2)
class BilibiliUploader(BaseUploader): class BilibiliUploader(BaseUploader):
"""Bilibili video uploader using biliup library""" """Bilibili video uploader using biliup library"""
@@ -53,6 +58,12 @@ class BilibiliUploader(BaseUploader):
Returns: Returns:
dict: Upload result 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: try:
# 1. Load cookie data # 1. Load cookie data
if not self.account_file or not Path(self.account_file).exists(): 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: with open(self.account_file, 'r', encoding='utf-8') as f:
cookie_data = json.load(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 # 2. Prepare video data
data = Data() data = Data()
data.copyright = self.copyright data.copyright = self.copyright

View File

@@ -6,6 +6,7 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Optional, List from typing import Optional, List
import asyncio import asyncio
import time
from playwright.async_api import Playwright, async_playwright from playwright.async_api import Playwright, async_playwright
from loguru import logger from loguru import logger
@@ -55,8 +56,8 @@ class DouyinUploader(BaseUploader):
async def upload(self, playwright: Playwright): async def upload(self, playwright: Playwright):
"""Main upload logic""" """Main upload logic"""
try: try:
# Launch browser # Launch browser in headless mode for server deployment
browser = await playwright.chromium.launch(headless=False) browser = await playwright.chromium.launch(headless=True)
context = await browser.new_context(storage_state=self.account_file) context = await browser.new_context(storage_state=self.account_file)
context = await set_init_script(context) context = await set_init_script(context)
@@ -64,10 +65,57 @@ class DouyinUploader(BaseUploader):
# Go to upload page # Go to upload page
await page.goto(self.upload_url) await page.goto(self.upload_url)
await page.wait_for_load_state('domcontentloaded')
await asyncio.sleep(2)
logger.info(f"[抖音] 正在上传: {self.file_path.name}") logger.info(f"[抖音] 正在上传: {self.file_path.name}")
# Upload video file # Check if redirected to login page (more reliable than text detection)
await page.set_input_files("input[type='file']", str(self.file_path)) 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 # Wait for redirect to publish page
while True: while True:
@@ -125,21 +173,67 @@ class DouyinUploader(BaseUploader):
await self.set_schedule_time(page, self.publish_date) await self.set_schedule_time(page, self.publish_date)
# Click publish button # Click publish button
while True: # 使用更稳健的点击逻辑
try: try:
publish_button = page.get_by_role('button', name="发布", exact=True) publish_button = page.get_by_role('button', name="发布", exact=True)
if await publish_button.count(): # 等待按钮出现
await publish_button.wait_for(state="visible", timeout=10000)
await asyncio.sleep(1) # 额外等待以确保可交互
await publish_button.click() await publish_button.click()
logger.info("[抖音] 点击了发布按钮")
await page.wait_for_url( except Exception as e:
"https://creator.douyin.com/creator-micro/content/manage**", logger.error(f"[抖音] 点击发布按钮失败: {e}")
timeout=3000 # 尝试备用选择器
) try:
logger.success("[抖音] 视频发布成功") await page.click("button:has-text('发布')", timeout=5000)
break logger.info("[抖音] 使用备用选择器点击了发布按钮")
except: 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("[抖音] 视频正在发布中...") 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 # Save updated cookies
await context.storage_state(path=self.account_file) await context.storage_state(path=self.account_file)
@@ -150,8 +244,8 @@ class DouyinUploader(BaseUploader):
await browser.close() await browser.close()
return { return {
"success": True, "success": True, # 只要流程走完,通常是成功的,哪怕检测超时
"message": "上传成功" if self.publish_date == 0 else "已设置定时发布", "message": "发布流程完成",
"url": None "url": None
} }

View File

@@ -10,7 +10,8 @@
"dependencies": { "dependencies": {
"next": "16.1.1", "next": "16.1.1",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3",
"swr": "^2.3.8"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@@ -2759,6 +2760,15 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -6027,6 +6037,19 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/tailwindcss": {
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
@@ -6387,6 +6410,15 @@
"punycode": "^2.1.0" "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": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -11,7 +11,8 @@
"dependencies": { "dependencies": {
"next": "16.1.1", "next": "16.1.1",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3",
"swr": "^2.3.8"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",

View File

@@ -1,6 +1,9 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import useSWR from 'swr';
const fetcher = (url: string) => fetch(url).then((res) => res.json());
import Link from "next/link"; import Link from "next/link";
// 动态获取 API 地址:服务端使用 localhost客户端使用当前域名 // 动态获取 API 地址:服务端使用 localhost客户端使用当前域名
@@ -119,6 +122,38 @@ export default function PublishPage() {
setIsPublishing(false); 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) => { const handleLogin = async (platform: string) => {
try { try {
const res = await fetch(`${API_BASE}/api/publish/login/${platform}`, { const res = await fetch(`${API_BASE}/api/publish/login/${platform}`, {
@@ -127,32 +162,9 @@ export default function PublishPage() {
const result = await res.json(); const result = await res.json();
if (result.success && result.qr_code) { if (result.success && result.qr_code) {
// 显示二维码
setQrCodeImage(result.qr_code); setQrCodeImage(result.qr_code);
setQrPlatform(platform); setQrPlatform(platform);
// SWR hook will automatically start polling since qrPlatform is set
// 轮询登录状态
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);
} else { } else {
alert(result.message || '登录失败'); 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> = { const platformIcons: Record<string, string> = {
douyin: "🎵", douyin: "🎵",
xiaohongshu: "📕", xiaohongshu: "📕",
@@ -248,12 +278,31 @@ export default function PublishPage() {
</div> </div>
</div> </div>
</div> </div>
<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 <button
onClick={() => handleLogin(account.platform)} onClick={() => handleLogin(account.platform)}
className="px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg transition-colors" className="px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg transition-colors"
> >
🔐 🔐
</button> </button>
)}
</div>
</div> </div>
))} ))}
</div> </div>