返回 AiToEarn
accountCore.tsx
根目录 / project / aitoearn-web / src / app / [lng] / accounts / accountCore.tsx
1 'use client'
2
3 import type { IPublishDialogRef } from '@/components/PublishDialog'
4 import { NoSSR } from '@kwooshung/react-no-ssr'
5 import Image from 'next/image'
6 import { useEffect, useRef, useState } from 'react'
7 import { useShallow } from 'zustand/react/shallow'
8 import AccountsTopNav from '@/app/[lng]/accounts/components/AccountsTopNav'
9 import CalendarTiming from '@/app/[lng]/accounts/components/CalendarTiming'
10 import { AccountStatus } from '@/app/config/accountConfig'
11 import { PlatType } from '@/app/config/platConfig'
12 import { useTransClient } from '@/app/i18n/client'
13 import rightArrow from '@/assets/images/jiantou.png'
14 import { useChannelManagerStore } from '@/components/ChannelManager'
15 import PublishDialog from '@/components/PublishDialog'
16 import { VideoGrabFrame } from '@/components/PublishDialog/PublishDialog.util'
17 import { usePublishDialog } from '@/components/PublishDialog/usePublishDialog'
18 import { useAccountStore } from '@/store/account'
19 import { generateUUID } from '@/utils/common'
20 import { useCalendarTiming } from './components/CalendarTiming/useCalendarTiming'
21 import { useNewWork } from './hooks/useNewWork'
22 import 'driver.js/dist/driver.css'
23
24 interface AccountPageCoreProps {
25 searchParams?: {
26 platform?: string
27 spaceId?: string
28 addChannel?: string // 添加频道引导参数
29 updateChannel?: string // 更新频道授权参数
30 action?: string // 动作类型:publish 等
31 // AI生成的内容参数
32 aiGenerated?: string
33 accountId?: string
34 taskId?: string
35 title?: string
36 description?: string
37 tags?: string
38 medias?: string
39 }
40 }
41
42 export default function AccountPageCore({ searchParams }: AccountPageCoreProps) {
43 const { accountInit, accountLoading, accountListInitialized } = useAccountStore(
44 useShallow(state => ({
45 accountInit: state.accountInit,
46 accountLoading: state.accountLoading,
47 accountListInitialized: state.accountListInitialized,
48 })),
49 )
50
51 const { t } = useTransClient('account')
52
53 // 频道管理器相关方法
54 const { openConnectList, openAndAuth } = useChannelManagerStore(
55 useShallow(state => ({
56 openConnectList: state.openConnectList,
57 openAndAuth: state.openAndAuth,
58 })),
59 )
60
61 // 微信浏览器提示弹窗开关
62 const [showWechatBrowserTip, setShowWechatBrowserTip] = useState(false)
63 // 发布弹窗状态
64 const [publishDialogOpen, setPublishDialogOpen] = useState(false)
65 const [defaultAccountIds, setDefaultAccountIds] = useState<string[]>()
66 const [aiGeneratedData, setAiGeneratedData] = useState<any>(null)
67 const publishDialogRef = useRef<IPublishDialogRef>(null)
68 const accountListInitialLoading = accountLoading && !accountListInitialized
69
70 // 使用新建作品 hook
71 const { openNewWork, allAccounts } = useNewWork({
72 publishDialogRef,
73 setPublishDialogOpen,
74 setDefaultAccountIds,
75 })
76
77 useEffect(() => {
78 accountInit()
79 }, [])
80
81 // 处理URL参数
82 useEffect(() => {
83 // 处理更新频道授权(直接打开授权弹窗并自动触发)
84 if (searchParams?.updateChannel) {
85 const platform = searchParams.updateChannel as PlatType
86 const validPlatforms = Object.values(PlatType)
87
88 if (validPlatforms.includes(platform)) {
89 // 直接打开频道管理器并触发授权
90 setTimeout(() => {
91 openAndAuth(platform)
92 }, 500)
93
94 // 清除URL参数
95 if (typeof window !== 'undefined') {
96 const url = new URL(window.location.href)
97 url.searchParams.delete('updateChannel')
98 window.history.replaceState({}, '', url.toString())
99 }
100 }
101 return
102 }
103
104 // Handle AI-generated content params
105 if (searchParams?.aiGenerated === 'true' && allAccounts.length > 0) {
106 try {
107 const medias = searchParams.medias
108 ? JSON.parse(decodeURIComponent(searchParams.medias))
109 : []
110 const tags = searchParams.tags ? JSON.parse(decodeURIComponent(searchParams.tags)) : []
111
112 const data = {
113 taskId: searchParams.taskId,
114 title: searchParams.title ? decodeURIComponent(searchParams.title) : '',
115 description: searchParams.description ? decodeURIComponent(searchParams.description) : '',
116 tags,
117 medias,
118 }
119
120 setAiGeneratedData(data)
121
122 // 选择账号:优先使用 accountId,其次选择对应平台的账号
123 let targetAccount = null
124
125 if (searchParams.accountId) {
126 // 如果指定了 accountId,查找该账号
127 targetAccount = allAccounts.find(account => account.id === searchParams.accountId)
128 }
129 else if (searchParams.platform) {
130 // 如果指定了 platform,选择该平台的第一个在线账号
131 const platform = searchParams.platform as PlatType
132 targetAccount = allAccounts.find((account) => {
133 const isOnline = account.status === AccountStatus.USABLE
134 const isPlatformMatch = account.type === platform
135 return isOnline && isPlatformMatch
136 })
137 // 如果没有在线账号,选择该平台的第一个账号
138 if (!targetAccount) {
139 targetAccount = allAccounts.find(account => account.type === platform)
140 }
141 }
142 else {
143 // 没有指定平台,选择第一个在线账户
144 targetAccount = allAccounts.find((account) => {
145 const isOnline = account.status === AccountStatus.USABLE
146 return isOnline
147 })
148 }
149
150 if (targetAccount) {
151 setDefaultAccountIds([targetAccount.id])
152 }
153 else if (allAccounts[0]) {
154 // 如果没有找到符合条件的账户,退而求其次选择第一个账户
155 setDefaultAccountIds([allAccounts[0].id])
156 }
157
158 // Open publish dialog
159 setTimeout(() => {
160 setPublishDialogOpen(true)
161 }, 500)
162
163 // Clear URL params
164 if (typeof window !== 'undefined') {
165 const url = new URL(window.location.href)
166 url.searchParams.delete('action')
167 url.searchParams.delete('aiGenerated')
168 url.searchParams.delete('platform')
169 url.searchParams.delete('accountId')
170 url.searchParams.delete('taskId')
171 url.searchParams.delete('title')
172 url.searchParams.delete('description')
173 url.searchParams.delete('tags')
174 url.searchParams.delete('medias')
175 window.history.replaceState({}, '', url.toString())
176 }
177 }
178 catch (error) {
179 console.error('Failed to parse AI generated data:', error)
180 }
181 }
182
183 // 注意:只有在不是 AI 发布场景时才处理 platform 参数打开添加账号弹窗
184 if (
185 (searchParams?.platform || searchParams?.spaceId)
186 && searchParams?.action !== 'publish'
187 && searchParams?.aiGenerated !== 'true'
188 ) {
189 // 验证平台类型是否有效
190 const platform = searchParams.platform as PlatType
191 const validPlatforms = Object.values(PlatType)
192 const spaceId = searchParams.spaceId
193
194 if (searchParams.platform && validPlatforms.includes(platform)) {
195 // 有指定平台,直接授权
196 openAndAuth(platform, spaceId)
197 }
198 else if (spaceId) {
199 // 只有spaceId,打开连接频道列表
200 openConnectList(spaceId)
201 }
202 }
203 }, [searchParams, allAccounts.length, openAndAuth, openConnectList])
204
205 /**
206 * 检测是否为微信浏览器
207 */
208 const isWechatBrowser = () => {
209 if (typeof window === 'undefined')
210 return false
211 const ua = window.navigator.userAgent.toLowerCase()
212 return ua.includes('micromessenger')
213 }
214
215 /**
216 * 在移动端首次进入时,如果是微信浏览器,显示微信浏览器提示
217 */
218 useEffect(() => {
219 if (typeof window === 'undefined')
220 return
221 const isMobile = window.innerWidth <= 768
222 const hasShownWechatTip = sessionStorage.getItem('accountsWechatTipShown')
223
224 if (isMobile && isWechatBrowser() && !hasShownWechatTip) {
225 setShowWechatBrowserTip(true)
226 sessionStorage.setItem('accountsWechatTipShown', '1')
227 }
228 }, [])
229
230 /**
231 * 关闭微信浏览器提示弹窗
232 */
233 const closeWechatBrowserTip = () => {
234 setShowWechatBrowserTip(false)
235 }
236
237 const wechatBrowserTexts = (() => {
238 return {
239 title: t('browserTip.title'),
240 desc: t('browserTip.description'),
241 cta: t('browserTip.button'),
242 }
243 })()
244
245 // Fill AI-generated data after publish dialog opens
246 useEffect(() => {
247 if (aiGeneratedData && publishDialogOpen && allAccounts.length > 0) {
248 let innerTimeoutId: ReturnType<typeof setTimeout> | undefined
249 // Delay filling to ensure PublishDialog is fully initialized
250 const timeoutId = setTimeout(() => {
251 try {
252 const store = usePublishDialog.getState()
253
254 // If pubList is not initialized yet, retry after delay
255 if (!store.pubList || store.pubList.length === 0) {
256 innerTimeoutId = setTimeout(() => {
257 const retryStore = usePublishDialog.getState()
258 if (retryStore.pubList && retryStore.pubList.length > 0) {
259 fillAIData(retryStore)
260 }
261 }, 1000)
262 return
263 }
264
265 fillAIData(store)
266 }
267 catch (error) {
268 console.error('Failed to fill AI data:', error)
269 }
270 }, 1000)
271
272 // Helper function to fill data
273 const fillAIData = async (store: any) => {
274 // Build params - append tags to description
275 let description = aiGeneratedData.description || ''
276 if (aiGeneratedData.tags && aiGeneratedData.tags.length > 0) {
277 const tagsText = aiGeneratedData.tags.map((tag: string) => `#${tag}`).join(' ')
278 description = `${description}\n\n${tagsText}`
279 }
280
281 const params: any = {
282 des: description,
283 title: aiGeneratedData.title || '',
284 }
285
286 // Handle media files - support multiple medias
287 const medias = aiGeneratedData.medias || []
288
289 if (medias.length > 0) {
290 // Check if there's a video
291 const videoMedia = medias.find((m: any) => m.type === 'VIDEO')
292 if (videoMedia) {
293 try {
294 let coverInfo
295
296 // If API returned cover URL, use it directly (support both coverUrl and thumbUrl)
297 const coverUrl = videoMedia.coverUrl || videoMedia.thumbUrl
298 if (coverUrl) {
299 // Load cover image to get dimension info
300 coverInfo = await new Promise((resolve) => {
301 const img = document.createElement('img')
302 img.crossOrigin = 'anonymous'
303 img.onload = () => {
304 resolve({
305 id: generateUUID(),
306 width: img.width,
307 height: img.height,
308 imgUrl: coverUrl,
309 ossUrl: coverUrl,
310 filename: `ai_${aiGeneratedData.taskId}_cover.jpg`,
311 imgPath: '',
312 size: 0,
313 file: null as any,
314 })
315 }
316 img.onerror = () => {
317 resolve(null)
318 }
319 img.src = coverUrl
320 })
321 }
322
323 // If no cover URL or cover load failed, try to extract from video
324 if (!coverInfo) {
325 try {
326 const videoInfo = await VideoGrabFrame(videoMedia.url, 0)
327
328 params.video = {
329 size: 0,
330 file: null as any,
331 videoUrl: videoMedia.url,
332 ossUrl: videoMedia.url,
333 filename: `ai_${aiGeneratedData.taskId}.mp4`,
334 width: videoInfo.width,
335 height: videoInfo.height,
336 duration: videoInfo.duration,
337 cover: videoInfo.cover,
338 }
339 }
340 catch (extractError) {
341 // Cross-origin video cannot extract cover, use placeholder
342 // Create a cover using video URL as imgUrl (browser will auto-display first frame)
343 const video = document.createElement('video')
344 video.src = videoMedia.url
345 video.crossOrigin = 'anonymous'
346
347 await new Promise((resolve) => {
348 video.addEventListener('loadedmetadata', () => {
349 // Use video URL as cover imgUrl, browser video tag's poster will handle it automatically
350 const placeholderCover: any = {
351 id: generateUUID(),
352 size: 0,
353 file: null as any,
354 imgUrl: videoMedia.url, // Use video URL, video tag will show first frame
355 filename: `ai_${aiGeneratedData.taskId}_cover.jpg`,
356 imgPath: '',
357 width: video.videoWidth,
358 height: video.videoHeight,
359 ossUrl: '', // No separate cover URL
360 }
361
362 params.video = {
363 size: 0,
364 file: null as any,
365 videoUrl: videoMedia.url,
366 ossUrl: videoMedia.url,
367 filename: `ai_${aiGeneratedData.taskId}.mp4`,
368 width: video.videoWidth,
369 height: video.videoHeight,
370 duration: Math.floor(video.duration),
371 cover: placeholderCover,
372 }
373 video.remove()
374 resolve(null)
375 })
376 video.addEventListener('error', () => {
377 // Complete failure, use default values
378 const defaultCover: any = {
379 id: generateUUID(),
380 size: 0,
381 file: null as any,
382 imgUrl: videoMedia.url,
383 filename: `ai_${aiGeneratedData.taskId}_cover.jpg`,
384 imgPath: '',
385 width: 1920,
386 height: 1080,
387 ossUrl: '',
388 }
389
390 params.video = {
391 size: 0,
392 file: null as any,
393 videoUrl: videoMedia.url,
394 ossUrl: videoMedia.url,
395 filename: `ai_${aiGeneratedData.taskId}.mp4`,
396 width: 1920,
397 height: 1080,
398 duration: 0,
399 cover: defaultCover,
400 }
401 video.remove()
402 resolve(null)
403 })
404 video.load()
405 })
406 }
407 }
408 else {
409 // Use API-returned cover, but still need to get width/height/duration from video
410 const video = document.createElement('video')
411 video.src = videoMedia.url
412 video.crossOrigin = 'anonymous'
413
414 await new Promise((resolve) => {
415 video.addEventListener('loadedmetadata', () => {
416 params.video = {
417 size: 0,
418 file: null as any,
419 videoUrl: videoMedia.url,
420 ossUrl: videoMedia.url,
421 filename: `ai_${aiGeneratedData.taskId}.mp4`,
422 width: video.videoWidth,
423 height: video.videoHeight,
424 duration: Math.floor(video.duration),
425 cover: coverInfo,
426 }
427 video.remove()
428 resolve(null)
429 })
430 video.addEventListener('error', () => {
431 // If video metadata loading fails, use default dimensions
432 params.video = {
433 size: 0,
434 file: null as any,
435 videoUrl: videoMedia.url,
436 ossUrl: videoMedia.url,
437 filename: `ai_${aiGeneratedData.taskId}.mp4`,
438 width: 1920,
439 height: 1080,
440 duration: 0,
441 cover: coverInfo,
442 }
443 video.remove()
444 resolve(null)
445 })
446 video.load()
447 })
448 }
449
450 params.images = []
451 }
452 catch (error) {
453 console.error('Failed to process video:', error)
454 // If all methods fail, use default cover
455 const defaultCover: any = {
456 id: generateUUID(),
457 size: 0,
458 file: null as any,
459 imgUrl: '', // Empty, will show default icon
460 filename: `ai_${aiGeneratedData.taskId}_cover.jpg`,
461 imgPath: '',
462 width: 1920,
463 height: 1080,
464 ossUrl: '',
465 }
466
467 params.video = {
468 size: 0,
469 file: null as any,
470 videoUrl: videoMedia.url,
471 ossUrl: videoMedia.url,
472 filename: `ai_${aiGeneratedData.taskId}.mp4`,
473 width: 1920,
474 height: 1080,
475 duration: 0,
476 cover: defaultCover,
477 }
478 params.images = []
479 }
480 }
481 else {
482 // Process all images
483 const imageMedias = medias.filter((m: any) => m.type === 'IMAGE')
484 if (imageMedias.length > 0) {
485 params.images = imageMedias.map((media: any, index: number) => ({
486 id: generateUUID(),
487 size: 0,
488 file: null as any,
489 imgUrl: media.url, // Use ossUrl as preview URL
490 filename: `ai_${aiGeneratedData.taskId}_${index + 1}.jpg`,
491 imgPath: '',
492 width: 1920,
493 height: 1080,
494 ossUrl: media.url, // AI-generated images already have ossUrl
495 }))
496 params.video = undefined
497 }
498 }
499 }
500
501 // Fill data to first selected account
502 if (store.pubListChoosed && store.pubListChoosed.length > 0) {
503 store.setOnePubParams(params, store.pubListChoosed[0].account.id)
504 }
505 }
506
507 return () => {
508 clearTimeout(timeoutId)
509 if (innerTimeoutId)
510 clearTimeout(innerTimeoutId)
511 }
512 }
513 }, [aiGeneratedData, publishDialogOpen, allAccounts.length])
514
515 return (
516 <NoSSR>
517 <div className="flex flex-col h-full bg-background">
518 {/* SEO: h1 标题 - 视觉隐藏但对搜索引擎可见 */}
519 <h1 className="sr-only">{t('title')}</h1>
520
521 {/* Row 1: 顶部导航栏 */}
522 <AccountsTopNav onNewWork={() => openNewWork()} onAddAccount={() => openConnectList()} />
523
524 {/* 主内容区域: CalendarTiming (包含 Row 2 工具栏和日历/列表视图) */}
525 <CalendarTiming />
526
527 {/* 微信浏览器提示(遮罩 + 箭头指向右上角) */}
528 {showWechatBrowserTip && (
529 <>
530 <div
531 className="fixed inset-0 bg-black/85 z-[1000] animate-[fadeIn_0.2s_ease-out]"
532 onClick={closeWechatBrowserTip}
533 />
534 <Image
535 src={rightArrow}
536 alt="rightArrow"
537 width={120}
538 height={120}
539 className="fixed top-[10%] right-5 z-[1002] pointer-events-none bg-accent animate-[arrowPulse_2s_ease-in-out_infinite] rounded-full p-2.5"
540 />
541 <div className="fixed inset-0 z-[1001] flex items-center justify-center pointer-events-none">
542 <div className="bg-background/95 rounded-2xl p-6 mx-5 max-w-[320px] shadow-[0_10px_40px_rgba(0,0,0,0.3)] backdrop-blur-[10px] pointer-events-auto animate-[tipFadeIn_0.3s_ease-out]">
543 <div className="text-lg font-bold text-foreground text-center mb-5">
544 {wechatBrowserTexts.title}
545 </div>
546 <div className="mb-5">
547 <div className="flex items-center gap-3 mb-3 text-sm text-foreground leading-normal">
548 <span className="bg-gradient-back text-gradient-foreground w-6 h-6 rounded-full flex items-center justify-center text-xs font-semibold flex-shrink-0 shadow-sm shadow-primary/20">
549 1
550 </span>
551 <span className="flex-1">
552 {t('wechatBrowserTip.clickCorner')}
553 <span className="bg-muted text-muted-foreground px-2 py-0.5 rounded text-xs mx-1 inline-block">
554
555 </span>
556 {t('wechatBrowserTip.dotsButton')}
557 </span>
558 </div>
559 <div className="flex items-center gap-3 mb-3 text-sm text-foreground leading-normal">
560 <span className="bg-gradient-back text-gradient-foreground w-6 h-6 rounded-full flex items-center justify-center text-xs font-semibold flex-shrink-0 shadow-sm shadow-primary/20">
561 2
562 </span>
563 <span className="flex-1">
564 {t('wechatBrowserTip.selectBrowser')}
565 <span className="bg-muted text-muted-foreground px-1.5 py-0.5 rounded text-xs mx-1 inline-block">
566 🌐
567 </span>
568 {t('wechatBrowserTip.openInBrowser')}
569 </span>
570 </div>
571 </div>
572 <button
573 className="w-full bg-gradient-back text-gradient-foreground border-none px-3 py-3 rounded-lg font-semibold cursor-pointer shadow-sm shadow-primary/20 transition-all hover:shadow-md hover:shadow-primary/25"
574 onClick={closeWechatBrowserTip}
575 >
576 {wechatBrowserTexts.cta}
577 </button>
578 </div>
579 </div>
580 </>
581 )}
582
583 {/* 发布作品弹窗 */}
584 <PublishDialog
585 ref={publishDialogRef}
586 open={publishDialogOpen}
587 onClose={() => {
588 setPublishDialogOpen(false)
589 setAiGeneratedData(null)
590 setDefaultAccountIds(undefined)
591 }}
592 accounts={allAccounts}
593 defaultAccountIds={defaultAccountIds}
594 accountListInitialLoading={accountListInitialLoading}
595 onPubSuccess={() => {
596 setPublishDialogOpen(false)
597 setAiGeneratedData(null)
598 setDefaultAccountIds(undefined)
599 useCalendarTiming.getState().getPubRecord()
600 }}
601 />
602 </div>
603 </NoSSR>
604 )
605 }
606
606 lines Plain Text