| 1 | /** |
| 2 | * AccountsTab - 平台账号 Tab 组件 |
| 3 | * 显示插件支持的平台账号列表及其状态 |
| 4 | */ |
| 5 | |
| 6 | 'use client' |
| 7 | |
| 8 | import type { PluginPlatformType } from '@/store/plugin/types/baseTypes' |
| 9 | import type { PlatAccountInfo } from '@/store/plugin/types/plat.type' |
| 10 | import { BookOpen, CheckCircle, CircleHelp, ExternalLink, RefreshCw } from 'lucide-react' |
| 11 | import Link from 'next/link' |
| 12 | import { useCallback, useEffect, useRef, useState } from 'react' |
| 13 | import { Trans, useTranslation } from 'react-i18next' |
| 14 | import { useShallow } from 'zustand/react/shallow' |
| 15 | import { PlatType } from '@/app/config/platConfig' |
| 16 | import { useChannelManagerStore } from '@/components/ChannelManager' |
| 17 | import AvatarPlat from '@/components/common/AvatarPlat' |
| 18 | import { Avatar, AvatarFallback } from '@/components/ui/avatar' |
| 19 | import { Badge } from '@/components/ui/badge' |
| 20 | import { Button } from '@/components/ui/button' |
| 21 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' |
| 22 | import { useAccountStore } from '@/store/account' |
| 23 | import { getPlatformInfoSync } from '@/store/platformMetadata' |
| 24 | import { getXhsLoginStatus, isPluginPlatformAccountReady, usePluginStore } from '@/store/plugin' |
| 25 | import { PLUGIN_SUPPORTED_PLATFORMS } from '@/store/plugin/types/baseTypes' |
| 26 | import { cn } from '@/utils/className' |
| 27 | import { toast } from '@/utils/ui/toast' |
| 28 | |
| 29 | interface AccountsTabProps { |
| 30 | /** 需要高亮的平台 */ |
| 31 | highlightPlatform?: string | null |
| 32 | } |
| 33 | |
| 34 | type PlatformLoginTarget = 'home' | 'creator' |
| 35 | type XhsLoginStage = 'none' | 'homeOnly' | 'creatorOnly' | 'ready' |
| 36 | |
| 37 | const XHS_CREATOR_LOGIN_URL = '//creator.xiaohongshu.com/?source=official' |
| 38 | const PLUGIN_PLATFORM_HOME_URLS: Record<PluginPlatformType, string> = { |
| 39 | [PlatType.Xhs]: '//www.xiaohongshu.com/', |
| 40 | [PlatType.WxSph]: 'https://channels.weixin.qq.com/', |
| 41 | } |
| 42 | const AUTH_POLLING_INTERVAL = 2000 |
| 43 | const AUTH_POLLING_MAX_ATTEMPTS = 60 |
| 44 | const AUTH_POLLING_PLATFORMS: readonly PluginPlatformType[] = [PlatType.Xhs, PlatType.WxSph] |
| 45 | |
| 46 | function shouldStartAuthPolling(platform: PluginPlatformType) { |
| 47 | return AUTH_POLLING_PLATFORMS.includes(platform) |
| 48 | } |
| 49 | |
| 50 | /** |
| 51 | * 获取平台显示名称 |
| 52 | */ |
| 53 | function getPlatformName(platform: PluginPlatformType): string { |
| 54 | const platInfo = getPlatformInfoSync(platform) |
| 55 | return platInfo?.name || platform |
| 56 | } |
| 57 | |
| 58 | /** |
| 59 | * 获取平台登录链接 |
| 60 | */ |
| 61 | function getPlatformLoginUrl(platform: PluginPlatformType, target: PlatformLoginTarget = 'home') { |
| 62 | if (platform === PlatType.Xhs && target === 'creator') { |
| 63 | return XHS_CREATOR_LOGIN_URL |
| 64 | } |
| 65 | |
| 66 | return PLUGIN_PLATFORM_HOME_URLS[platform] |
| 67 | } |
| 68 | |
| 69 | function renderXhsLoginTip( |
| 70 | account: PlatAccountInfo | null, |
| 71 | t: ReturnType<typeof useTranslation>['t'], |
| 72 | ) { |
| 73 | const xhsLoginStage = getXhsLoginStage(account) |
| 74 | |
| 75 | if (xhsLoginStage === 'homeOnly') { |
| 76 | return ( |
| 77 | <span className="text-xs leading-5 text-warning"> |
| 78 | {t('header.xhsHomeLoggedInOnlyTip')} |
| 79 | </span> |
| 80 | ) |
| 81 | } |
| 82 | |
| 83 | if (xhsLoginStage === 'creatorOnly') { |
| 84 | return ( |
| 85 | <span className="text-xs leading-5 text-warning"> |
| 86 | {t('header.xhsCreatorLoggedInOnlyTip')} |
| 87 | </span> |
| 88 | ) |
| 89 | } |
| 90 | |
| 91 | return ( |
| 92 | <span className="text-xs leading-5 text-warning"> |
| 93 | <Trans |
| 94 | i18nKey="header.xhsNotLoggedInTip" |
| 95 | ns="plugin" |
| 96 | components={{ |
| 97 | creatorLink: ( |
| 98 | <a |
| 99 | href={XHS_CREATOR_LOGIN_URL} |
| 100 | target="_blank" |
| 101 | rel="noreferrer" |
| 102 | className="cursor-pointer underline underline-offset-2 transition-opacity hover:opacity-80" |
| 103 | /> |
| 104 | ), |
| 105 | homeLink: ( |
| 106 | <a |
| 107 | href={getPlatformLoginUrl(PlatType.Xhs)} |
| 108 | target="_blank" |
| 109 | rel="noreferrer" |
| 110 | className="cursor-pointer underline underline-offset-2 transition-opacity hover:opacity-80" |
| 111 | /> |
| 112 | ), |
| 113 | }} |
| 114 | /> |
| 115 | </span> |
| 116 | ) |
| 117 | } |
| 118 | |
| 119 | function getXhsLoginStage(account: PlatAccountInfo | null): XhsLoginStage { |
| 120 | const xhsLoginStatus = getXhsLoginStatus(account) |
| 121 | |
| 122 | if (!xhsLoginStatus) { |
| 123 | return 'none' |
| 124 | } |
| 125 | |
| 126 | if (xhsLoginStatus.home && xhsLoginStatus.creator) { |
| 127 | return 'ready' |
| 128 | } |
| 129 | |
| 130 | if (xhsLoginStatus.home) { |
| 131 | return 'homeOnly' |
| 132 | } |
| 133 | |
| 134 | if (xhsLoginStatus.creator) { |
| 135 | return 'creatorOnly' |
| 136 | } |
| 137 | |
| 138 | return 'none' |
| 139 | } |
| 140 | |
| 141 | function getXhsStatusText( |
| 142 | account: PlatAccountInfo | null, |
| 143 | t: ReturnType<typeof useTranslation>['t'], |
| 144 | ) { |
| 145 | const xhsLoginStage = getXhsLoginStage(account) |
| 146 | |
| 147 | if (xhsLoginStage === 'homeOnly') { |
| 148 | return t('header.xhsHomeLoggedInOnlyStatus') |
| 149 | } |
| 150 | |
| 151 | if (xhsLoginStage === 'creatorOnly') { |
| 152 | return t('header.xhsCreatorLoggedInOnlyStatus') |
| 153 | } |
| 154 | |
| 155 | return t('header.notLoggedIn') |
| 156 | } |
| 157 | |
| 158 | /** |
| 159 | * 平台账号 Tab 组件 |
| 160 | */ |
| 161 | export function AccountsTab({ highlightPlatform }: AccountsTabProps) { |
| 162 | const { t } = useTranslation('plugin') |
| 163 | const { platformAccounts, refreshAllPlatformAccounts, syncAccountToDatabase } = usePluginStore( |
| 164 | useShallow(state => ({ |
| 165 | platformAccounts: state.platformAccounts, |
| 166 | refreshAllPlatformAccounts: state.refreshAllPlatformAccounts, |
| 167 | syncAccountToDatabase: state.syncAccountToDatabase, |
| 168 | })), |
| 169 | ) |
| 170 | const closePluginModal = usePluginStore(state => state.closePluginModal) |
| 171 | const closeChannelManager = useChannelManagerStore(state => state.closeModal) |
| 172 | |
| 173 | const [syncLoading, setSyncLoading] = useState<PluginPlatformType | null>(null) |
| 174 | const [authPollingPlatform, setAuthPollingPlatform] = useState<PluginPlatformType | null>(null) |
| 175 | const authPollingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) |
| 176 | const authPollingPlatformRef = useRef<PluginPlatformType | null>(null) |
| 177 | const authPollingAttemptsRef = useRef(0) |
| 178 | const authPollingSessionRef = useRef(0) |
| 179 | |
| 180 | const clearAuthPolling = useCallback(() => { |
| 181 | if (authPollingTimerRef.current) { |
| 182 | clearTimeout(authPollingTimerRef.current) |
| 183 | authPollingTimerRef.current = null |
| 184 | } |
| 185 | |
| 186 | authPollingPlatformRef.current = null |
| 187 | authPollingAttemptsRef.current = 0 |
| 188 | authPollingSessionRef.current += 1 |
| 189 | }, []) |
| 190 | |
| 191 | const stopAuthPolling = useCallback(() => { |
| 192 | clearAuthPolling() |
| 193 | setAuthPollingPlatform(null) |
| 194 | }, [clearAuthPolling]) |
| 195 | |
| 196 | const syncPlatformAccount = useCallback(async (platform: PluginPlatformType) => { |
| 197 | setSyncLoading(platform) |
| 198 | |
| 199 | try { |
| 200 | const defaultGroup = useAccountStore.getState().accountGroupList.find(g => g.isDefault) |
| 201 | const result = await syncAccountToDatabase(platform, defaultGroup?.id) |
| 202 | |
| 203 | if (result) { |
| 204 | toast.success(t('header.syncSuccess')) |
| 205 | } |
| 206 | else { |
| 207 | toast.error(t('header.syncFailed')) |
| 208 | } |
| 209 | } |
| 210 | catch (error) { |
| 211 | console.error('同步账号失败:', error) |
| 212 | toast.error(t('header.syncFailed')) |
| 213 | } |
| 214 | finally { |
| 215 | setSyncLoading(current => current === platform ? null : current) |
| 216 | } |
| 217 | }, [syncAccountToDatabase, t]) |
| 218 | |
| 219 | const pollPlatformAuthStatus = useCallback(async ( |
| 220 | platform: PluginPlatformType, |
| 221 | sessionId: number, |
| 222 | ) => { |
| 223 | authPollingAttemptsRef.current += 1 |
| 224 | |
| 225 | try { |
| 226 | await refreshAllPlatformAccounts() |
| 227 | } |
| 228 | catch (error) { |
| 229 | console.warn('轮询平台授权状态失败:', error) |
| 230 | } |
| 231 | |
| 232 | const isPollingCurrentPlatform = authPollingPlatformRef.current === platform |
| 233 | && authPollingSessionRef.current === sessionId |
| 234 | const account = usePluginStore.getState().platformAccounts[platform] |
| 235 | |
| 236 | if (!isPollingCurrentPlatform) |
| 237 | return |
| 238 | |
| 239 | if (isPluginPlatformAccountReady(account)) { |
| 240 | await syncPlatformAccount(platform) |
| 241 | stopAuthPolling() |
| 242 | return |
| 243 | } |
| 244 | |
| 245 | if (authPollingAttemptsRef.current >= AUTH_POLLING_MAX_ATTEMPTS) { |
| 246 | stopAuthPolling() |
| 247 | return |
| 248 | } |
| 249 | |
| 250 | authPollingTimerRef.current = setTimeout(() => { |
| 251 | void pollPlatformAuthStatus(platform, sessionId) |
| 252 | }, AUTH_POLLING_INTERVAL) |
| 253 | }, [refreshAllPlatformAccounts, stopAuthPolling, syncPlatformAccount]) |
| 254 | |
| 255 | const startAuthPolling = useCallback((platform: PluginPlatformType) => { |
| 256 | if (!shouldStartAuthPolling(platform)) |
| 257 | return |
| 258 | |
| 259 | clearAuthPolling() |
| 260 | const sessionId = authPollingSessionRef.current + 1 |
| 261 | authPollingSessionRef.current = sessionId |
| 262 | authPollingPlatformRef.current = platform |
| 263 | setAuthPollingPlatform(platform) |
| 264 | void pollPlatformAuthStatus(platform, sessionId) |
| 265 | }, [clearAuthPolling, pollPlatformAuthStatus]) |
| 266 | |
| 267 | const renderAuthPollingStatus = useCallback((platform: PluginPlatformType) => { |
| 268 | if (authPollingPlatform !== platform) |
| 269 | return null |
| 270 | |
| 271 | return ( |
| 272 | <div className="flex items-center gap-2 rounded-full border border-primary/15 bg-primary/5 px-2.5 py-1 text-xs text-primary shadow-sm shadow-primary/5"> |
| 273 | <RefreshCw className="h-3.5 w-3.5 animate-spin" /> |
| 274 | <span>{t('header.authPolling')}</span> |
| 275 | <Button |
| 276 | type="button" |
| 277 | variant="ghost" |
| 278 | size="sm" |
| 279 | className="h-6 cursor-pointer px-2 text-xs text-primary hover:bg-primary/10 hover:text-primary" |
| 280 | onClick={stopAuthPolling} |
| 281 | > |
| 282 | {t('common.cancel')} |
| 283 | </Button> |
| 284 | </div> |
| 285 | ) |
| 286 | }, [authPollingPlatform, stopAuthPolling, t]) |
| 287 | |
| 288 | useEffect(() => { |
| 289 | return () => { |
| 290 | clearAuthPolling() |
| 291 | } |
| 292 | }, [clearAuthPolling]) |
| 293 | |
| 294 | /** |
| 295 | * 处理同步账号到数据库 |
| 296 | */ |
| 297 | const handleSyncAccount = async (platform: PluginPlatformType) => { |
| 298 | await syncPlatformAccount(platform) |
| 299 | } |
| 300 | |
| 301 | /** |
| 302 | * 处理去登录 |
| 303 | */ |
| 304 | const handleGoLogin = ( |
| 305 | platform: PluginPlatformType, |
| 306 | target: PlatformLoginTarget = 'home', |
| 307 | ) => { |
| 308 | const loginUrl = getPlatformLoginUrl(platform, target) |
| 309 | if (loginUrl) { |
| 310 | window.open(loginUrl, '_blank') |
| 311 | startAuthPolling(platform) |
| 312 | } |
| 313 | } |
| 314 | |
| 315 | const handleViewGuide = () => { |
| 316 | closePluginModal() |
| 317 | closeChannelManager() |
| 318 | } |
| 319 | |
| 320 | return ( |
| 321 | <div className="flex flex-col gap-4 flex-1"> |
| 322 | {/* 顶部状态栏 */} |
| 323 | <div className="flex items-center gap-2 rounded-lg bg-success/10 px-4 py-3"> |
| 324 | <CheckCircle className="h-5 w-5 text-success" /> |
| 325 | <span className="text-sm text-success">{t('header.activeDescription')}</span> |
| 326 | </div> |
| 327 | |
| 328 | {/* 平台账号列表 */} |
| 329 | <div className="flex flex-col gap-3"> |
| 330 | {PLUGIN_SUPPORTED_PLATFORMS.map((platform) => { |
| 331 | const account = platformAccounts[platform] |
| 332 | const isConnected = isPluginPlatformAccountReady(account) |
| 333 | const loginUrl = getPlatformLoginUrl(platform) |
| 334 | const xhsLoginStage = platform === PlatType.Xhs |
| 335 | ? getXhsLoginStage(account) |
| 336 | : 'none' |
| 337 | const shouldHighlight = highlightPlatform?.toLowerCase() === platform.toLowerCase() |
| 338 | |
| 339 | return ( |
| 340 | <div |
| 341 | key={platform} |
| 342 | className={cn( |
| 343 | 'flex items-center justify-between gap-4 rounded-lg border p-4 transition-all', |
| 344 | shouldHighlight |
| 345 | ? 'border-primary bg-primary/10 shadow-md shadow-primary/10' |
| 346 | : 'border-border bg-card hover:border-border', |
| 347 | )} |
| 348 | > |
| 349 | {/* 左侧:账号信息 */} |
| 350 | <div className="flex min-w-0 flex-1 items-center gap-3"> |
| 351 | {account ? ( |
| 352 | <AvatarPlat |
| 353 | account={{ |
| 354 | type: platform, |
| 355 | avatar: account.avatar || '', |
| 356 | nickname: account.nickname || '', |
| 357 | uid: account.uid || '', |
| 358 | id: '0', |
| 359 | fansCount: account.fansCount || 0, |
| 360 | }} |
| 361 | size="large" |
| 362 | avatarWidth={44} |
| 363 | /> |
| 364 | ) : ( |
| 365 | <Avatar className="h-11 w-11"> |
| 366 | <AvatarFallback className="bg-muted text-muted-foreground"> |
| 367 | {getPlatformName(platform).charAt(0)} |
| 368 | </AvatarFallback> |
| 369 | </Avatar> |
| 370 | )} |
| 371 | |
| 372 | <div className="flex min-w-0 flex-col"> |
| 373 | <span className="truncate font-medium text-foreground">{getPlatformName(platform)}</span> |
| 374 | {isConnected ? ( |
| 375 | <span className="truncate text-sm text-muted-foreground"> |
| 376 | {account?.nickname || account?.uid} |
| 377 | </span> |
| 378 | ) : ( |
| 379 | <> |
| 380 | <span className="text-sm text-muted-foreground"> |
| 381 | {platform === PlatType.Xhs |
| 382 | ? getXhsStatusText(account, t) |
| 383 | : t('header.notLoggedIn')} |
| 384 | </span> |
| 385 | {platform === PlatType.Xhs && ( |
| 386 | renderXhsLoginTip(account, t) |
| 387 | )} |
| 388 | </> |
| 389 | )} |
| 390 | </div> |
| 391 | </div> |
| 392 | |
| 393 | {/* 右侧:操作按钮 */} |
| 394 | <div className="shrink-0"> |
| 395 | {isConnected ? ( |
| 396 | <div className="flex items-center gap-2"> |
| 397 | <Badge |
| 398 | variant="outline" |
| 399 | className="border-success/20 bg-success/10 text-success" |
| 400 | > |
| 401 | {t('status.connected')} |
| 402 | </Badge> |
| 403 | <Button |
| 404 | variant="ghost" |
| 405 | size="sm" |
| 406 | className="cursor-pointer gap-1 text-muted-foreground hover:text-foreground" |
| 407 | onClick={() => handleSyncAccount(platform)} |
| 408 | disabled={syncLoading === platform} |
| 409 | > |
| 410 | <RefreshCw |
| 411 | className={cn('h-4 w-4', syncLoading === platform && 'animate-spin')} |
| 412 | /> |
| 413 | {t('header.sync')} |
| 414 | </Button> |
| 415 | </div> |
| 416 | ) : ( |
| 417 | platform === PlatType.Xhs |
| 418 | ? ( |
| 419 | <div className="flex flex-col items-end gap-2"> |
| 420 | {xhsLoginStage === 'homeOnly' && ( |
| 421 | <Badge |
| 422 | variant="outline" |
| 423 | className="border-warning/20 bg-warning/10 text-warning" |
| 424 | > |
| 425 | {t('header.xhsNoteDetailReady')} |
| 426 | </Badge> |
| 427 | )} |
| 428 | <div className="flex flex-wrap justify-end gap-2"> |
| 429 | <Button |
| 430 | variant="outline" |
| 431 | size="sm" |
| 432 | className="cursor-pointer gap-1" |
| 433 | onClick={() => handleGoLogin(platform)} |
| 434 | > |
| 435 | <ExternalLink className="h-4 w-4" /> |
| 436 | {t('header.xhsHome')} |
| 437 | </Button> |
| 438 | <Button |
| 439 | variant="outline" |
| 440 | size="sm" |
| 441 | className="cursor-pointer gap-1" |
| 442 | onClick={() => handleGoLogin(platform, 'creator')} |
| 443 | > |
| 444 | <ExternalLink className="h-4 w-4" /> |
| 445 | {t('header.xhsCreator')} |
| 446 | </Button> |
| 447 | </div> |
| 448 | {renderAuthPollingStatus(platform)} |
| 449 | |
| 450 | <TooltipProvider delayDuration={200}> |
| 451 | <Tooltip> |
| 452 | <TooltipTrigger asChild> |
| 453 | <button |
| 454 | type="button" |
| 455 | className="inline-flex cursor-help items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground" |
| 456 | aria-label={t('header.xhsCreatorQuickLoginLabel')} |
| 457 | > |
| 458 | <CircleHelp className="h-3.5 w-3.5 text-warning" /> |
| 459 | <span>{t('header.xhsCreatorQuickLoginLabel')}</span> |
| 460 | </button> |
| 461 | </TooltipTrigger> |
| 462 | <TooltipContent |
| 463 | side="top" |
| 464 | align="end" |
| 465 | className="max-w-60 text-xs leading-5" |
| 466 | > |
| 467 | {t('header.xhsCreatorQuickLoginTip')} |
| 468 | </TooltipContent> |
| 469 | </Tooltip> |
| 470 | </TooltipProvider> |
| 471 | </div> |
| 472 | ) |
| 473 | : loginUrl |
| 474 | ? ( |
| 475 | <div className="flex flex-col items-end gap-2"> |
| 476 | <Button |
| 477 | variant="outline" |
| 478 | size="sm" |
| 479 | className="cursor-pointer gap-1" |
| 480 | onClick={() => handleGoLogin(platform)} |
| 481 | > |
| 482 | <ExternalLink className="h-4 w-4" /> |
| 483 | {t('header.loginNow')} |
| 484 | </Button> |
| 485 | {renderAuthPollingStatus(platform)} |
| 486 | </div> |
| 487 | ) |
| 488 | : null |
| 489 | )} |
| 490 | </div> |
| 491 | </div> |
| 492 | ) |
| 493 | })} |
| 494 | </div> |
| 495 | |
| 496 | {/* 插件使用教程链接 */} |
| 497 | <div className="flex justify-center pt-2"> |
| 498 | <Link |
| 499 | href="/websit/plugin-guide" |
| 500 | onClick={handleViewGuide} |
| 501 | className="flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 text-amber-700 dark:text-amber-400 hover:bg-amber-100 dark:hover:bg-amber-950/50 transition-colors cursor-pointer" |
| 502 | > |
| 503 | <BookOpen className="h-4 w-4" /> |
| 504 | <span className="text-sm font-medium">{t('header.viewGuide')}</span> |
| 505 | </Link> |
| 506 | </div> |
| 507 | </div> |
| 508 | ) |
| 509 | } |
| 510 |