| 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 |