返回 AiToEarn
AccountsTab.tsx
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
510 lines Plain Text