| 1 | /** |
| 2 | * ChannelManager - 频道管理器状态管理 |
| 3 | * 管理三页面视图状态、授权状态、侧边栏状态等 |
| 4 | */ |
| 5 | |
| 6 | import type { |
| 7 | AuthState, |
| 8 | AuthUrlResponse, |
| 9 | ChannelManagerMethods, |
| 10 | ChannelManagerState, |
| 11 | ChannelManagerView, |
| 12 | } from './types' |
| 13 | import type { SocialAccount } from '@/api/accounts/account.types' |
| 14 | import type { ChannelAccountAuthStatus } from '@/api/channels/channel.types' |
| 15 | import type { PlatType } from '@/app/config/platConfig' |
| 16 | import type { PluginPlatformType } from '@/store/plugin' |
| 17 | import lodash from 'lodash' |
| 18 | import { create } from 'zustand' |
| 19 | import { combine } from 'zustand/middleware' |
| 20 | import { getChannelAccountAuthStatusApi, startChannelAccountAuthApi } from '@/api/channels/channel.api' |
| 21 | import i18next from '@/app/i18n/client' |
| 22 | import { useAccountStore } from '@/store/account' |
| 23 | import { getPlatformInfoSync, isPlatformDisabledSync, isPlatformEnabledSync } from '@/store/platformMetadata' |
| 24 | import { |
| 25 | isPluginPlatformAccountReady, |
| 26 | PLUGIN_SUPPORTED_PLATFORMS, |
| 27 | PluginStatus, |
| 28 | usePluginStore, |
| 29 | } from '@/store/plugin' |
| 30 | import { confirmPlatformRegionRedirect } from '@/utils/region/redirect' |
| 31 | import { toast } from '@/utils/ui/toast' |
| 32 | import { DEFAULT_AUTH_COUNTDOWN, POLLING_INTERVAL } from './types' |
| 33 | |
| 34 | /** |
| 35 | * 获取翻译文本 |
| 36 | */ |
| 37 | function t(key: string, options?: Record<string, string>): string { |
| 38 | return i18next.t(key, { ns: 'account', ...options }) |
| 39 | } |
| 40 | |
| 41 | function getPlatformName(platform: PlatType) { |
| 42 | return getPlatformInfoSync(platform)?.name || platform |
| 43 | } |
| 44 | |
| 45 | function notifyPlatformComingSoon(platform: PlatType) { |
| 46 | toast.warning(t('channelManager.platformComingSoon', { platform: getPlatformName(platform) })) |
| 47 | } |
| 48 | |
| 49 | /** |
| 50 | * 检查平台是否为插件支持的平台 |
| 51 | */ |
| 52 | function isPluginSupportedPlatform(platform: PlatType): platform is PluginPlatformType { |
| 53 | return PLUGIN_SUPPORTED_PLATFORMS.includes(platform as PluginPlatformType) |
| 54 | } |
| 55 | |
| 56 | /** 初始授权状态 */ |
| 57 | const initialAuthState: AuthState = { |
| 58 | platform: null, |
| 59 | sessionId: null, |
| 60 | authUrl: null, |
| 61 | authMode: 'oauth', |
| 62 | qrCodeDataUrl: null, |
| 63 | qrCodePath: null, |
| 64 | countdown: DEFAULT_AUTH_COUNTDOWN, |
| 65 | isPolling: false, |
| 66 | error: null, |
| 67 | isTimeout: false, |
| 68 | } |
| 69 | |
| 70 | /** 初始状态 */ |
| 71 | const initialState: ChannelManagerState = { |
| 72 | open: false, |
| 73 | currentView: 'main', |
| 74 | selectedPlatform: 'all', |
| 75 | authState: { ...initialAuthState }, |
| 76 | targetSpaceId: null, |
| 77 | onAuthSuccess: null, |
| 78 | isNewUser: false, |
| 79 | } |
| 80 | |
| 81 | function getInitialState(): ChannelManagerState { |
| 82 | return lodash.cloneDeep(initialState) |
| 83 | } |
| 84 | |
| 85 | /** 轮询定时器ID */ |
| 86 | let pollingTimerId: ReturnType<typeof setTimeout> | null = null |
| 87 | /** 倒计时定时器ID */ |
| 88 | let countdownIntervalId: ReturnType<typeof setInterval> | null = null |
| 89 | |
| 90 | /** |
| 91 | * 清理所有定时器 |
| 92 | */ |
| 93 | function clearAllTimers() { |
| 94 | if (pollingTimerId) { |
| 95 | clearTimeout(pollingTimerId) |
| 96 | pollingTimerId = null |
| 97 | } |
| 98 | if (countdownIntervalId) { |
| 99 | clearInterval(countdownIntervalId) |
| 100 | countdownIntervalId = null |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | /** |
| 105 | * 根据平台类型获取授权URL |
| 106 | */ |
| 107 | async function getAuthUrl(platform: PlatType, spaceId?: string): Promise<AuthUrlResponse | null> { |
| 108 | try { |
| 109 | const res = await startChannelAccountAuthApi(platform, { groupId: spaceId }) |
| 110 | |
| 111 | if (res?.code === 0 && res.data?.url && res.data.sessionId) { |
| 112 | return { |
| 113 | url: res.data.url, |
| 114 | sessionId: res.data.sessionId, |
| 115 | expiresAt: res.data.expiresAt, |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | return null |
| 120 | } |
| 121 | catch (error) { |
| 122 | console.error(`Failed to get auth URL for ${platform}:`, error) |
| 123 | return null |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | function getAuthCountdown(expiresAt?: string) { |
| 128 | if (!expiresAt) |
| 129 | return DEFAULT_AUTH_COUNTDOWN |
| 130 | |
| 131 | const expiresTime = new Date(expiresAt).getTime() |
| 132 | if (Number.isNaN(expiresTime)) |
| 133 | return DEFAULT_AUTH_COUNTDOWN |
| 134 | |
| 135 | return Math.max(0, Math.ceil((expiresTime - Date.now()) / 1000)) |
| 136 | } |
| 137 | |
| 138 | interface AuthStatusResult { |
| 139 | status: ChannelAccountAuthStatus['status'] |
| 140 | data?: ChannelAccountAuthStatus |
| 141 | message?: string |
| 142 | } |
| 143 | |
| 144 | async function checkAuthStatus( |
| 145 | platform: PlatType, |
| 146 | sessionId: string, |
| 147 | ): Promise<AuthStatusResult | null> { |
| 148 | try { |
| 149 | const res = await getChannelAccountAuthStatusApi(platform, sessionId) |
| 150 | |
| 151 | if (res?.code === 0 && res.data) { |
| 152 | return { |
| 153 | status: res.data.status, |
| 154 | data: res.data, |
| 155 | message: res.data.status === 'failed' ? res.message : undefined, |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | if (res?.message) { |
| 160 | return { status: 'failed', message: res.message } |
| 161 | } |
| 162 | |
| 163 | return null |
| 164 | } |
| 165 | catch (error) { |
| 166 | console.error(`Failed to check auth status for ${platform}:`, error) |
| 167 | return null |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | function getAuthAccountIds(data?: ChannelAccountAuthStatus) { |
| 172 | return [ |
| 173 | data?.accountId, |
| 174 | ...(data?.accountIds ?? []), |
| 175 | ...(data?.accounts?.map(account => account.accountId) ?? []), |
| 176 | ].filter((accountId): accountId is string => Boolean(accountId)) |
| 177 | } |
| 178 | |
| 179 | function findAuthorizedAccount( |
| 180 | accountList: SocialAccount[], |
| 181 | platform: PlatType, |
| 182 | data?: ChannelAccountAuthStatus, |
| 183 | ) { |
| 184 | const accountIds = getAuthAccountIds(data) |
| 185 | |
| 186 | if (accountIds.length > 0) { |
| 187 | const accountIdSet = new Set(accountIds) |
| 188 | const matchedAccount = accountList.find(account => accountIdSet.has(account.id)) |
| 189 | |
| 190 | if (matchedAccount) |
| 191 | return matchedAccount |
| 192 | } |
| 193 | |
| 194 | return accountList.find(account => account.type === platform) |
| 195 | } |
| 196 | |
| 197 | export const useChannelManagerStore = create( |
| 198 | combine(getInitialState(), (set, get) => { |
| 199 | const methods: ChannelManagerMethods = { |
| 200 | /** 打开弹框(默认显示主页) */ |
| 201 | openModal() { |
| 202 | const accountList = useAccountStore.getState().accountList |
| 203 | const isNewUser = accountList.length === 0 |
| 204 | |
| 205 | set({ |
| 206 | open: true, |
| 207 | currentView: isNewUser ? 'connect-list' : 'main', |
| 208 | isNewUser, |
| 209 | }) |
| 210 | }, |
| 211 | |
| 212 | /** 关闭弹框 */ |
| 213 | closeModal() { |
| 214 | // 停止所有授权相关的定时器 |
| 215 | methods.stopAuth() |
| 216 | set({ |
| 217 | open: false, |
| 218 | currentView: 'main', |
| 219 | selectedPlatform: 'all', |
| 220 | authState: { ...initialAuthState }, |
| 221 | targetSpaceId: null, |
| 222 | }) |
| 223 | }, |
| 224 | |
| 225 | /** 直接打开并进入指定平台的授权流程 */ |
| 226 | openAndAuth(platform: PlatType, spaceId?: string) { |
| 227 | if (isPlatformDisabledSync(platform)) { |
| 228 | notifyPlatformComingSoon(platform) |
| 229 | return |
| 230 | } |
| 231 | |
| 232 | if (!isPlatformEnabledSync(platform)) { |
| 233 | confirmPlatformRegionRedirect(getPlatformName(platform)) |
| 234 | return |
| 235 | } |
| 236 | |
| 237 | // 如果没有指定空间,使用默认空间 |
| 238 | const accountGroupList = useAccountStore.getState().accountGroupList |
| 239 | const defaultSpace = accountGroupList.find(g => g.isDefault) |
| 240 | const targetSpaceId = spaceId || defaultSpace?.id || null |
| 241 | |
| 242 | set({ |
| 243 | open: true, |
| 244 | currentView: 'auth-loading', |
| 245 | targetSpaceId, |
| 246 | }) |
| 247 | |
| 248 | // 开始授权流程 |
| 249 | methods.startAuth(platform, targetSpaceId || undefined) |
| 250 | }, |
| 251 | |
| 252 | /** 直接打开并进入连接新频道列表页 */ |
| 253 | openConnectList(spaceId?: string) { |
| 254 | // 如果没有指定空间,使用默认空间 |
| 255 | const accountGroupList = useAccountStore.getState().accountGroupList |
| 256 | const defaultSpace = accountGroupList.find(g => g.isDefault) |
| 257 | const targetSpaceId = spaceId || defaultSpace?.id || null |
| 258 | |
| 259 | set({ |
| 260 | open: true, |
| 261 | currentView: 'connect-list', |
| 262 | targetSpaceId, |
| 263 | isNewUser: false, |
| 264 | }) |
| 265 | }, |
| 266 | |
| 267 | /** 设置授权成功回调 */ |
| 268 | setOnAuthSuccess(callback) { |
| 269 | set({ onAuthSuccess: callback }) |
| 270 | }, |
| 271 | |
| 272 | /** 切换视图 */ |
| 273 | setCurrentView(view: ChannelManagerView) { |
| 274 | set({ currentView: view }) |
| 275 | }, |
| 276 | |
| 277 | /** 设置侧边栏选中的平台 */ |
| 278 | setSelectedPlatform(platform) { |
| 279 | set({ selectedPlatform: platform }) |
| 280 | }, |
| 281 | |
| 282 | /** 设置目标空间ID */ |
| 283 | setTargetSpaceId(spaceId) { |
| 284 | set({ targetSpaceId: spaceId }) |
| 285 | }, |
| 286 | |
| 287 | /** 开始授权流程 */ |
| 288 | async startAuth(platform: PlatType, spaceId?: string) { |
| 289 | if (isPlatformDisabledSync(platform)) { |
| 290 | notifyPlatformComingSoon(platform) |
| 291 | set({ |
| 292 | currentView: 'connect-list', |
| 293 | authState: { ...initialAuthState }, |
| 294 | }) |
| 295 | return |
| 296 | } |
| 297 | |
| 298 | if (!isPlatformEnabledSync(platform)) { |
| 299 | confirmPlatformRegionRedirect(getPlatformName(platform)) |
| 300 | set({ |
| 301 | currentView: 'connect-list', |
| 302 | authState: { ...initialAuthState }, |
| 303 | }) |
| 304 | return |
| 305 | } |
| 306 | |
| 307 | // 清理之前的定时器 |
| 308 | clearAllTimers() |
| 309 | |
| 310 | const platformInfo = getPlatformInfoSync(platform) |
| 311 | const authMode: AuthState['authMode'] = platformInfo?.authType === 'qrcode' |
| 312 | ? 'miniappQr' |
| 313 | : 'oauth' |
| 314 | const targetSpaceId = spaceId || get().targetSpaceId || undefined |
| 315 | |
| 316 | // 设置授权状态 |
| 317 | set({ |
| 318 | currentView: 'auth-loading', |
| 319 | authState: { |
| 320 | ...initialAuthState, |
| 321 | platform, |
| 322 | authMode, |
| 323 | isPolling: true, |
| 324 | }, |
| 325 | targetSpaceId: targetSpaceId || null, |
| 326 | }) |
| 327 | |
| 328 | // 检查是否为插件支持的平台 |
| 329 | if (authMode !== 'miniappQr' && isPluginSupportedPlatform(platform)) { |
| 330 | await methods.handlePluginPlatformAuth(platform, targetSpaceId) |
| 331 | return |
| 332 | } |
| 333 | |
| 334 | // OAuth授权流程 |
| 335 | // 先同步打开空白窗口,避免 Safari 拦截弹窗 |
| 336 | const authWindow = authMode === 'oauth' ? window.open('about:blank') : null |
| 337 | try { |
| 338 | // 获取授权URL |
| 339 | const authData = await getAuthUrl(platform, targetSpaceId) |
| 340 | |
| 341 | if (!authData) { |
| 342 | authWindow?.close() |
| 343 | set({ |
| 344 | authState: { |
| 345 | ...get().authState, |
| 346 | error: t('channelManager.authFailedTip'), |
| 347 | isPolling: false, |
| 348 | }, |
| 349 | }) |
| 350 | return |
| 351 | } |
| 352 | |
| 353 | // 更新状态并打开授权窗口/二维码 |
| 354 | set({ |
| 355 | authState: { |
| 356 | ...get().authState, |
| 357 | sessionId: authData.sessionId, |
| 358 | authUrl: authData.url, |
| 359 | countdown: getAuthCountdown(authData.expiresAt), |
| 360 | qrCodeDataUrl: authMode === 'miniappQr' ? authData.url : null, |
| 361 | }, |
| 362 | }) |
| 363 | |
| 364 | // 设置授权窗口地址 |
| 365 | if (authMode === 'oauth') { |
| 366 | if (authWindow) { |
| 367 | authWindow.location.href = authData.url |
| 368 | } |
| 369 | else { |
| 370 | window.open(authData.url) |
| 371 | } |
| 372 | } |
| 373 | |
| 374 | // 启动倒计时 |
| 375 | countdownIntervalId = setInterval(() => { |
| 376 | const currentState = get() |
| 377 | const newCountdown = currentState.authState.countdown - 1 |
| 378 | |
| 379 | if (newCountdown <= 0) { |
| 380 | // 超时 |
| 381 | methods.stopAuth() |
| 382 | set({ |
| 383 | authState: { |
| 384 | ...currentState.authState, |
| 385 | isTimeout: true, |
| 386 | isPolling: false, |
| 387 | countdown: 0, |
| 388 | }, |
| 389 | }) |
| 390 | } |
| 391 | else { |
| 392 | set({ |
| 393 | authState: { |
| 394 | ...currentState.authState, |
| 395 | countdown: newCountdown, |
| 396 | }, |
| 397 | }) |
| 398 | } |
| 399 | }, 1000) |
| 400 | |
| 401 | const pollAuthStatus = async () => { |
| 402 | const authState = get().authState |
| 403 | |
| 404 | if ( |
| 405 | authState.sessionId !== authData.sessionId |
| 406 | || authState.platform !== platform |
| 407 | || !authState.isPolling |
| 408 | ) { |
| 409 | return |
| 410 | } |
| 411 | |
| 412 | const result = await checkAuthStatus(platform, authData.sessionId) |
| 413 | const latestAuthState = get().authState |
| 414 | |
| 415 | if ( |
| 416 | latestAuthState.sessionId !== authData.sessionId |
| 417 | || latestAuthState.platform !== platform |
| 418 | || !latestAuthState.isPolling |
| 419 | ) { |
| 420 | return |
| 421 | } |
| 422 | |
| 423 | if (result?.status === 'completed') { |
| 424 | // 授权成功 |
| 425 | clearAllTimers() |
| 426 | |
| 427 | // 刷新账户列表 |
| 428 | await useAccountStore.getState().getAccountList() |
| 429 | const completedAuthState = get().authState |
| 430 | |
| 431 | if ( |
| 432 | completedAuthState.sessionId !== authData.sessionId |
| 433 | || completedAuthState.platform !== platform |
| 434 | || !completedAuthState.isPolling |
| 435 | ) { |
| 436 | return |
| 437 | } |
| 438 | |
| 439 | // 获取新添加的账户 |
| 440 | const accountList = useAccountStore.getState().accountList |
| 441 | const newAccount = findAuthorizedAccount(accountList, platform, result.data) |
| 442 | |
| 443 | if (newAccount) { |
| 444 | methods.handleAuthSuccess(newAccount) |
| 445 | } |
| 446 | else { |
| 447 | // 没找到新账户,但也算成功 |
| 448 | set({ |
| 449 | currentView: 'main', |
| 450 | selectedPlatform: platform, |
| 451 | authState: { ...initialAuthState }, |
| 452 | isNewUser: false, |
| 453 | }) |
| 454 | } |
| 455 | } |
| 456 | else if (result?.status === 'failed' || result?.message) { |
| 457 | clearAllTimers() |
| 458 | set({ |
| 459 | authState: { |
| 460 | ...latestAuthState, |
| 461 | error: result.message || t('channelManager.authFailedTip'), |
| 462 | isPolling: false, |
| 463 | }, |
| 464 | }) |
| 465 | } |
| 466 | else { |
| 467 | pollingTimerId = setTimeout(() => { |
| 468 | void pollAuthStatus() |
| 469 | }, POLLING_INTERVAL) |
| 470 | } |
| 471 | } |
| 472 | |
| 473 | // 启动轮询:每次请求完成后再安排下一轮,避免接口重叠请求 |
| 474 | pollingTimerId = setTimeout(() => { |
| 475 | void pollAuthStatus() |
| 476 | }, POLLING_INTERVAL) |
| 477 | } |
| 478 | catch (error) { |
| 479 | authWindow?.close() |
| 480 | console.error('Auth error:', error) |
| 481 | set({ |
| 482 | authState: { |
| 483 | ...get().authState, |
| 484 | error: error instanceof Error ? error.message : t('channelManager.authFailedTip'), |
| 485 | isPolling: false, |
| 486 | }, |
| 487 | }) |
| 488 | } |
| 489 | }, |
| 490 | |
| 491 | /** |
| 492 | * 处理插件平台的授权(小红书、抖音等) |
| 493 | * 这些平台通过浏览器插件同步账号,而非OAuth |
| 494 | */ |
| 495 | async handlePluginPlatformAuth(platform: PluginPlatformType, spaceId?: string) { |
| 496 | const pluginStore = usePluginStore.getState() |
| 497 | const platformName = getPlatformInfoSync(platform)?.name || platform |
| 498 | |
| 499 | // 检查插件是否就绪 |
| 500 | if (pluginStore.status !== PluginStatus.READY) { |
| 501 | // 插件未就绪,重置状态并打开插件弹框 |
| 502 | set({ |
| 503 | currentView: 'connect-list', |
| 504 | authState: { ...initialAuthState }, |
| 505 | }) |
| 506 | toast.warning(t('channelManager.pluginNotReady', { platform: platformName })) |
| 507 | // 打开插件弹框引导用户安装/授权 |
| 508 | pluginStore.openPluginModal() |
| 509 | return |
| 510 | } |
| 511 | |
| 512 | // 检查是否有账号 |
| 513 | const account = pluginStore.platformAccounts[platform] |
| 514 | if (!account || !isPluginPlatformAccountReady(account)) { |
| 515 | // 平台未登录,重置状态并打开插件弹框 |
| 516 | set({ |
| 517 | currentView: 'connect-list', |
| 518 | authState: { ...initialAuthState }, |
| 519 | }) |
| 520 | toast.warning(t('channelManager.platformNotLoggedIn', { platform: platformName })) |
| 521 | // 打开插件弹框引导用户登录 |
| 522 | pluginStore.openPluginModal() |
| 523 | return |
| 524 | } |
| 525 | |
| 526 | // 同步账号到数据库 |
| 527 | try { |
| 528 | const result = await pluginStore.syncAccountToDatabase(platform, spaceId) |
| 529 | |
| 530 | if (result) { |
| 531 | // 同步成功 |
| 532 | toast.success(t('channelManager.syncSuccess')) |
| 533 | |
| 534 | // 刷新账户列表 |
| 535 | await useAccountStore.getState().getAccountList() |
| 536 | |
| 537 | // 处理授权成功 |
| 538 | methods.handleAuthSuccess(result) |
| 539 | } |
| 540 | else { |
| 541 | set({ |
| 542 | currentView: 'connect-list', |
| 543 | authState: { ...initialAuthState }, |
| 544 | }) |
| 545 | toast.error(t('channelManager.syncFailed')) |
| 546 | } |
| 547 | } |
| 548 | catch (error) { |
| 549 | console.error('Plugin auth error:', error) |
| 550 | set({ |
| 551 | currentView: 'connect-list', |
| 552 | authState: { ...initialAuthState }, |
| 553 | }) |
| 554 | toast.error(t('channelManager.syncFailed')) |
| 555 | } |
| 556 | }, |
| 557 | |
| 558 | /** 停止授权(取消/超时) */ |
| 559 | stopAuth() { |
| 560 | clearAllTimers() |
| 561 | set({ |
| 562 | authState: { |
| 563 | ...get().authState, |
| 564 | isPolling: false, |
| 565 | }, |
| 566 | }) |
| 567 | }, |
| 568 | |
| 569 | /** 重新打开授权页面 */ |
| 570 | reopenAuthWindow() { |
| 571 | const { authState } = get() |
| 572 | if (authState.authMode === 'miniappQr' && authState.platform) { |
| 573 | methods.startAuth(authState.platform, get().targetSpaceId || undefined) |
| 574 | return |
| 575 | } |
| 576 | if (authState.authUrl) { |
| 577 | window.open(authState.authUrl) |
| 578 | } |
| 579 | }, |
| 580 | |
| 581 | /** 授权成功处理 */ |
| 582 | handleAuthSuccess(account: SocialAccount) { |
| 583 | const { authState, onAuthSuccess, selectedPlatform } = get() |
| 584 | const platform = authState.platform |
| 585 | |
| 586 | // 调用外部回调 |
| 587 | if (onAuthSuccess && platform) { |
| 588 | onAuthSuccess(account, platform) |
| 589 | } |
| 590 | |
| 591 | // 移动端保持原筛选状态,PC 端切换到授权平台 |
| 592 | const isMobile |
| 593 | = typeof window !== 'undefined' |
| 594 | && window.matchMedia('(max-width: 767px)').matches |
| 595 | |
| 596 | // 重置状态,跳转到主页 |
| 597 | set({ |
| 598 | currentView: 'main', |
| 599 | selectedPlatform: isMobile |
| 600 | ? selectedPlatform |
| 601 | : (platform || 'all'), |
| 602 | authState: { ...initialAuthState }, |
| 603 | isNewUser: false, |
| 604 | }) |
| 605 | }, |
| 606 | |
| 607 | /** 重置状态 */ |
| 608 | reset() { |
| 609 | clearAllTimers() |
| 610 | set(getInitialState()) |
| 611 | }, |
| 612 | |
| 613 | /** 检查是否为新用户 */ |
| 614 | checkIsNewUser() { |
| 615 | const accountList = useAccountStore.getState().accountList |
| 616 | set({ isNewUser: accountList.length === 0 }) |
| 617 | }, |
| 618 | } |
| 619 | |
| 620 | return methods |
| 621 | }), |
| 622 | ) |
| 623 |