返回 AiToEarn
PublishDetailModal.tsx
根目录 / project / aitoearn-web / src / components / Plugin / PublishDetailModal.tsx
1 /**
2 * PublishDetailModal - 发布进度弹框组件
3 * 统一展示自动发布、发布任务和需要用户完成的平台进度
4 */
5
6 'use client'
7
8 import type {
9 PlatformPublishMode,
10 PlatformPublishTask,
11 PublishTask,
12 } from '@/store/plugin/types/baseTypes'
13 import { CircleOff, ExternalLink, Loader2, Minimize2, Smartphone } from 'lucide-react'
14 import { useCallback, useEffect, useRef, useState } from 'react'
15 import { useTranslation } from 'react-i18next'
16 import { QRCode } from 'react-qrcode-logo'
17 import { cancelChannelPublishTaskApi } from '@/api/channels/channel.api'
18 import { useCalendarTiming } from '@/app/[lng]/accounts/components/CalendarTiming/useCalendarTiming'
19 import { PlatType } from '@/app/config/platConfig'
20 import { OssImage } from '@/components/common/OssImage'
21 import { Badge } from '@/components/ui/badge'
22 import { Button } from '@/components/ui/button'
23 import { Modal } from '@/components/ui/modal'
24 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
25 import { useDouyinPublishSession } from '@/hooks/useDouyinPublishSession'
26 import { useAccountStore } from '@/store/account'
27 import { getPlatformInfoSync } from '@/store/platformMetadata'
28 import { usePluginStore } from '@/store/plugin'
29 import { PlatformTaskStatus } from '@/store/plugin/types/baseTypes'
30 import { openDeepLink } from '@/utils/appLaunch'
31 import { cn } from '@/utils/className'
32 import { toast } from '@/utils/ui/toast'
33
34 /**
35 * 组件属性
36 */
37 export interface PublishDetailModalProps {
38 /** 是否显示 */
39 visible: boolean
40 /** 关闭回调 */
41 onClose: () => void
42 /** 任务ID(二选一) */
43 taskId?: string
44 /** 任务对象(二选一) */
45 task?: PublishTask
46 /** 发布完成后是否自动关闭弹框(默认 false) */
47 autoCloseOnComplete?: boolean
48 }
49
50 function getPlatformName(platform: PlatType) {
51 const platInfo = getPlatformInfoSync(platform)
52 return platInfo?.name || platform
53 }
54
55 function getStatusClassName(status: PlatformTaskStatus) {
56 switch (status) {
57 case PlatformTaskStatus.COMPLETED:
58 return 'bg-success/10 text-success'
59 case PlatformTaskStatus.PUBLISHING:
60 return 'bg-info/10 text-info'
61 case PlatformTaskStatus.ERROR:
62 return 'bg-destructive/10 text-destructive'
63 case PlatformTaskStatus.CANCELED:
64 return 'bg-muted text-muted-foreground'
65 default:
66 return 'bg-muted text-foreground'
67 }
68 }
69
70 function getProgressColor(status: PlatformTaskStatus) {
71 switch (status) {
72 case PlatformTaskStatus.COMPLETED:
73 return 'bg-success'
74 case PlatformTaskStatus.ERROR:
75 return 'bg-destructive'
76 case PlatformTaskStatus.PUBLISHING:
77 return 'bg-gradient-back'
78 default:
79 return 'bg-muted-foreground/30'
80 }
81 }
82
83 function StatusBadge({ status, label }: { status: PlatformTaskStatus, label: string }) {
84 return (
85 <Badge className={cn('shrink-0 gap-1.5', getStatusClassName(status))}>
86 {status === PlatformTaskStatus.PUBLISHING && <Loader2 aria-hidden className="h-3 w-3 animate-spin" />}
87 {label}
88 </Badge>
89 )
90 }
91
92 function getSectionOrder(mode: PlatformPublishMode) {
93 if (mode === 'user_action')
94 return 0
95 if (mode === 'auto')
96 return 1
97 return 2
98 }
99
100 function getTaskGroups(platformTasks: PlatformPublishTask[]) {
101 const groups = new Map<PlatformPublishMode, PlatformPublishTask[]>()
102 platformTasks.forEach((platformTask) => {
103 const mode = platformTask.publishMode || 'auto'
104 groups.set(mode, [...(groups.get(mode) || []), platformTask])
105 })
106
107 return Array.from(groups.entries())
108 .sort(([leftMode], [rightMode]) => getSectionOrder(leftMode) - getSectionOrder(rightMode))
109 .map(([mode, tasks]) => ({ mode, tasks }))
110 }
111
112 function AccountAvatar({ platformTask }: { platformTask: PlatformPublishTask }) {
113 const accountMap = useAccountStore(state => state.accountMap)
114 const account = platformTask.accountId ? accountMap.get(platformTask.accountId) : null
115 const platInfo = getPlatformInfoSync(platformTask.platform)
116 const fallbackText = account?.nickname?.charAt(0) || '?'
117
118 return (
119 <div className="relative shrink-0">
120 <div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full border border-border bg-muted text-sm font-medium text-muted-foreground">
121 {account?.avatar
122 ? (
123 <OssImage
124 src={account.avatar}
125 alt={account.nickname || platInfo?.name || 'account'}
126 width={40}
127 height={40}
128 className="h-full w-full object-cover"
129 thumbnailSize={40}
130 />
131 )
132 : fallbackText}
133 </div>
134 {platInfo?.icon && (
135 <div className="absolute -bottom-1 -right-1 h-5 w-5 overflow-hidden rounded-full border-2 border-background bg-background">
136 <OssImage src={platInfo.icon} alt={platInfo.name} width={20} height={20} thumbnailSize={20} />
137 </div>
138 )}
139 </div>
140 )
141 }
142
143 type Translate = (key: string, options?: Record<string, string | number>) => string
144
145 const USER_ACTION_QR_CODE_SIZE = 200
146
147 function getSectionTitle(mode: PlatformPublishMode, t: Translate) {
148 if (mode === 'user_action')
149 return t('publishDetail.sectionUserAction')
150 if (mode === 'auto')
151 return t('publishDetail.sectionAuto')
152 return t('publishDetail.sectionTask')
153 }
154
155 function getSectionTitleWithCount(mode: PlatformPublishMode, count: number, t: Translate) {
156 return t('publishDetail.sectionTitleWithCount', {
157 title: getSectionTitle(mode, t),
158 count,
159 })
160 }
161
162 function getTaskDisplayStatus(platformTask: PlatformPublishTask, t: (key: string) => string) {
163 if (platformTask.status === PlatformTaskStatus.CANCELED)
164 return t('common.canceled')
165
166 if (platformTask.publishMode === 'user_action' && platformTask.status === PlatformTaskStatus.PENDING)
167 return t('publishDetail.actionRequired')
168
169 if (platformTask.publishMode === 'task' && platformTask.status === PlatformTaskStatus.COMPLETED)
170 return t('publishDetail.taskCreated')
171
172 return t(`common.${platformTask.status}`)
173 }
174
175 function UserActionPanel({ taskId, platformTask }: { taskId: string, platformTask: PlatformPublishTask }) {
176 const { t } = useTranslation('plugin')
177 const platformName = getPlatformName(platformTask.platform)
178 const [launching, setLaunching] = useState(false)
179 const [canceling, setCanceling] = useState(false)
180 const sessionKey = platformTask.publishRecordId
181 ? `publish-detail:${platformTask.publishRecordId}`
182 : ''
183 const updatePlatformTask = usePluginStore(state => state.updatePlatformTask)
184
185 const handleDouyinPublished = useCallback(async (publishSession: { publishRecordId: string, workLink?: string }) => {
186 updatePlatformTask(taskId, platformTask.id, {
187 status: PlatformTaskStatus.COMPLETED,
188 progress: {
189 stage: 'complete',
190 progress: 100,
191 message: t('publishDetail.userActionPublished'),
192 timestamp: Date.now(),
193 },
194 result: {
195 success: true,
196 workId: publishSession.publishRecordId,
197 shareLink: publishSession.workLink,
198 },
199 endTime: Date.now(),
200 })
201 await useCalendarTiming.getState().refreshCurrentPubRecords()
202 }, [platformTask.id, t, taskId, updatePlatformTask])
203
204 const { session, polling, createSession, clearSession } = useDouyinPublishSession({
205 sessionKey,
206 enabled: platformTask.platform === PlatType.Douyin && !!platformTask.publishRecordId && !!platformTask.userAction,
207 onPublished: handleDouyinPublished,
208 })
209
210 useEffect(() => {
211 if (platformTask.platform !== PlatType.Douyin || !sessionKey || !platformTask.publishRecordId || !platformTask.userAction)
212 return
213
214 if (session?.publishRecordId === platformTask.publishRecordId)
215 return
216
217 createSession({
218 publishRecordId: platformTask.publishRecordId,
219 shortLink: platformTask.userAction.shortLink,
220 permalink: platformTask.userAction.schemeUrl,
221 status: 'polling',
222 })
223 }, [
224 createSession,
225 platformTask.platform,
226 platformTask.publishRecordId,
227 platformTask.userAction?.schemeUrl,
228 platformTask.userAction?.shortLink,
229 session?.publishRecordId,
230 sessionKey,
231 ])
232
233 useEffect(() => {
234 if (session?.status !== 'failed')
235 return
236
237 const errorMessage = session.errorMsg || t('publishDetail.userActionFailed')
238 updatePlatformTask(taskId, platformTask.id, {
239 status: PlatformTaskStatus.ERROR,
240 error: errorMessage,
241 result: {
242 success: false,
243 failReason: errorMessage,
244 },
245 endTime: Date.now(),
246 })
247 }, [platformTask.id, session?.errorMsg, session?.status, t, taskId, updatePlatformTask])
248
249 const handleOpenPlatform = () => {
250 if (!platformTask.userAction?.schemeUrl)
251 return
252
253 setLaunching(true)
254 openDeepLink(platformTask.userAction.schemeUrl, {
255 fallbackUrl: platformTask.userAction.shortLink,
256 onFailed: () => {
257 setLaunching(false)
258 toast.error(t('publishDetail.openPlatformFailed', { platform: platformName }))
259 },
260 })
261 window.setTimeout(() => setLaunching(false), 1800)
262 }
263
264 const handleCancelPublish = async () => {
265 const publishRecordId = platformTask.publishRecordId
266 if (!publishRecordId || canceling)
267 return
268
269 setCanceling(true)
270 try {
271 const res = await cancelChannelPublishTaskApi(publishRecordId)
272 if (!res || res.code !== 0) {
273 toast.error(res?.message || t('publishDetail.cancelPublishFailed'))
274 return
275 }
276
277 updatePlatformTask(taskId, platformTask.id, {
278 status: PlatformTaskStatus.CANCELED,
279 error: null,
280 progress: null,
281 result: null,
282 endTime: Date.now(),
283 })
284 clearSession()
285 await useCalendarTiming.getState().refreshCurrentPubRecords()
286 toast.success(t('publishDetail.cancelPublishSuccess'))
287 }
288 finally {
289 setCanceling(false)
290 }
291 }
292
293 if (!platformTask.userAction) {
294 return (
295 <div className="mt-3 flex items-center gap-2 rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground">
296 <Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
297 {t('publishDetail.preparingUserAction', { platform: platformName })}
298 </div>
299 )
300 }
301
302 return (
303 <div className="mt-3 grid gap-4 border-t border-border pt-3 md:grid-cols-[auto_1fr]">
304 <div className="hidden w-fit items-center justify-center rounded-lg border border-border bg-card p-4 shadow-sm md:flex">
305 <QRCode value={platformTask.userAction.shortLink} size={USER_ACTION_QR_CODE_SIZE} quietZone={8} qrStyle="squares" eyeRadius={4} />
306 </div>
307 <div className="min-w-0 space-y-3">
308 <div>
309 <p className="text-sm font-medium text-foreground">
310 {t('publishDetail.userActionTitle', { platform: platformName })}
311 </p>
312 <p className="mt-1 text-xs text-muted-foreground md:hidden">
313 {t('publishDetail.userActionMobileDesc', { platform: platformName })}
314 </p>
315 <p className="mt-1 hidden text-xs text-muted-foreground md:block">
316 {t('publishDetail.userActionQrDesc', { platform: platformName })}
317 </p>
318 {polling && (
319 <p className="mt-2 inline-flex items-center gap-1.5 text-xs text-primary">
320 <Loader2 className="h-3.5 w-3.5 animate-spin" />
321 {t('publishDetail.userActionPolling')}
322 </p>
323 )}
324 </div>
325 <Button
326 type="button"
327 size="sm"
328 className="w-full cursor-pointer md:hidden"
329 disabled={launching || canceling}
330 onClick={handleOpenPlatform}
331 >
332 {launching && <Loader2 className="h-4 w-4 animate-spin" />}
333 <Smartphone className="h-4 w-4" />
334 {t('publishDetail.openPlatform', { platform: platformName })}
335 </Button>
336 <Button
337 type="button"
338 size="sm"
339 variant="outline"
340 className="w-full cursor-pointer border-warning/30 text-warning hover:bg-warning/10 hover:text-warning md:w-fit"
341 disabled={canceling}
342 onClick={handleCancelPublish}
343 >
344 {canceling ? (
345 <Loader2 className="h-4 w-4 animate-spin" />
346 ) : (
347 <CircleOff className="h-4 w-4" />
348 )}
349 {t('publishDetail.cancelPublish')}
350 </Button>
351 </div>
352 </div>
353 )
354 }
355
356 function PlatformTaskCard({ taskId, platformTask }: { taskId: string, platformTask: PlatformPublishTask }) {
357 const { t } = useTranslation('plugin')
358 const accountMap = useAccountStore(state => state.accountMap)
359 const account = platformTask.accountId ? accountMap.get(platformTask.accountId) : null
360 const progress = platformTask.progress?.progress || 0
361 const platformName = getPlatformName(platformTask.platform)
362 const isAutoPublish = platformTask.publishMode === 'auto'
363 const isUserActionPublish = platformTask.publishMode === 'user_action'
364 const showResultBlock = !!platformTask.result && (
365 platformTask.result.failReason
366 || ((isAutoPublish || isUserActionPublish) && (platformTask.result.workId || platformTask.result.shareLink))
367 )
368
369 return (
370 <div className="rounded-xl border border-border bg-card p-3 shadow-sm">
371 <div className="flex items-start justify-between gap-3">
372 <div className="flex min-w-0 items-center gap-3">
373 <AccountAvatar platformTask={platformTask} />
374 <div className="flex min-w-0 flex-col">
375 <span className="truncate text-sm font-medium text-foreground">
376 {account?.nickname || t('publishDetail.unknownAccount')}
377 </span>
378 <span className="text-xs text-muted-foreground">
379 {platformName}
380 </span>
381 </div>
382 </div>
383
384 <StatusBadge status={platformTask.status} label={getTaskDisplayStatus(platformTask, t)} />
385 </div>
386
387 {platformTask.publishMode === 'user_action' && platformTask.status === PlatformTaskStatus.PENDING && (
388 <UserActionPanel taskId={taskId} platformTask={platformTask} />
389 )}
390
391 {platformTask.publishMode === 'auto' && platformTask.status === PlatformTaskStatus.PENDING && !platformTask.progress && (
392 <p className="mt-3 rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground">
393 {t('publishDetail.autoPendingDesc')}
394 </p>
395 )}
396
397 {isAutoPublish && platformTask.progress && (
398 <div className="mt-3">
399 <div className="mb-1 flex items-center justify-between text-xs">
400 <span className="text-muted-foreground">{t(`stage.${platformTask.progress.stage}`)}</span>
401 <span className="text-muted-foreground">
402 {Math.round(progress)}
403 %
404 </span>
405 </div>
406 <div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
407 <div
408 className={cn(
409 'h-full rounded-full transition-all',
410 getProgressColor(platformTask.status),
411 )}
412 style={{ width: `${progress}%` }}
413 />
414 </div>
415 {platformTask.progress.message && (
416 <p className="mt-1 text-xs text-muted-foreground">{platformTask.progress.message}</p>
417 )}
418 </div>
419 )}
420
421 {showResultBlock && (
422 <div className="mt-3 space-y-1 border-t border-border pt-3">
423 {(isAutoPublish || isUserActionPublish) && platformTask.result?.workId && (
424 <div className="flex items-center gap-2 text-xs">
425 <span className="text-muted-foreground">
426 {t('publishDetail.workId')}
427 :
428 </span>
429 <span className="text-foreground">{platformTask.result.workId}</span>
430 </div>
431 )}
432 {(isAutoPublish || isUserActionPublish) && platformTask.result?.shareLink && (
433 <div className="flex items-center gap-2 text-xs">
434 <span className="text-muted-foreground">
435 {t('publishDetail.shareLink')}
436 :
437 </span>
438 <a
439 href={platformTask.result.shareLink}
440 target="_blank"
441 rel="noopener noreferrer"
442 className="flex items-center gap-1 text-primary hover:text-primary/80"
443 >
444 <ExternalLink className="h-3 w-3" />
445 {t('common.viewLink')}
446 </a>
447 </div>
448 )}
449 {platformTask.result?.failReason && (
450 <div className="flex items-start gap-2 text-xs">
451 <span className="shrink-0 text-muted-foreground">
452 {t('publishDetail.failReason')}
453 :
454 </span>
455 <span className="text-destructive">{platformTask.result.failReason}</span>
456 </div>
457 )}
458 </div>
459 )}
460
461 {platformTask.error && !platformTask.result?.failReason && (
462 <div className="mt-3 border-t border-border pt-3">
463 <p className="text-xs text-destructive">{platformTask.error}</p>
464 </div>
465 )}
466 </div>
467 )
468 }
469
470 function PublishTaskSection({ taskId, mode, platformTasks }: { taskId: string, mode: PlatformPublishMode, platformTasks: PlatformPublishTask[] }) {
471 const { t } = useTranslation('plugin')
472
473 if (platformTasks.length === 0)
474 return null
475
476 return (
477 <section className="space-y-2">
478 <h4 className="text-sm font-medium text-foreground">
479 {getSectionTitleWithCount(mode, platformTasks.length, t)}
480 </h4>
481 <div className="space-y-3">
482 {platformTasks.map(platformTask => (
483 <PlatformTaskCard key={platformTask.id} taskId={taskId} platformTask={platformTask} />
484 ))}
485 </div>
486 </section>
487 )
488 }
489
490 /**
491 * 发布详情弹框组件
492 */
493 export function PublishDetailModal({ visible, onClose, taskId, task, autoCloseOnComplete = false }: PublishDetailModalProps) {
494 const { t } = useTranslation('plugin')
495 const [minimizeTooltipOpen, setMinimizeTooltipOpen] = useState(false)
496 const ignoreInitialMinimizeTooltipOpenRef = useRef(visible)
497 const wasVisibleRef = useRef(visible)
498 const isCreatingRecord = usePluginStore(state => state.isCreatingRecord)
499 const registerPublishDetailModalOpen = usePluginStore(state => state.registerPublishDetailModalOpen)
500 const unregisterPublishDetailModalOpen = usePluginStore(state => state.unregisterPublishDetailModalOpen)
501
502 if (visible && !wasVisibleRef.current) {
503 ignoreInitialMinimizeTooltipOpenRef.current = true
504 }
505
506 // 从 store 获取任务(支持实时更新)
507 const currentTask = usePluginStore((state) => {
508 if (taskId) {
509 return state.publishTasks.find(item => item.id === taskId)
510 }
511 return task
512 })
513
514 // 自动关闭:仅当 autoCloseOnComplete 为 true 时,发布完成且发布记录创建完毕后自动关闭
515 useEffect(() => {
516 if (autoCloseOnComplete && currentTask?.overallStatus === PlatformTaskStatus.COMPLETED && !isCreatingRecord) {
517 onClose()
518 }
519 }, [autoCloseOnComplete, currentTask?.overallStatus, isCreatingRecord, onClose])
520
521 useEffect(() => {
522 wasVisibleRef.current = visible
523
524 if (!visible) {
525 ignoreInitialMinimizeTooltipOpenRef.current = false
526 setMinimizeTooltipOpen(false)
527 return
528 }
529
530 setMinimizeTooltipOpen(false)
531 const timer = window.setTimeout(() => {
532 ignoreInitialMinimizeTooltipOpenRef.current = false
533 }, 300)
534
535 return () => window.clearTimeout(timer)
536 }, [visible])
537
538 const handleMinimizeTooltipOpenChange = (open: boolean) => {
539 if (open && ignoreInitialMinimizeTooltipOpenRef.current) {
540 return
541 }
542
543 setMinimizeTooltipOpen(open)
544 }
545
546 const hasCurrentTask = !!currentTask
547
548 useEffect(() => {
549 if (!visible || !hasCurrentTask)
550 return
551
552 registerPublishDetailModalOpen()
553 return () => unregisterPublishDetailModalOpen()
554 }, [hasCurrentTask, registerPublishDetailModalOpen, unregisterPublishDetailModalOpen, visible])
555
556 if (!currentTask) {
557 return null
558 }
559
560 const taskGroups = getTaskGroups(currentTask.platformTasks)
561 const processingCount = currentTask.platformTasks.filter(platformTask => platformTask.status === PlatformTaskStatus.PUBLISHING).length
562 const actionRequiredCount = currentTask.platformTasks.filter(platformTask => (
563 platformTask.publishMode === 'user_action' && platformTask.status === PlatformTaskStatus.PENDING
564 )).length
565 const hasProcessing = processingCount > 0 || currentTask.platformTasks.some(platformTask => (
566 platformTask.publishMode === 'task' && platformTask.status === PlatformTaskStatus.PENDING
567 ))
568 const title = hasProcessing || actionRequiredCount > 0
569 ? t('publishDetail.progressTitle')
570 : t('publishDetail.completeTitle')
571 const statusLabel = actionRequiredCount > 0 && currentTask.overallStatus === PlatformTaskStatus.PENDING
572 ? t('publishDetail.actionRequired')
573 : t(`common.${currentTask.overallStatus}`)
574
575 return (
576 <Modal
577 open={visible}
578 onCancel={onClose}
579 title={(
580 <div className="flex w-full items-center justify-between gap-3 pr-1">
581 <div className="flex min-w-0 items-center gap-3">
582 <span className="truncate">{title}</span>
583 <StatusBadge status={currentTask.overallStatus} label={statusLabel} />
584 </div>
585 <TooltipProvider delayDuration={150}>
586 <Tooltip open={minimizeTooltipOpen} onOpenChange={handleMinimizeTooltipOpenChange}>
587 <TooltipTrigger asChild>
588 <Button
589 type="button"
590 variant="ghost"
591 size="icon"
592 className="h-9 w-9 shrink-0 cursor-pointer rounded-full border border-primary/30 bg-primary/10 text-primary shadow-sm transition-all hover:border-primary/50 hover:bg-primary/15 hover:text-primary focus-visible:ring-primary/40"
593 onClick={onClose}
594 aria-label={t('publishDetail.minimize')}
595 >
596 <Minimize2 aria-hidden className="h-4 w-4" />
597 </Button>
598 </TooltipTrigger>
599 <TooltipContent side="bottom" align="end" className="z-[1001]">
600 {t('publishDetail.minimize')}
601 </TooltipContent>
602 </Tooltip>
603 </TooltipProvider>
604 </div>
605 )}
606 footer={isCreatingRecord ? (
607 <div className="flex w-full items-center justify-center gap-2 py-2">
608 <Loader2 className="h-4 w-4 animate-spin text-primary" />
609 <span className="text-sm text-muted-foreground">{t('publishDetail.creatingRecord')}</span>
610 </div>
611 ) : null}
612 width={640}
613 zIndex={1000}
614 closable={false}
615 >
616 <div className="space-y-4">
617 {taskGroups.map(group => (
618 <PublishTaskSection key={group.mode} taskId={currentTask.id} mode={group.mode} platformTasks={group.tasks} />
619 ))}
620 </div>
621 </Modal>
622 )
623 }
624
624 lines Plain Text